Compare commits

..
5 Commits
59 changed files with 2162 additions and 6426 deletions
+28 -184
View File
@@ -14,152 +14,28 @@ on:
- master
workflow_dispatch:
# Only one build per ref at a time: two QEMU-emulated Gradle builds in parallel
# take the ARM64 runner down (both runs then die at the same instant).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Keep in sync with .github/workflows/build-ios.yaml and pubspec.yaml (environment).
FLUTTER_VERSION: '3.47.4'
# Static qemu-x86_64 for the ARM64 runner (Debian trixie `qemu-user`, see below).
QEMU_DEB_VERSION: '10.0.13+ds-0+deb13u1'
QEMU_DEB_SHA1: '02b553a4532192bc999d15408d1e56d7b254906f'
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
container:
# Cirrus Labs stopped publishing Flutter images in May 2026 (last tag: 3.44.0,
# multi-arch amd64/arm64, Ubuntu 24.04, OpenJDK 21, Android SDK 36). The image is
# only used for its JDK + Android SDK; the bundled Flutter SDK is switched to
# FLUTTER_VERSION in the first step.
image: ghcr.io/cirruslabs/flutter:3.44.0
options: --privileged
image: ghcr.io/cirruslabs/flutter:3.19.3
steps:
- name: Switch Flutter SDK to pinned version
# act_runner defaults to `sh -e` (dash); bash is needed for `pipefail`.
shell: bash
run: |
set -euo pipefail
FLUTTER_ROOT="${FLUTTER_ROOT:-/sdks/flutter}"
git config --global --add safe.directory "$FLUTTER_ROOT"
git config --global --add safe.directory '*'
CURRENT="$(git -C "$FLUTTER_ROOT" describe --tags --exact-match 2>/dev/null || true)"
if [ "$CURRENT" != "$FLUTTER_VERSION" ]; then
echo "Switching Flutter SDK from '${CURRENT:-unknown}' to '$FLUTTER_VERSION'"
git -C "$FLUTTER_ROOT" fetch --depth 1 origin "refs/tags/${FLUTTER_VERSION}:refs/tags/${FLUTTER_VERSION}"
git -C "$FLUTTER_ROOT" checkout -q "$FLUTTER_VERSION"
# Drop the cache of the previous version; the Dart SDK and the engine
# artifacts for the new version are downloaded on first use.
rm -rf "$FLUTTER_ROOT/bin/cache"
fi
flutter --version
- name: Setup Node.js 20 & ARM64 Flutter Engine
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; else NODE_ARCH="x64"; fi
curl -fsSL "https://nodejs.org/dist/v20.18.0/node-v20.18.0-linux-${NODE_ARCH}.tar.gz" | tar -xzf - -C /usr/local --strip-components=1
node --version
# On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..."
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc llvm
# Setup x86_64 dynamic libraries & prefix for QEMU emulation
mkdir -p /etc/qemu-binfmt
ln -sf /usr/x86_64-linux-gnu /etc/qemu-binfmt/x86_64
mkdir -p /lib64 /usr/lib64
ln -sf /usr/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
ln -sf /usr/x86_64-linux-gnu/lib/* /lib64/ 2>/dev/null || true
ln -sf /usr/x86_64-linux-gnu/lib/* /usr/lib64/ 2>/dev/null || true
export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu
echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV"
# Ubuntu 24.04 ships QEMU 8.2.2, whose x86_64 user-mode emulation dies with
# "QEMU internal SIGSEGV {code=MAPERR, addr=0x20}" as soon as the guest calls
# pthread_getattr_np() - which Dart's gen_snapshot does since Flutter 3.4x
# (https://gitlab.com/qemu-project/qemu/-/issues/2170, fixed in 8.2.3/9.0).
# Replace it with the static binary from Debian trixie's qemu-user package.
QEMU_DEB="qemu-user_${QEMU_DEB_VERSION}_arm64.deb"
curl -fsSL -o /tmp/qemu-user.deb "https://deb.debian.org/debian/pool/main/q/qemu/${QEMU_DEB}" ||
curl -fsSL -o /tmp/qemu-user.deb "https://snapshot.debian.org/file/${QEMU_DEB_SHA1}"
echo "${QEMU_DEB_SHA1} /tmp/qemu-user.deb" | sha1sum -c -
dpkg-deb -x /tmp/qemu-user.deb /tmp/qemu-user
install -m 0755 /tmp/qemu-user/usr/bin/qemu-x86_64 /usr/bin/qemu-x86_64-static
rm -rf /tmp/qemu-user /tmp/qemu-user.deb
/usr/bin/qemu-x86_64-static --version | head -n 1
# Must match the path hard-coded in scripts/ci/qemu_intercept.c.
QEMU_BIN=/usr/bin/qemu-x86_64-static
# Dummy fallbacks for FEX binaries/sockets
for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
printf '#!/bin/sh\nexit 0\n' > "$fex"
chmod +x "$fex" 2>/dev/null || true
done
flutter precache --android
# Flutter (still) only ships an x86_64 gen_snapshot for Linux hosts; wrap it
# with QEMU under the linux-arm64 path the tool expects.
for dir in "${FLUTTER_ROOT:-/sdks/flutter}"/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ] && [ ! -x "$dir/linux-arm64/gen_snapshot" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec env -u LD_PRELOAD %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
chmod +x "$dir/linux-arm64/gen_snapshot"
echo "Configured gen_snapshot wrapper for $(basename "$dir")"
fi
done
# AGP strips libflutter.so/libapp.so with the NDK's llvm-strip; unstripped they
# are ~15x larger (165 MB instead of 12 MB per ABI). The NDK's x86_64 llvm-strip
# cannot run under QEMU (needs libz.so.1 etc. in the cross sysroot), so install
# the NDK Flutter expects up front and swap in Ubuntu's native arm64 llvm-strip.
NDK_VERSION="$(sed -nE 's/.*ndkVersion: String = "([^"]+)".*/\1/p' "${FLUTTER_ROOT:-/sdks/flutter}/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt")"
[ -n "$NDK_VERSION" ] || { echo "::error::Could not determine Flutter's NDK version"; exit 1; }
echo "Installing NDK $NDK_VERSION ..."
yes | sdkmanager --install "ndk;${NDK_VERSION}" > /dev/null
NDK_BIN="${ANDROID_HOME:-/opt/android-sdk-linux}/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin"
for tool in llvm-strip llvm-objcopy; do
mv "$NDK_BIN/$tool" "$NDK_BIN/$tool.x86_64"
ln -s "$(command -v "$tool")" "$NDK_BIN/$tool"
done
"$NDK_BIN/llvm-strip" --version | head -n 2
fi
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup QEMU Interceptor
- name: Configure Git Safe Directory
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Compiling transparent QEMU execution interceptor..."
gcc -O2 -shared -fPIC -o /usr/local/lib/libqemu_intercept.so scripts/ci/qemu_intercept.c -ldl
echo "LD_PRELOAD=/usr/local/lib/libqemu_intercept.so" >> "$GITHUB_ENV"
rm -f /etc/ld.so.preload
echo "Enabled LD_PRELOAD interceptor via GITHUB_ENV"
fi
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
- name: Make Gradlew Executable
run: |
if [ -f android/gradlew ]; then
chmod +x android/gradlew
fi
run: chmod +x android/gradlew
- name: Inspect Build Environment
run: |
flutter --version
java -version
node --version
- name: Fetch Flutter Dependencies
run: flutter pub get
@@ -169,67 +45,35 @@ jobs:
flutter analyze
flutter test
- name: Build Release APKs (Split-per-ABI + Universal)
# android/app/build.gradle re-enables the universal APK for split builds, so
# this one Gradle run yields all four APKs in build/app/outputs/flutter-apk/.
- name: Build Universal Release APK
run: flutter build apk --release
- name: Build Split-per-ABI Release APKs
run: flutter build apk --release --split-per-abi
- name: Organize Artifacts
run: |
mkdir -p release-apks
APK_DIR=build/app/outputs/flutter-apk
echo "=== Flutter APK outputs ==="
ls -lah "$APK_DIR"
cp "$APK_DIR/app-release.apk" release-apks/oggauer-gemeindetrommler-universal.apk
cp "$APK_DIR/app-arm64-v8a-release.apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
cp "$APK_DIR/app-armeabi-v7a-release.apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
cp "$APK_DIR/app-x86_64-release.apk" release-apks/oggauer-gemeindetrommler-x86_64.apk
# Copy universal APK
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
fi
# Copy split APKs
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
fi
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
fi
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
fi
echo "=== Generated APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
uses: actions/upload-artifact@v3
- name: Upload APKs to Gitea Artifacts
uses: actions/upload-artifact@v4
with:
name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7
if-no-files-found: error
- name: Upload ARM64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-arm64-v8a
path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
retention-days: 7
if-no-files-found: error
- name: Upload ARMv7 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-armeabi-v7a
path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
retention-days: 7
if-no-files-found: error
- name: Upload x86_64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-x86_64
path: release-apks/oggauer-gemeindetrommler-x86_64.apk
retention-days: 7
if-no-files-found: error
- name: Publish Direct APKs to Gitea Releases
continue-on-error: true
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_API: ${{ github.api_url }}
REPO: ${{ github.repository }}
RELEASE_TAG: latest
run: |
node scripts/ci/publish_release.js
- name: Clean Workspace Disk Space
if: always()
run: |
rm -rf build android/.gradle release-apks 2>/dev/null || true
name: oggauer-gemeindetrommler-apks
path: release-apks/*.apk
retention-days: 30
+79
View File
@@ -0,0 +1,79 @@
name: Build Android APK
on:
push:
branches:
- main
- master
- 'refactor/**'
tags:
- 'v*'
pull_request:
branches:
- main
- master
workflow_dispatch:
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:3.19.3
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Configure Git Safe Directory
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
- name: Make Gradlew Executable
run: chmod +x android/gradlew
- name: Inspect Build Environment
run: |
flutter --version
java -version
- name: Fetch Flutter Dependencies
run: flutter pub get
- name: Run Code Analysis & Tests
run: |
flutter analyze
flutter test
- name: Build Universal Release APK
run: flutter build apk --release
- name: Build Split-per-ABI Release APKs
run: flutter build apk --release --split-per-abi
- name: Organize Artifacts
run: |
mkdir -p release-apks
# Copy universal APK
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
fi
# Copy split APKs
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
fi
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
fi
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
fi
echo "=== Generated APK Artifacts ==="
ls -lah release-apks/
- name: Upload APKs to Gitea Artifacts
uses: actions/upload-artifact@v4
with:
name: oggauer-gemeindetrommler-apks
path: release-apks/*.apk
retention-days: 30
-102
View File
@@ -1,102 +0,0 @@
name: Build iOS IPA
on:
push:
branches:
- main
- master
tags:
- 'v*'
- 'latest'
workflow_dispatch:
# Only the newest push per branch/tag is built.
concurrency:
group: build-ios-${{ github.ref }}
cancel-in-progress: true
env:
# Keep in sync with .gitea/workflows/build-apk.yaml and pubspec.yaml (environment).
FLUTTER_VERSION: '3.47.4'
jobs:
build-ios:
name: Build iOS IPA
# macOS 26 (arm64) ships Xcode 26 by default; Flutter 3.47 needs Xcode >= 16.
runs-on: macos-26
timeout-minutes: 60
if: github.server_url == 'https://github.com'
permissions:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Configure Flutter
run: |
# All plugins of this project are integrated via CocoaPods. Turning
# off the (default-on) Swift Package Manager integration keeps the
# build on that single, deterministic path.
flutter config --no-enable-swift-package-manager
- name: Inspect Environment
run: |
xcodebuild -version
pod --version
flutter --version
flutter doctor -v
- name: Fetch Flutter Dependencies
run: flutter pub get
- name: Build iOS (Unsigned)
run: flutter build ios --release --no-codesign
- name: Package IPA
run: |
set -euo pipefail
cd build/ios/iphoneos
rm -rf Payload oggauer-gemeindetrommler.ipa
mkdir Payload
cp -R Runner.app Payload/
zip -qry oggauer-gemeindetrommler.ipa Payload
ls -lh oggauer-gemeindetrommler.ipa
- name: Upload IPA Artifact
uses: actions/upload-artifact@v4
with:
name: oggauer-gemeindetrommler-ios
path: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
retention-days: 7
if-no-files-found: error
- name: Publish IPA to GitHub Release
if: github.event_name != 'pull_request'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'latest' }}
name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'Oggauer Gemeindetrommler - LATEST' }}
files: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Xcode Logs (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-build-logs
path: |
~/Library/Logs/DiagnosticReports/*.ips
build/ios/**/*.log
ios/Podfile.lock
if-no-files-found: ignore
retention-days: 3
-4
View File
@@ -44,7 +44,3 @@ app.*.map.json
/android/app/release
/android/app/build/
.gradle/
release-apks/
gitea-runner/data/
gitea-runner/.env
backups/
+81 -147
View File
@@ -1,198 +1,132 @@
# Oggauer Gemeindetrommler 🥁
Die **Oggauer Gemeindetrommler App** ist die offizielle Smartphone-Anwendung (Flutter für Android & iOS) des **Bürgerforums Oggau** für alle Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.
Die App vereint aktuelle Gemeinde-Nachrichten, offizielle PDF-Aussendungen des Bürgerforums, einen bürgernahen Mängelmelder mit Foto-Upload, eine Ideenplattform sowie einen direkten Kontaktkanal mit integriertem Admin-Posteingang und vollständiger In-App-Benutzerverwaltung.
Die **Oggauer Gemeindetrommler App** ist eine mobile Anwendung (Flutter für Android & iOS) für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See. Sie dient als digitale Plattform für Gemeinde-Nachrichten, Bürgeranliegen (Mängelmeldungen mit Foto-Upload) sowie den Austausch im Bürgerforum bzw. die Einbringung von Gemeinde-Ideen inklusive PDF-Exportfunktionen.
---
## 📱 Funktionsumfang & Module
## 📱 Aktueller Funktionsumfang (Ist-Zustand)
### 1. 📰 Nachrichten & Offizielle PDF-Aussendungen (`News`)
- **Chronologischer Feed:** Vereint manuell erstellte Gemeinde-Nachrichten und die offiziellen Rundschreiben/Aussendungen des Bürgerforums nach Datum sortiert (neueste zuerst).
- **WordPress REST-API Anbindung:** Vollautomatische Synchronisation der Aussendungen von [buergerforumoggau.at](https://buergerforumoggau.at/category/aktuelles/aussendungen/) inklusive HTML-Bereinigung und PDF-Extraktion.
- **Offline-First:** Geladene Aussendungen werden lokal via `SharedPreferences` zwischengespeichert und stehen sofort beim App-Start sowie offline zur Verfügung.
- **Integrierter PDF-Reader:**
- Flüssige In-App-Vorschau ohne externe App zwingend öffnen zu müssen.
- **Stufenloser Zoom:** Gesten-Zoom (Pinch-to-Zoom von 0.8x bis 5.0x) und schwebendes Bedienfeld (`+`, `-`, `1:1` Reset auf 100%).
- **Drucken & Teilen:** Direktes Teilen von PDFs via `Printing.sharePdf` und System-Druckfunktion.
- **Browser-Aufruf:** Öffnen des Original-Dokuments im Standard-Webbrowser oder externen PDF-Viewer.
- **Filter-Leiste:** Schnelles Filtern nach `Alle`, `Aussendungen (PDF)` und `Aktuelles`.
- **Echtzeitsuche & PDF-Export:** Volltextsuche in Beiträgen sowie Export von Einzelbeiträgen oder Tabellenübersichten als PDF.
- **Redaktions-Funktion:** Berechtigte Administratoren können neue Nachrichten direkt über die App mit Titel, Text und Beitragsbild erfassen.
Die App wurde in ihrer Erstversion mit Unterstützung von KI (frühe GPT-Modelle) entwickelt und nutzt Firebase als Backend.
### 2. ⚠️ Mängelmelder (`Issues`)
- **Mängelübersicht:** Chronologische Liste aller im Gemeindegebiet gemeldeten Mängel mit Bildvorschau, Status und Ortsangabe.
- **Strukturierte Erfassung:**
- Titel und detaillierte Beschreibung.
- Kategorie-Auswahl (z. B. Straßenbeleuchtung, Straßenschäden, Grünflächen, Müll, Spielplätze).
- Pflicht-Dropdown aller offiziellen Straßen in Oggau.
- Bis zu 2 Fotos (Aufnahme direkt per Kamera oder Auswahl aus der Galerie).
- Automatische Bildkomprimierung und Speicheroptimierung (< 10 MB).
- **Status-Workflow:** Verwaltung durch Administratoren (`Gemeldet` $\rightarrow$ `In Bearbeitung` $\rightarrow$ `Behoben`).
- **Sammel-PDF-Druck:** Mehrfachauswahl gemeldeter Mängel mit tabellarischem PDF-Export (ideal zur Weiterleitung an den Bauhof oder Gemeinderat).
- **Löschfunktion:** Verfasser und Administratoren können Meldungen per Wischgeste entfernen.
### 1. Neuigkeiten (`News`)
- Liste aktueller Berichte und Neuigkeiten aus der Gemeinde mit Datum, Überschrift, Textvorschau und Beitragsbild.
- Detailansicht mit vollständigem Text und großem Beitragsbild.
- PDF-Export: Sowohl Einzelbeiträge als auch eine Mehrfachauswahl können direkt als PDF generiert und geteilt werden.
### 3. 💡 BürgerForum & Ideen (`CitizensForum`)
- Bürger-Beteiligungsplattform für Anregungen, Projektideen und Initiativen zur Dorfentwicklung.
- Erfassung mit Titel, Freitextbeschreibung und bis zu 2 Fotos/Anhängen.
- PDF-Export und Druckunterstützung zur Vorstellung in Ausschüssen oder Sitzungen.
### 2. Mängelübersicht & Mängelmeldung (`Issues`)
- Übersicht über gemeldete Mängel im Ortsgebiet (chronologisch sortiert).
- Erfassung neuer Mängel mit Titel, Beschreibungstext und Foto (Kamera oder Galerie).
- Automatischer Zuschnitt (1:1 Quadrat) und Größenvalidierung (< 10 MB).
- Eigene Meldungen können per Wischgeste (Swipe-to-Delete) entfernt werden.
- Mehrfachauswahl von Mängeln mit Sammel-PDF-Export (z. B. zur Weiterleitung an den Bauhof).
### 4. 📬 Kontakt & Admin-Posteingang (`Contact`)
- **Bürger-Kontaktformular:**
- Einfache und direkte Kontaktaufnahme mit dem Bürgerforum.
- Pflichtangaben: Name, E-Mail-Adresse und Anliegen.
- Optionale Rückruf-Telefonnummer für schnelle Rückfragen.
- Rechtlicher Datenschutzhinweis.
- **Admin-Posteingang (`AdminMessagesScreen`):**
- Administratoren haben direkten Zugriff auf alle über die App eingesendeten Bürgernachrichten.
- **Echtzeit-Aktualisierung** via Firestore.
- **Filter-Chips:** Schnelles Filtern nach `Alle`, `Neu`, `In Bearbeitung` und `Erledigt`.
- **Volltextsuche:** Durchsucht Betreff, Nachrichtentext, Absender-Name und E-Mail-Adresse.
- **Detail-Ansicht (Bottom Sheet):**
- Vollständiger Nachrichtentext mit Metadaten und Zeitstempel.
- Statuswechsel (`Neu`, `In Bearbeitung`, `Erledigt`).
- Ein-Klick-Aktionen: Direkte E-Mail-Antwort (`mailto:`) und Anruf (`tel:`).
- Sicheres Löschen erledigter Nachrichten.
### 3. BürgerForum / Ideen (`CitizensForum`)
- Plattform zur Einbringung von Anregungen und Vorschlägen für das Gemeindeleben.
- Nutzt aktuell den gleichen Erfassungs- und PDF-Export-Workflow wie bei den Mängeln.
### 5. ℹ️ Bürgerforum-Info, Leitbild & In-App-Benutzerverwaltung (`Info`)
- **Leitbild & Über uns:** Informationen über die Grundsätze, Ziele und Aktivitäten des Bürgerforums Oggau.
- **Notrufnummern:** Wichtige Notfallkontakte (Feuerwehr, Polizei, Rettung, Euronotruf, Vergiftungszentrale, etc.) auf einen Blick mit direkter Anruffunktion.
- **Benutzerkonto-Status:** Transparente Anzeige des Anmeldestatus (`Gast`, `Bürger`, `Administrator`).
- **In-App-Benutzerverwaltung (`UserManagementScreen`):**
- Keine Notwendigkeit mehr, für die Rollenvergabe die Firebase Console zu öffnen.
- Vollständige Liste aller registrierten Bürger mit Such- und Filterfunktion (`Alle`, `Administratoren`, `Bürger`).
- **Admin-Rechte verwalten:** Beförderung zum Administrator oder Entzug von Admin-Rechten mit einem Klick (inklusive Sicherheits-Bestätigungsdialog).
- **Selbstentzugs-Schutz:** Der angemeldete Admin kann sich nicht versehentlich selbst die Administrationsrechte entziehen.
- **Manuelle Admin-Freischaltung:** Zusätzliche Dialog-Eingabe zur direkten Autorisierung per E-Mail oder UID.
### 4. Authentifizierung (`AuthState`)
- Anonyme Firebase-Authentifizierung (`FirebaseAuth.signInAnonymously`).
- Lesemodus für Gäste; Erstellen und Löschen eigener Beiträge ist an den angemeldeten Nutzerstatus gekoppelt.
---
## 🔒 Sicherheitsarchitektur (Backend & Frontend)
## 🔍 Soll vs. Ist Abgleich (Original-Anforderungen)
Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sicherheitskonzept gewährleistet:
Basierend auf dem ursprünglichen Lastenheft von Thomas und den UI-Wireframes (`App (1).xlsx`) ergibt sich folgender Umsetzungsstatus:
1. **Firestore Security Rules (`firestore.rules`):**
- **`/Users/{userId}`:** Nutzer können ihr eigenes Profil lesen und pflegen. Nur Administratoren (`isAdmin()`) haben Vollzugriff auf alle Benutzerprofile.
- **`/Admins/{adminDoc}`:** Schreib- und Löschrechte sind strikt auf bestehende Administratoren beschränkt.
- **`/ContactMessages/{doc}`:** Eingereichte Kontaktanfragen können ausschließlich von Administratoren oder dem jeweiligen Verfasser gelesen werden. Statusänderungen und Löschungen sind Administratoren vorbehalten.
- **`/News/{newsId}`:** Erstellen und Löschen von News-Beiträgen ist nur für Administratoren gestattet.
- **`/Issues` & `/CitizensForum`:** Angemeldete Nutzer dürfen eigene Beiträge erfassen und löschen; Admins dürfen den Bearbeitungsstatus pflegen.
2. **Cloud Storage Rules (`storage.rules`):**
- Uploads sind auf angemeldete Nutzer, Dateigrößen unter 10 MB und gültige Bildformate (`image/*`) limitiert.
- Fotos dürfen ausschließlich vom ursprünglichen Verfasser oder von einem berechtigten Administrator gelöscht werden.
3. **Automatische Profil-Synchronisation:**
- Bei jeder Anmeldung oder Registrierung gleicht `AuthService` das Benutzerprofil mit Firestore (`Users/{uid}`) ab.
---
## 📊 Umsetzungsstatus (Soll vs. Ist)
| Anforderung / Modul | Spezifikation & Features | Umsetzungsstatus | Status |
|---|---|---|:---:|
| **News & Aussendungen** | Chronologischer Feed, Volltextsuche, PDF-Aussendungen der Webseite, Zoom-Viewer, PDF-Export | Vollständig integriert (WordPress REST-API, In-App Zoom Reader, Filter-Chips) | 🟢 Erledigt |
| **Mängelübersicht** | Chronologische Liste, Straßenauswahl, Volltextsuche, Tabellen-PDF-Export, Status-Tracking | Vollständig integriert (Dropdown aller Oggauer Straßen, Statusanzeige, PDF-Export) | 🟢 Erledigt |
| **Mängelerfassung** | Pflicht-Straße, Kategorie, max. 2 Fotos, automatische Komprimierung, Verfasser-Zuordnung | Vollständig integriert (Kamera/Galerie, Dual-Foto-Upload, Validierung) | 🟢 Erledigt |
| **Bürgerforum / Ideen** | Bürgerbeteiligung, Beschreibung, bis zu 2 Anhänge, PDF-Export | Vollständig integriert | 🟢 Erledigt |
| **Kontakt & Posteingang** | Kontaktformular mit Validierung, Datenschutzhinweis, Admin-Nachrichten-Posteingang | Vollständig integriert (Echtzeit-Inbox, Status-Workflow, Schnellantwort) | 🟢 Erledigt |
| **Benutzerverwaltung** | Rollenverwaltung (Admin/Bürger), Kontoverwaltung in der App ohne Firebase Console | Vollständig integriert (In-App User Management, Selbstschutz, Echtzeit) | 🟢 Erledigt |
| **Info & Notruf** | Leitbild Bürgerforum, Gemeinde-Informationen, Notfallnummern mit Schnellwahl | Vollständig integriert | 🟢 Erledigt |
| **Authentifizierung & Rechte** | E-Mail/Passwort, Bürger-Freischaltung, Rollenkonzept, Gast-Zugang, Security Rules | Vollständig integriert & backend-seitig gehärtet | 🟢 Erledigt |
| Anforderung / Modul | Ursprüngliche Spezifikation | Aktueller Umsetzungsstatus | Status |
|---|---|---|---|
| **Startseite / News** | 2-Zeilen-Vorschau, Trennlinie, Volltextsuche (Lupe), Detailansicht, PDF/JPG-Anhänge | Überschrift, Datum, 3 Zeilen Text, Bild, Detailansicht | 🟡 Teilweise (Suche fehlt, nur 1 Bild) |
| **Mängelübersicht** | Liste, Volltextsuche, PDF-Tabellendruck einer Auswahl | Liste vorhanden, Checkbox-Auswahl + PDF-Druck integriert | 🟡 Teilweise (Suche & Tabellenlayout im PDF fehlen) |
| **Mängelerfassung** | Pflicht-Dropdown für Straße/Ort, Dropdown für Grundlage, max. 2 Fotos im Querformat (max. 800x600), automatischer Verfasser | Titel, Beschreibung, 1 Foto im Quadrat (1:1). Keine Dropdowns | 🔴 Wesentliche Felder fehlen |
| **Ideen** | Wie Mängel, bis zu 5 Anhänge (PDF/Bilder), kein Druck erforderlich | Als „BürgerForum“ realisiert, aber identische Maske zu Mängeln (nur 1 Bild) | 🟡 Teilweise (Mehrfach-Upload bis 5 Dateien fehlt) |
| **Kontakt** | Freitext, Name, Nachname, E-Mail (Pflicht), Rückrufnummer (optional), E-Mail-Versand, Rechtshinweis | Nicht im Menü eingebunden; nur Registrier-Fragmente vorhanden | 🔴 Fehlt komplett |
| **Information / Impressum** | Öffentliche Gemeinde-Infos, Veranstaltungen, Impressum ohne Login frei zugänglich | Nicht vorhanden | 🔴 Fehlt komplett |
| **Zugangsschutz / Auth** | Gemeindebürger-Zugang via Passwort/Freigabe; nur Info-Bereich offen | Anonymer Login per Knopfdruck für alle; Fragmente für E-Mail-Login | 🔴 Entspricht nicht dem Passwort-Konzept |
| **Design & Header** | Neutraler Hintergrund, optionales Betreiber-Logo im Header | Schlichter Material-AppBar-Header ohne Logo | 🟡 Optimierbar |
---
## 🏗️ Technische Architektur & Tech-Stack
### Verwendete Technologien:
- **Framework:** [Flutter](https://flutter.dev) `3.47.x` (Dart SDK `>=3.11`), Android `minSdk 23`, iOS `>= 15.0`
- **Backend & Cloud Services (Google Firebase):**
- `firebase_core` (Initialisierung)
- `firebase_auth` (E-Mail & Passwort Authentifizierung, Gast-Modus)
- `cloud_firestore` (NoSQL Echtzeit-Datenbank)
- `firebase_storage` (Cloud-Bilderspeicher)
- **Externe APIs & Web-Services:**
- WordPress REST-API (`http`-Paket für Bürgerforum-Aussendungen)
- `shared_preferences` (Offline-Zwischenspeicherung)
- **Dokumentenverarbeitung & PDF:**
- `pdf`, `printing` (PDF-Generierung, In-App Vorschau & Druck)
- `url_launcher` (System-Browser, Telefon- und E-Mail-Intents)
- **Bildverarbeitung:**
- `image_picker`, `cached_network_image`, `image`
- **State Management:**
- `provider` (`MultiProvider` Architektur)
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=2.18.2 <3.0.0` / Dart 3 kompatibel)
- **Backend / Cloud Services (Firebase):**
- `firebase_core` (Core-Initialisierung)
- `firebase_auth` (Authentifizierung)
- `cloud_firestore` (NoSQL Echtzeitdatenbank für News, Mängel, Ideen)
- `firebase_storage` (Speicherung hochgeladener Bilder)
- **PDF-Erstellung & Druck:** `pdf`, `printing`
- **Bildverarbeitung:** `image`, `image_picker`, `image_cropper`, `cached_network_image`
- **State Management:** `provider`
### Projektstruktur (`lib/`):
```text
lib/
├── firebase_options.dart # Firebase-Konfiguration für Android & iOS
├── main.dart # Einstiegspunkt, Theme & MultiProvider Setup
├── firebase_options.dart # Firebase-Projektkonfiguration (Android & iOS)
├── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
├── models/
│ └── post_item.dart # Datenmodell für News, Aussendungen, Mängel & Ideen
│ └── post_item.dart # Typisiertes Datenmodell für News, Mängel & Ideen
├── screens/
│ ├── add_issue_screen.dart # Mängelerfassung (Straßen-Dropdown & bis zu 2 Fotos)
│ ├── add_post_screen.dart # Erfassung von News (Admins) & Bürgerideen
│ ├── admin_messages_screen.dart # Admin-Posteingang für eingegangene Kontaktanfragen
│ ├── auth_dialog.dart # Anmelde-, Registrierungs- & Freischaltungs-Dialog
│ ├── contact_screen.dart # Bürger-Kontaktformular mit Admin-Posteingang-Banner
│ ├── detail_screen.dart # Beitrags-Detailansicht mit PDF-Direktaufruf
│ ├── home_screen.dart # Haupt-Navigation mit 5 Tabs
│ ├── info_screen.dart # Bürgerforum Leitbild, Notrufnummern & Kontostatus
│ ├── pdf_viewer_screen.dart # In-App PDF-Viewer mit Pinch-to-Zoom & Schwebepanel
│ ├── post_list_screen.dart # News-, Mängel- & Ideen-Listen mit Volltextsuche & Filter
│ └── user_management_screen.dart# In-App Benutzerverwaltung & Admin-Rollenvergabe
│ ├── add_issue_screen.dart # Mängelerfassung mit Straßenauswahl & bis zu 2 Fotos
│ ├── add_post_screen.dart # Universelle Erfassung für News & Bürgerideen
│ ├── auth_dialog.dart # Anmelde-, Registrierungs- & Code-Freischaltungs-Dialog
│ ├── contact_screen.dart # Bürgerforum-Kontaktformular & Anfragen
│ ├── detail_screen.dart # Detailansicht mit Metadaten & PDF-Einzelexport
│ ├── home_screen.dart # Haupt-Scaffold mit 5 Tabs (News, Mängel, Ideen, Kontakt, Info)
│ ├── info_screen.dart # Bürgerforum Leitbild, Notrufnummern & Account-Status
│ └── post_list_screen.dart # Universelle Listenansicht, Echtzeitsuche & PDF-Tabelle
└── services/
├── aussendungen_service.dart # WordPress REST-API Client mit SharedPreferences-Cache
├── auth_service.dart # Authentifizierung & automatischer Firestore-Profilabgleich
├── firestore_service.dart # Firestore CRUD, Admin-Verwaltung & Kontakt-Stream
├── pdf_service.dart # PDF-Tabellen und Einzeldokument-Erstellung
└── storage_service.dart # Komprimierter Cloud-Storage Bild-Upload
├── auth_service.dart # Hybrid-Auth (E-Mail/Passwort, Bürger-Code, Anonym)
├── firestore_service.dart# NoSQL CRUD & Kontaktanfragen
├── pdf_service.dart # PDF-Druckerzeugung (Listen & Einzelansichten)
└── storage_service.dart # Optimierter Bild-Upload (Querformat max 800x600)
```
---
## 🤖 CI/CD Pipeline
## 🤖 CI/CD Pipeline (Gitea Actions)
Die Flutter-Version ist zentral gepinnt (`FLUTTER_VERSION` in beiden Workflows, `environment` in `pubspec.yaml`) und muss bei einem Upgrade an allen drei Stellen gemeinsam angehoben werden.
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
### Android (Gitea Actions, `.gitea/workflows/build-apk.yaml`)
### Funktionen der Pipeline:
- **Automatische Trigger:** Wird bei jedem `push` auf `main`, `master`, `refactor/**`, bei Git-Tags (`v*`) oder manuell im Webinterface (`workflow_dispatch`) ausgelöst.
- **Isolierte Build-Umgebung:** Nutzt das offizielle `ghcr.io/cirruslabs/flutter:3.19.3` Docker-Image mit vorinstalliertem Android SDK, Java 17 und Flutter.
- **Qualitätssicherung:** Führt automatisch `flutter analyze` und `flutter test` aus, bevor der Build startet.
- **Vollständige APK-Generierung:**
- `oggauer-gemeindetrommler-universal-release.apk` (Universelle Release-APK für alle Android-Geräte)
- `oggauer-gemeindetrommler-arm64-v8a.apk` (Für moderne 64-Bit Smartphones)
- `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Smartphones)
- `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren & ChromeOS)
- **Artifact-Upload:** Speichert die fertigen APKs als herunterladbare ZIP-Datei (`oggauer-gemeindetrommler-apks`) direkt im Gitea Run-Dashboard.
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.44.0` (letztes von Cirrus Labs veröffentlichtes Image, liefert JDK 21 + Android SDK 36). Das darin enthaltene Flutter-SDK wird im ersten Schritt per `git checkout` auf `FLUTTER_VERSION` umgeschaltet.
- **Runner:** Oracle Cloud ARM64 – x86_64-Werkzeuge (AAPT2, `gen_snapshot`) laufen über QEMU (`scripts/ci/qemu_intercept.c`).
- **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus.
- **Erzeugte APK-Pakete:**
1. `oggauer-gemeindetrommler-universal-release.apk` (Universelle Version für alle Android-Smartphones).
2. `oggauer-gemeindetrommler-arm64-v8a.apk` (Optimiert für moderne 64-Bit Geräte).
3. `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Geräte).
4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren).
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als Artefakte im Gitea-Reiter **Aktionen** sowie im Release `latest` zum Download bereit.
### iOS (GitHub Actions, `.github/workflows/build-ios.yaml`)
- **Runner:** `macos-26` (Xcode 26), Flutter über `subosito/flutter-action`.
- **Plugins:** ausschließlich über CocoaPods (Swift Package Manager ist im Workflow bewusst deaktiviert); `ios/Podfile.lock` wird nicht versioniert, `pod install` läuft bei jedem Build frisch.
- **Ergebnis:** unsigniertes IPA (`flutter build ios --release --no-codesign`) als Artefakt `oggauer-gemeindetrommler-ios` – für die Verteilung muss es nachträglich signiert werden.
### APKs in Gitea herunterladen:
1. Im Gitea-Repository auf den Reiter **Aktionen** (*Actions*) klicken.
2. Den gewünschten Pipeline-Durchlauf auswählen.
3. Im Abschnitt **Artefakte** (*Artifacts*) das Paket `oggauer-gemeindetrommler-apks` herunterladen.
---
## 🛠️ Einrichtung & Lokale Ausführung
## 🛠️ Einrichtung & Lokaler Start
### Voraussetzungen:
- **Flutter SDK:** `3.47.x` (stable) – identisch zur in den Workflows gepinnten Version
- **Dart SDK:** Im Flutter SDK enthalten
- **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten
### Voraussetzungen
1. **Flutter SDK** (empfohlen: `>= 3.19.x`)
2. **Dart SDK** (enthalten in Flutter)
3. **Android Studio** / **Xcode** (für Emulatoren oder physische Geräte)
4. Konfiguriertes Firebase-Projekt (`gemeinde-trommler`)
### Befehle:
### Installation & Ausführung
```bash
# 1. Abhängigkeiten laden
# 1. Abhängigkeiten installieren
flutter pub get
# 2. Statische Code-Analyse ausführen
flutter analyze
# 3. Unit-Tests ausführen
# 3. Tests ausführen
flutter test
# 4. App im Entwicklungsmodus starten
# 4. App lokal starten
flutter run
```
-6
View File
@@ -9,12 +9,6 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
+3
View File
@@ -1,5 +1,8 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
+50 -38
View File
@@ -1,34 +1,65 @@
plugins {
id "com.android.application"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
// (With android.builtInKotlin=false the Flutter plugin applies kotlin-android itself.)
id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
// START: FlutterFire Configuration
apply plugin: 'com.google.gms.google-services'
// END: FlutterFire Configuration
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
namespace "com.oggauergemeindetrommler.app"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileSdkVersion flutter.compileSdkVersion
ndkVersion "26.1.10909125"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
buildConfig = true
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.oggauergemeindetrommler.app"
// Flutter 3.47 defaults to 24; 23 (Android 6.0) is still supported and
// is also the minimum required by the current Firebase Android SDKs.
minSdkVersion flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
// Read from pubspec.yaml (via local.properties); defaults to 1 / "1.0".
versionCode flutter.versionCode
versionName flutter.versionName
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
minSdkVersion 23
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
buildTypes {
@@ -38,31 +69,12 @@ android {
signingConfig signingConfigs.debug
}
}
// `flutter build apk --split-per-abi` makes the Flutter Gradle plugin enable ABI
// splits for the requested target platforms but switch the universal APK off.
// Turn it back on so a single Gradle run yields the per-ABI APKs *and* the
// universal one (the plugin copies all of them to build/app/outputs/flutter-apk/,
// the universal one as app-release.apk). Only when splitting: for plain builds
// the plugin sets ndk.abiFilters instead, which AGP does not allow together
// with splits. Do not use AGP's `packageReleaseUniversalApk` task for this -
// it derives the universal APK from an App Bundle, and bundles cannot be
// built while ABI splits are enabled.
if (project.findProperty("split-per-abi")?.toString()?.toBoolean()) {
splits {
abi {
universalApk true
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
+2 -20
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.oggauergemeindetrommler.app">
<application
android:label="Oggauer Gemeindetrommler"
android:name="${applicationName}"
@@ -34,24 +35,5 @@
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,6 @@
package com.oggauergemeindetrommler.oggauer_gemeindetrommler
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
+30 -7
View File
@@ -1,3 +1,19 @@
buildscript {
ext.kotlin_version = '1.9.22'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.2'
// START: FlutterFire Configuration
classpath 'com.google.gms:google-services:4.4.1'
// END: FlutterFire Configuration
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
@@ -5,18 +21,25 @@ allprojects {
}
}
// Keep all Gradle outputs under <repo>/build so the Flutter CLI finds the APKs.
// (Project.buildDir was removed in Gradle 9 -> use layout.buildDirectory.)
def newBuildDir = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
rootProject.buildDir = '../build'
subprojects {
project.layout.buildDirectory.value(newBuildDir.dir(project.name))
// Note: Do not override project.buildDir across different drives on Windows (H: vs C:)
// Inject namespace into plugins that don't have it (required for AGP 8+)
afterEvaluate { project ->
if (project.hasProperty('android')) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
delete rootProject.buildDir
}
+3 -10
View File
@@ -1,11 +1,4 @@
# vfork keeps process spawning cheap under QEMU user-mode emulation (ARM64 CI runner).
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Djdk.lang.Process.launchMechanism=vfork
org.gradle.jvmargs=-Xmx1536M -Dandroid.prefs.root=C:/Users/nicol/.android
android.useAndroidX=true
# Stay on the legacy AGP DSL / external Kotlin plugin until all Flutter plugins
# support the AGP 9 built-in Kotlin (same as the Flutter 3.47 project template).
android.newDsl=false
android.builtInKotlin=false
# Kotlin incremental compilation stores project-relative paths and breaks on Windows
# when the pub cache (C:) and the project live on different drives
# ("this and base files have different roots"). CI builds are clean anyway.
kotlin.incremental=false
android.enableJetifier=true
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
Binary file not shown.
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
-90
View File
@@ -1,90 +0,0 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+8 -30
View File
@@ -1,33 +1,11 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
def localPropertiesFile = file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { properties.load(it) }
}
def flutterSdkPath = properties.getProperty("flutter.sdk") ?: System.getenv('FLUTTER_ROOT') ?: System.getenv('FLUTTER_HOME')
assert flutterSdkPath != null, "flutter.sdk not set in local.properties or FLUTTER_ROOT environment variable"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
include ':app'
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
// Versions follow the Flutter 3.47 project template (see
// packages/flutter_tools/lib/src/android/gradle_utils.dart in the Flutter SDK).
// Flutter refuses to build with AGP < 8.11.1, Kotlin < 2.2.20 or Gradle < 8.14.
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "9.1.0" apply false
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
id "com.google.gms.google-services" version "4.5.0" apply false
}
include ":app"
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
-9
View File
@@ -1,9 +0,0 @@
@echo off
echo ========================================================
echo Oggauer Gemeindetrommler - Firestore Backup Tool
echo ========================================================
echo.
node scripts\backup_firestore.js
echo.
echo Backup abgeschlossen. Taste druecken zum Schliessen...
pause >nul
-59
View File
@@ -1,59 +0,0 @@
@echo off
setlocal enabledelayedexpansion
echo ========================================================
echo Oggauer Gemeindetrommler - Android APK Builder
echo ========================================================
echo.
set FLUTTER=C:\src\flutter\bin\flutter.bat
if not exist "%FLUTTER%" (
set FLUTTER=flutter
)
echo [1/4] Dependencies laden...
call %FLUTTER% pub get
if %ERRORLEVEL% NEQ 0 (
echo [FEHLER] 'flutter pub get' fehlgeschlagen.
pause
exit /b %ERRORLEVEL%
)
echo.
echo [2/4] Universelle Release APK bauen...
call %FLUTTER% build apk --release
if %ERRORLEVEL% NEQ 0 (
echo [FEHLER] 'flutter build apk --release' fehlgeschlagen.
pause
exit /b %ERRORLEVEL%
)
echo.
echo [3/4] Split-per-ABI APKs bauen (fuer schlankere Downloads)...
call %FLUTTER% build apk --release --split-per-abi
echo.
echo [4/4] APKs organisieren...
if not exist "release-apks" mkdir "release-apks"
if exist "build\app\outputs\flutter-apk\app-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-release.apk" "release-apks\oggauer-gemeindetrommler-universal.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk" "release-apks\oggauer-gemeindetrommler-arm64.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk" "release-apks\oggauer-gemeindetrommler-armv7.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-x86_64-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-x86_64-release.apk" "release-apks\oggauer-gemeindetrommler-x86_64.apk" >nul
)
echo.
echo ========================================================
echo ERFOLG! Alle APKs befinden sich im Ordner:
echo %CD%\release-apks\
echo ========================================================
echo.
explorer release-apks
pause
+21 -47
View File
@@ -2,60 +2,34 @@ rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Hilfsfunktionen
function isAuthenticated() {
return request.auth != null;
// News / Neuigkeiten: Öffentlich lesbar, Schreiben für angemeldete Nutzer
match /News/{document=**} {
allow read: if true;
allow write: if request.auth != null;
}
function isAdmin() {
return request.auth != null && (
request.auth.token.admin == true ||
exists(/databases/$(database)/documents/Admins/$(request.auth.uid)) ||
(request.auth.token.email != null && exists(/databases/$(database)/documents/Admins/$(request.auth.token.email)))
);
// Mängel & Anliegen: Öffentlich lesbar, Erstellen angemeldet, Löschen nur eigener Autor
match /Issues/{document} {
allow read: if true;
allow create: if request.auth != null;
allow update: if request.auth != null;
allow delete: if request.auth != null &&
(resource.data.author_uid == request.auth.uid);
}
function isAuthorOrAdmin() {
return isAuthenticated() && (
(resource.data.author_uid != null && resource.data.author_uid == request.auth.uid) ||
(resource.data.authorUid != null && resource.data.authorUid == request.auth.uid) ||
isAdmin()
);
// Bürgerforum / Ideen: Öffentlich lesbar, Erstellen angemeldet, Löschen eigener Autor
match /CitizensForum/{document} {
allow read: if true;
allow create: if request.auth != null;
allow update: if request.auth != null;
allow delete: if request.auth != null &&
(resource.data.author_uid == request.auth.uid);
}
// Benutzerprofile: Nutzer verwaltet eigenes Profil, Admins haben Vollzugriff
match /Users/{userId} {
allow read: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
allow create, update: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
allow delete: if isAdmin();
}
// Admins-Sammlung: Angemeldete Nutzer können prüfen, nur Admins schreiben
match /Admins/{adminDoc} {
allow read: if isAuthenticated();
allow write: if isAdmin();
}
// Öffentliche Sammlungen: News, Mängel (Issues) & Ideen (CitizensForum)
match /{collection}/{document=**} {
// Jeder darf News, Mängel & Ideen öffentlich lesen
allow read: if collection in ['News', 'Issues', 'CitizensForum'];
// Erstellen: News nur Admin; Mängel und Ideen für angemeldete Nutzer
allow create: if collection == 'News'
? isAdmin()
: (isAuthenticated() && collection in ['Issues', 'CitizensForum']);
// Ändern/Löschen nur der eigene Autor oder Admin
allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum'];
}
// Kontakt-Nachrichten: Geschützt (nur registrierte Nutzer senden, Lesen nur Admin & Autor)
// Kontakt-Nachrichten: Jeder kann einsenden, Lesen nur berechtigte Nutzer
match /ContactMessages/{document} {
// Senden nur für registrierte Nutzer (nicht-anonym)
allow create: if isAuthenticated() && request.auth.token.firebase.sign_in_provider != 'anonymous';
// Lesen, Ändern und Löschen für Admin und Autor
allow read, update, delete: if isAuthorOrAdmin();
allow create: if true;
allow read, update, delete: if request.auth != null;
}
}
}
-9
View File
@@ -1,9 +0,0 @@
# Gitea URL
GITEA_INSTANCE_URL=https://git.djnizz.at
# Hier das Token einfügen aus Gitea:
# Website-Verwaltung -> Aktionen -> Runner -> "Neuen Runner erstellen"
GITEA_RUNNER_REGISTRATION_TOKEN=hier_dein_token_einfuegen
# Beliebiger Name für den Runner
GITEA_RUNNER_NAME=oracle-arm-runner
-25
View File
@@ -1,25 +0,0 @@
# Gitea Act Runner (Oracle Cloud ARM64 / Docker)
Docker Compose Setup für den Gitea CI/CD Runner zum automatischen Bauen der Android-APKs.
## Schnellstart
1. **Token holen:**
In Gitea unter **Website-Verwaltung** (oder Repo-Einstellungen) ➔ **Aktionen** ➔ **Runner** ➔ **Neuen Runner erstellen** das Registrierungs-Token kopieren.
2. **Konfiguration erstellen:**
```bash
cp .env.example .env
nano .env
```
Dort das kopierte Token bei `GITEA_RUNNER_REGISTRATION_TOKEN` eintragen.
3. **Runner starten:**
```bash
docker compose up -d
```
4. **Logs ansehen:**
```bash
docker compose logs -f
```
-23
View File
@@ -1,23 +0,0 @@
version: '3.8'
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: always
environment:
# URL deiner Gitea-Instanz
- GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.djnizz.at}
# Registrierungs-Token aus Gitea (Website-Verwaltung -> Aktionen -> Runner -> "Neuen Runner erstellen")
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
# Name des Runners in Gitea
- GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-oracle-arm-runner}
# TLS-Verifizierung überspringen (falls SSL abgelaufen/selbstsigniert ist)
- GITEA_RUNNER_INSECURE=true
# Maximale parallele Jobs
- GITEA_RUNNER_CAPACITY=2
volumes:
# Docker-Socket des Host-Systems (nötig für Container-Builds wie Flutter)
- /var/run/docker.sock:/var/run/docker.sock
# Persistenter Speicher: Registrierung bleibt nach Neustart erhalten
- ./data:/data
-5
View File
@@ -32,8 +32,3 @@ Runner/GeneratedPluginRegistrant.*
!default.mode2v3
!default.pbxuser
!default.perspectivev3
# Podfile.lock is intentionally not tracked: development happens on Windows (no
# CocoaPods), and the iOS CI always runs a fresh `pod install`. A stale lock file
# only causes "sandbox is not in sync" / incompatible pod version errors.
Podfile.lock
+2
View File
@@ -20,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
</dict>
</plist>
-3
View File
@@ -1,5 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_REQUIRED=NO
CODE_SIGN_IDENTITY=
+2 -10
View File
@@ -1,5 +1,5 @@
# Flutter 3.47+ requires iOS 15.0 as the minimum deployment target.
platform :ios, '15.0'
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -37,13 +37,5 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Keep every pod on the same deployment target as the app so old
# podspecs do not drag the minimum back below what Flutter supports.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
# Pods never need to be signed; the CI produces an unsigned IPA.
config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
+842
View File
@@ -0,0 +1,842 @@
PODS:
- abseil/algorithm (1.20211102.0):
- abseil/algorithm/algorithm (= 1.20211102.0)
- abseil/algorithm/container (= 1.20211102.0)
- abseil/algorithm/algorithm (1.20211102.0):
- abseil/base/config
- abseil/algorithm/container (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/base (1.20211102.0):
- abseil/base/atomic_hook (= 1.20211102.0)
- abseil/base/base (= 1.20211102.0)
- abseil/base/base_internal (= 1.20211102.0)
- abseil/base/config (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/base/dynamic_annotations (= 1.20211102.0)
- abseil/base/endian (= 1.20211102.0)
- abseil/base/errno_saver (= 1.20211102.0)
- abseil/base/fast_type_id (= 1.20211102.0)
- abseil/base/log_severity (= 1.20211102.0)
- abseil/base/malloc_internal (= 1.20211102.0)
- abseil/base/pretty_function (= 1.20211102.0)
- abseil/base/raw_logging_internal (= 1.20211102.0)
- abseil/base/spinlock_wait (= 1.20211102.0)
- abseil/base/strerror (= 1.20211102.0)
- abseil/base/throw_delegate (= 1.20211102.0)
- abseil/base/atomic_hook (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/base (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/log_severity
- abseil/base/raw_logging_internal
- abseil/base/spinlock_wait
- abseil/meta/type_traits
- abseil/base/base_internal (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/base/config (1.20211102.0)
- abseil/base/core_headers (1.20211102.0):
- abseil/base/config
- abseil/base/dynamic_annotations (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/errno_saver (1.20211102.0):
- abseil/base/config
- abseil/base/fast_type_id (1.20211102.0):
- abseil/base/config
- abseil/base/log_severity (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/malloc_internal (1.20211102.0):
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/raw_logging_internal
- abseil/base/pretty_function (1.20211102.0)
- abseil/base/raw_logging_internal (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/config
- abseil/base/core_headers
- abseil/base/log_severity
- abseil/base/spinlock_wait (1.20211102.0):
- abseil/base/base_internal
- abseil/base/core_headers
- abseil/base/errno_saver
- abseil/base/strerror (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/errno_saver
- abseil/base/throw_delegate (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/container/common (1.20211102.0):
- abseil/meta/type_traits
- abseil/types/optional
- abseil/container/compressed_tuple (1.20211102.0):
- abseil/utility/utility
- abseil/container/container_memory (1.20211102.0):
- abseil/base/config
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/utility/utility
- abseil/container/fixed_array (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/throw_delegate
- abseil/container/compressed_tuple
- abseil/memory/memory
- abseil/container/flat_hash_map (1.20211102.0):
- abseil/algorithm/container
- abseil/container/container_memory
- abseil/container/hash_function_defaults
- abseil/container/raw_hash_map
- abseil/memory/memory
- abseil/container/hash_function_defaults (1.20211102.0):
- abseil/base/config
- abseil/hash/hash
- abseil/strings/cord
- abseil/strings/strings
- abseil/container/hash_policy_traits (1.20211102.0):
- abseil/meta/type_traits
- abseil/container/hashtable_debug_hooks (1.20211102.0):
- abseil/base/config
- abseil/container/hashtablez_sampler (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/container/have_sse
- abseil/debugging/stacktrace
- abseil/memory/memory
- abseil/profiling/exponential_biased
- abseil/profiling/sample_recorder
- abseil/synchronization/synchronization
- abseil/utility/utility
- abseil/container/have_sse (1.20211102.0)
- abseil/container/inlined_vector (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/base/throw_delegate
- abseil/container/inlined_vector_internal
- abseil/memory/memory
- abseil/container/inlined_vector_internal (1.20211102.0):
- abseil/base/core_headers
- abseil/container/compressed_tuple
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/types/span
- abseil/container/layout (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/strings/strings
- abseil/types/span
- abseil/utility/utility
- abseil/container/raw_hash_map (1.20211102.0):
- abseil/base/throw_delegate
- abseil/container/container_memory
- abseil/container/raw_hash_set
- abseil/container/raw_hash_set (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/container/common
- abseil/container/compressed_tuple
- abseil/container/container_memory
- abseil/container/hash_policy_traits
- abseil/container/hashtable_debug_hooks
- abseil/container/hashtablez_sampler
- abseil/container/have_sse
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/utility/utility
- abseil/debugging/debugging_internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/errno_saver
- abseil/base/raw_logging_internal
- abseil/debugging/demangle_internal (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/debugging/stacktrace (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/debugging/debugging_internal
- abseil/debugging/symbolize (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/debugging/debugging_internal
- abseil/debugging/demangle_internal
- abseil/strings/strings
- abseil/functional/bind_front (1.20211102.0):
- abseil/base/base_internal
- abseil/container/compressed_tuple
- abseil/meta/type_traits
- abseil/utility/utility
- abseil/functional/function_ref (1.20211102.0):
- abseil/base/base_internal
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/hash/city (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/hash/hash (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/container/fixed_array
- abseil/hash/city
- abseil/hash/low_level_hash
- abseil/meta/type_traits
- abseil/numeric/int128
- abseil/strings/strings
- abseil/types/optional
- abseil/types/variant
- abseil/utility/utility
- abseil/hash/low_level_hash (1.20211102.0):
- abseil/base/config
- abseil/base/endian
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/memory (1.20211102.0):
- abseil/memory/memory (= 1.20211102.0)
- abseil/memory/memory (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/meta (1.20211102.0):
- abseil/meta/type_traits (= 1.20211102.0)
- abseil/meta/type_traits (1.20211102.0):
- abseil/base/config
- abseil/numeric/bits (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/int128 (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/bits
- abseil/numeric/representation (1.20211102.0):
- abseil/base/config
- abseil/profiling/exponential_biased (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/profiling/sample_recorder (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/synchronization/synchronization
- abseil/time/time
- abseil/random/distributions (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/random/internal/distribution_caller
- abseil/random/internal/fast_uniform_bits
- abseil/random/internal/fastmath
- abseil/random/internal/generate_real
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/traits
- abseil/random/internal/uniform_helper
- abseil/random/internal/wide_multiply
- abseil/strings/strings
- abseil/random/internal/distribution_caller (1.20211102.0):
- abseil/base/config
- abseil/base/fast_type_id
- abseil/utility/utility
- abseil/random/internal/fast_uniform_bits (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/random/internal/fastmath (1.20211102.0):
- abseil/numeric/bits
- abseil/random/internal/generate_real (1.20211102.0):
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/random/internal/fastmath
- abseil/random/internal/traits
- abseil/random/internal/iostream_state_saver (1.20211102.0):
- abseil/meta/type_traits
- abseil/numeric/int128
- abseil/random/internal/nonsecure_base (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/random/internal/pool_urbg
- abseil/random/internal/salted_seed_seq
- abseil/random/internal/seed_material
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/pcg_engine (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/random/internal/fastmath
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/platform (1.20211102.0):
- abseil/base/config
- abseil/random/internal/pool_urbg (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/random/internal/randen
- abseil/random/internal/seed_material
- abseil/random/internal/traits
- abseil/random/seed_gen_exception
- abseil/types/span
- abseil/random/internal/randen (1.20211102.0):
- abseil/base/raw_logging_internal
- abseil/random/internal/platform
- abseil/random/internal/randen_hwaes
- abseil/random/internal/randen_slow
- abseil/random/internal/randen_engine (1.20211102.0):
- abseil/base/endian
- abseil/meta/type_traits
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/randen
- abseil/random/internal/randen_hwaes (1.20211102.0):
- abseil/base/config
- abseil/random/internal/platform
- abseil/random/internal/randen_hwaes_impl
- abseil/random/internal/randen_hwaes_impl (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/int128
- abseil/random/internal/platform
- abseil/random/internal/randen_slow (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/numeric/int128
- abseil/random/internal/platform
- abseil/random/internal/salted_seed_seq (1.20211102.0):
- abseil/container/inlined_vector
- abseil/meta/type_traits
- abseil/random/internal/seed_material
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/seed_material (1.20211102.0):
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/raw_logging_internal
- abseil/random/internal/fast_uniform_bits
- abseil/strings/strings
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/traits (1.20211102.0):
- abseil/base/config
- abseil/random/internal/uniform_helper (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/random/internal/traits
- abseil/random/internal/wide_multiply (1.20211102.0):
- abseil/base/config
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/random/internal/traits
- abseil/random/random (1.20211102.0):
- abseil/random/distributions
- abseil/random/internal/nonsecure_base
- abseil/random/internal/pcg_engine
- abseil/random/internal/pool_urbg
- abseil/random/internal/randen_engine
- abseil/random/seed_sequences
- abseil/random/seed_gen_exception (1.20211102.0):
- abseil/base/config
- abseil/random/seed_sequences (1.20211102.0):
- abseil/container/inlined_vector
- abseil/random/internal/nonsecure_base
- abseil/random/internal/pool_urbg
- abseil/random/internal/salted_seed_seq
- abseil/random/internal/seed_material
- abseil/random/seed_gen_exception
- abseil/types/span
- abseil/status/status (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/container/inlined_vector
- abseil/debugging/stacktrace
- abseil/debugging/symbolize
- abseil/functional/function_ref
- abseil/strings/cord
- abseil/strings/str_format
- abseil/strings/strings
- abseil/types/optional
- abseil/status/statusor (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/meta/type_traits
- abseil/status/status
- abseil/strings/strings
- abseil/types/variant
- abseil/utility/utility
- abseil/strings/cord (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/container/fixed_array
- abseil/container/inlined_vector
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/strings/cord_internal
- abseil/strings/cordz_functions
- abseil/strings/cordz_info
- abseil/strings/cordz_statistics
- abseil/strings/cordz_update_scope
- abseil/strings/cordz_update_tracker
- abseil/strings/internal
- abseil/strings/str_format
- abseil/strings/strings
- abseil/types/optional
- abseil/strings/cord_internal (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/base/throw_delegate
- abseil/container/compressed_tuple
- abseil/container/inlined_vector
- abseil/container/layout
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/strings/strings
- abseil/types/span
- abseil/strings/cordz_functions (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/profiling/exponential_biased
- abseil/strings/cordz_handle (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/synchronization/synchronization
- abseil/strings/cordz_info (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/container/inlined_vector
- abseil/debugging/stacktrace
- abseil/strings/cord_internal
- abseil/strings/cordz_functions
- abseil/strings/cordz_handle
- abseil/strings/cordz_statistics
- abseil/strings/cordz_update_tracker
- abseil/synchronization/synchronization
- abseil/types/span
- abseil/strings/cordz_statistics (1.20211102.0):
- abseil/base/config
- abseil/strings/cordz_update_tracker
- abseil/strings/cordz_update_scope (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/strings/cord_internal
- abseil/strings/cordz_info
- abseil/strings/cordz_update_tracker
- abseil/strings/cordz_update_tracker (1.20211102.0):
- abseil/base/config
- abseil/strings/internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/meta/type_traits
- abseil/strings/str_format (1.20211102.0):
- abseil/strings/str_format_internal
- abseil/strings/str_format_internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/numeric/representation
- abseil/strings/strings
- abseil/types/optional
- abseil/types/span
- abseil/strings/strings (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/base/throw_delegate
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/strings/internal
- abseil/synchronization/graphcycles_internal (1.20211102.0):
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/synchronization/kernel_timeout_internal (1.20211102.0):
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/time/time
- abseil/synchronization/synchronization (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/debugging/stacktrace
- abseil/debugging/symbolize
- abseil/synchronization/graphcycles_internal
- abseil/synchronization/kernel_timeout_internal
- abseil/time/time
- abseil/time (1.20211102.0):
- abseil/time/internal (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/time/internal (1.20211102.0):
- abseil/time/internal/cctz (= 1.20211102.0)
- abseil/time/internal/cctz (1.20211102.0):
- abseil/time/internal/cctz/civil_time (= 1.20211102.0)
- abseil/time/internal/cctz/time_zone (= 1.20211102.0)
- abseil/time/internal/cctz/civil_time (1.20211102.0):
- abseil/base/config
- abseil/time/internal/cctz/time_zone (1.20211102.0):
- abseil/base/config
- abseil/time/internal/cctz/civil_time
- abseil/time/time (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/numeric/int128
- abseil/strings/strings
- abseil/time/internal/cctz/civil_time
- abseil/time/internal/cctz/time_zone
- abseil/types (1.20211102.0):
- abseil/types/any (= 1.20211102.0)
- abseil/types/bad_any_cast (= 1.20211102.0)
- abseil/types/bad_any_cast_impl (= 1.20211102.0)
- abseil/types/bad_optional_access (= 1.20211102.0)
- abseil/types/bad_variant_access (= 1.20211102.0)
- abseil/types/compare (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/span (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/types/any (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/fast_type_id
- abseil/meta/type_traits
- abseil/types/bad_any_cast
- abseil/utility/utility
- abseil/types/bad_any_cast (1.20211102.0):
- abseil/base/config
- abseil/types/bad_any_cast_impl
- abseil/types/bad_any_cast_impl (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/bad_optional_access (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/bad_variant_access (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/compare (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/types/optional (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/types/bad_optional_access
- abseil/utility/utility
- abseil/types/span (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/base/throw_delegate
- abseil/meta/type_traits
- abseil/types/variant (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/types/bad_variant_access
- abseil/utility/utility
- abseil/utility/utility (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/meta/type_traits
- BoringSSL-GRPC (0.0.24):
- BoringSSL-GRPC/Implementation (= 0.0.24)
- BoringSSL-GRPC/Interface (= 0.0.24)
- BoringSSL-GRPC/Implementation (0.0.24):
- BoringSSL-GRPC/Interface (= 0.0.24)
- BoringSSL-GRPC/Interface (0.0.24)
- cloud_firestore (4.6.0):
- Firebase/Firestore (= 10.7.0)
- firebase_core
- Flutter
- nanopb (< 2.30910.0, >= 2.30908.0)
- Firebase/Auth (10.7.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 10.7.0)
- Firebase/CoreOnly (10.7.0):
- FirebaseCore (= 10.7.0)
- Firebase/Firestore (10.7.0):
- Firebase/CoreOnly
- FirebaseFirestore (~> 10.7.0)
- Firebase/Storage (10.7.0):
- Firebase/CoreOnly
- FirebaseStorage (~> 10.7.0)
- firebase_auth (4.5.0):
- Firebase/Auth (= 10.7.0)
- firebase_core
- Flutter
- firebase_core (2.11.0):
- Firebase/CoreOnly (= 10.7.0)
- Flutter
- firebase_storage (11.1.2):
- Firebase/Storage (= 10.7.0)
- firebase_core
- Flutter
- FirebaseAppCheckInterop (10.9.0)
- FirebaseAuth (10.7.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
- FirebaseAuthInterop (10.9.0)
- FirebaseCore (10.7.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreExtension (10.9.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.9.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseFirestore (10.7.0):
- abseil/algorithm (~> 1.20211102.0)
- abseil/base (~> 1.20211102.0)
- abseil/container/flat_hash_map (~> 1.20211102.0)
- abseil/memory (~> 1.20211102.0)
- abseil/meta (~> 1.20211102.0)
- abseil/strings/strings (~> 1.20211102.0)
- abseil/time (~> 1.20211102.0)
- abseil/types (~> 1.20211102.0)
- FirebaseCore (~> 10.0)
- "gRPC-C++ (~> 1.44.0)"
- leveldb-library (~> 1.22)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseStorage (10.7.0):
- FirebaseAppCheckInterop (~> 10.0)
- FirebaseAuthInterop (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseCoreExtension (~> 10.0)
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
- Flutter (1.0.0)
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.11.1):
- GoogleUtilities/Logger
- "gRPC-C++ (1.44.0)":
- "gRPC-C++/Implementation (= 1.44.0)"
- "gRPC-C++/Interface (= 1.44.0)"
- "gRPC-C++/Implementation (1.44.0)":
- abseil/base/base (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/container/flat_hash_map (= 1.20211102.0)
- abseil/container/inlined_vector (= 1.20211102.0)
- abseil/functional/bind_front (= 1.20211102.0)
- abseil/hash/hash (= 1.20211102.0)
- abseil/memory/memory (= 1.20211102.0)
- abseil/random/random (= 1.20211102.0)
- abseil/status/status (= 1.20211102.0)
- abseil/status/statusor (= 1.20211102.0)
- abseil/strings/cord (= 1.20211102.0)
- abseil/strings/str_format (= 1.20211102.0)
- abseil/strings/strings (= 1.20211102.0)
- abseil/synchronization/synchronization (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/utility/utility (= 1.20211102.0)
- "gRPC-C++/Interface (= 1.44.0)"
- gRPC-Core (= 1.44.0)
- "gRPC-C++/Interface (1.44.0)"
- gRPC-Core (1.44.0):
- gRPC-Core/Implementation (= 1.44.0)
- gRPC-Core/Interface (= 1.44.0)
- gRPC-Core/Implementation (1.44.0):
- abseil/base/base (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/container/flat_hash_map (= 1.20211102.0)
- abseil/container/inlined_vector (= 1.20211102.0)
- abseil/functional/bind_front (= 1.20211102.0)
- abseil/hash/hash (= 1.20211102.0)
- abseil/memory/memory (= 1.20211102.0)
- abseil/random/random (= 1.20211102.0)
- abseil/status/status (= 1.20211102.0)
- abseil/status/statusor (= 1.20211102.0)
- abseil/strings/cord (= 1.20211102.0)
- abseil/strings/str_format (= 1.20211102.0)
- abseil/strings/strings (= 1.20211102.0)
- abseil/synchronization/synchronization (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/utility/utility (= 1.20211102.0)
- BoringSSL-GRPC (= 0.0.24)
- gRPC-Core/Interface (= 1.44.0)
- Libuv-gRPC (= 0.0.10)
- gRPC-Core/Interface (1.44.0)
- GTMSessionFetcher/Core (3.1.1)
- image_picker_ios (0.0.1):
- Flutter
- leveldb-library (1.22.2)
- Libuv-gRPC (0.0.10):
- Libuv-gRPC/Implementation (= 0.0.10)
- Libuv-gRPC/Interface (= 0.0.10)
- Libuv-gRPC/Implementation (0.0.10):
- Libuv-gRPC/Interface (= 0.0.10)
- Libuv-gRPC/Interface (0.0.10)
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.2.0)
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
DEPENDENCIES:
- cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`)
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_storage (from `.symlinks/plugins/firebase_storage/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
SPEC REPOS:
trunk:
- abseil
- BoringSSL-GRPC
- Firebase
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseAuthInterop
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseFirestore
- FirebaseStorage
- FMDB
- GoogleUtilities
- "gRPC-C++"
- gRPC-Core
- GTMSessionFetcher
- leveldb-library
- Libuv-gRPC
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
cloud_firestore:
:path: ".symlinks/plugins/cloud_firestore/ios"
firebase_auth:
:path: ".symlinks/plugins/firebase_auth/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_storage:
:path: ".symlinks/plugins/firebase_storage/ios"
Flutter:
:path: Flutter
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
SPEC CHECKSUMS:
abseil: ebe5b5529fb05d93a8bdb7951607be08b7fa71bc
BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33
cloud_firestore: 3809dfc121ab99b81bba51eb84ac6ca3f9033325
Firebase: 0219acf760880eeec8ce479895bd7767466d9f81
firebase_auth: 1eb69de8f57040c5c78bb3267412e9cb395fd08c
firebase_core: dee76ded6c693fdb38b8ea39aef7129e32e587a3
firebase_storage: f6fb3e54e886182b5ab5cf514da1d9ceb196fa2f
FirebaseAppCheckInterop: e69dde5cd51b88ee1b4339d6766b691272256f9b
FirebaseAuth: dd64c01631df724b09f33e584625775c52f7d71f
FirebaseAuthInterop: e53c08e60a02de17d1ab77c5032db8ae22d3a799
FirebaseCore: e317665b9d744727a97e623edbbed009320afdd7
FirebaseCoreExtension: d3e9bba2930a8033042112397cd9f006a1bb203d
FirebaseCoreInternal: d2b4acb827908e72eca47a9fd896767c3053921e
FirebaseFirestore: 3963a6edd1c84b4748dab3e2c62624a29d03eca1
FirebaseStorage: 4841efa304543e1f9e4ca116c559c7a1ea2a9d0f
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
"gRPC-C++": 9675f953ace2b3de7c506039d77be1f2e77a8db2
gRPC-Core: 943e491cb0d45598b0b0eb9e910c88080369290b
GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
leveldb-library: f03246171cce0484482ec291f88b6d563699ee06
Libuv-gRPC: 55e51798e14ef436ad9bc45d12d43b77b49df378
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
PODFILE CHECKSUM: 7d6b9db9243dbc65e9914f56de22b83205a8e90d
COCOAPODS: 1.12.1
+10 -10
View File
@@ -160,7 +160,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1510;
LastUpgradeCheck = 1300;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -348,7 +348,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -363,13 +363,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -436,7 +436,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -486,7 +486,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -503,13 +503,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -535,13 +535,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+4 -7
View File
@@ -1,16 +1,13 @@
import Flutter
import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
-21
View File
@@ -26,27 +26,6 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
+1 -1
View File
@@ -38,7 +38,7 @@ class MyApp extends StatelessWidget {
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
),
cardTheme: CardThemeData(
cardTheme: CardTheme(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
+4 -215
View File
@@ -11,28 +11,19 @@ class PostItem {
final String? street;
final String? category;
final String? status;
final String? pdfUrl;
final String? webUrl;
final bool isAussendung;
PostItem({
required this.id,
required this.title,
required this.description,
String imageUrl = '',
this.imageUrl = '',
List<String>? imageUrls,
required this.createdDate,
this.authorUid,
this.street,
this.category,
this.status,
this.pdfUrl,
this.webUrl,
this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []),
imageUrl = imageUrl.isNotEmpty
? imageUrl
: (imageUrls != null && imageUrls.isNotEmpty ? imageUrls.first : '');
this.status = 'Gemeldet',
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
factory PostItem.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
@@ -52,9 +43,6 @@ class PostItem {
images = [legacyImage];
}
final hasIssueFields = data['street'] != null || data['category'] != null;
final status = data['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
return PostItem(
id: doc.id,
title: data['title'] as String? ?? '',
@@ -65,10 +53,7 @@ class PostItem {
authorUid: data['author_uid'] as String?,
street: data['street'] as String?,
category: data['category'] as String?,
status: status,
pdfUrl: data['pdfUrl'] as String?,
webUrl: data['webUrl'] as String?,
isAussendung: data['isAussendung'] as bool? ?? false,
status: data['status'] as String? ?? 'Gemeldet',
);
}
@@ -83,202 +68,6 @@ class PostItem {
if (street != null) 'street': street,
if (category != null) 'category': category,
if (status != null) 'status': status,
if (pdfUrl != null) 'pdfUrl': pdfUrl,
if (webUrl != null) 'webUrl': webUrl,
if (isAussendung) 'isAussendung': isAussendung,
};
}
/// Convert to standard JSON map for local caching
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'imageUrl': imageUrl,
'imageUrls': imageUrls,
'createdDate': createdDate.toIso8601String(),
'authorUid': authorUid,
'street': street,
'category': category,
'status': status,
'pdfUrl': pdfUrl,
'webUrl': webUrl,
'isAussendung': isAussendung,
};
}
/// Create from standard JSON map (local cache)
factory PostItem.fromJson(Map<String, dynamic> map) {
DateTime date;
if (map['createdDate'] is String) {
date = DateTime.tryParse(map['createdDate'] as String) ?? DateTime.now();
} else {
date = DateTime.now();
}
final String legacyImage = map['imageUrl'] as String? ?? '';
List<String> images = [];
if (map['imageUrls'] is List) {
images = List<String>.from(map['imageUrls'] as List);
} else if (legacyImage.isNotEmpty) {
images = [legacyImage];
}
final hasIssueFields = map['street'] != null || map['category'] != null;
final status =
map['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
return PostItem(
id: map['id'] as String? ?? '',
title: map['title'] as String? ?? '',
description: map['description'] as String? ?? '',
imageUrl: legacyImage.isNotEmpty
? legacyImage
: (images.isNotEmpty ? images.first : ''),
imageUrls: images,
createdDate: date,
authorUid: map['authorUid'] as String?,
street: map['street'] as String?,
category: map['category'] as String?,
status: status,
pdfUrl: map['pdfUrl'] as String?,
webUrl: map['webUrl'] as String?,
isAussendung: map['isAussendung'] as bool? ?? false,
);
}
/// Decode HTML entities commonly present in WordPress feeds
static String unescapeHtml(String text) {
var result = text
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.replaceAll('&#8211;', '–')
.replaceAll('&#8212;', '—')
.replaceAll('&#8216;', '‘')
.replaceAll('&#8217;', '’')
.replaceAll('&#8220;', '“')
.replaceAll('&#8221;', '”')
.replaceAll('&#8230;', '…');
// Decode numerical entities &#DDD;
result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) {
final code = int.tryParse(match.group(1)!);
return code != null ? String.fromCharCode(code) : match.group(0)!;
});
// Decode hex numerical entities &#xHH;
result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) {
final code = int.tryParse(match.group(1)!, radix: 16);
return code != null ? String.fromCharCode(code) : match.group(0)!;
});
return result.trim();
}
/// Extracts the direct PDF URL from WordPress post HTML content
static String? extractPdfUrl(String htmlContent) {
final pdfRegExp = RegExp(
r'https?://[^\s"<>]+?\.pdf',
caseSensitive: false,
);
final match = pdfRegExp.firstMatch(htmlContent);
if (match != null) {
return match.group(0);
}
// Fallback: check docx or doc files
final docRegExp = RegExp(
r'https?://[^\s"<>]+?\.(?:docx|doc)',
caseSensitive: false,
);
final docMatch = docRegExp.firstMatch(htmlContent);
return docMatch?.group(0);
}
/// Strip HTML tags and clean whitespace for preview text
static String extractCleanText(String htmlContent) {
var clean = htmlContent.replaceAll(RegExp(r'<style[^>]*>.*?</style>', dotAll: true), '');
clean = clean.replaceAll(RegExp(r'<script[^>]*>.*?</script>', dotAll: true), '');
clean = clean.replaceAll(RegExp(r'<[^>]*>'), ' ');
clean = unescapeHtml(clean);
clean = clean.replaceAll(RegExp(r'\s+'), ' ').trim();
return clean;
}
/// Parse a post from the WordPress REST API (/wp-json/wp/v2/posts)
factory PostItem.fromWordPressJson(Map<String, dynamic> json) {
final rawTitle = (json['title'] is Map)
? (json['title']['rendered'] as String? ?? '')
: (json['title'] as String? ?? '');
final title = unescapeHtml(rawTitle);
final rawContent = (json['content'] is Map)
? (json['content']['rendered'] as String? ?? '')
: (json['content'] as String? ?? '');
final pdfUrl = extractPdfUrl(rawContent);
final webUrl = json['link'] as String?;
DateTime date;
if (json['date'] is String) {
date = DateTime.tryParse(json['date'] as String) ?? DateTime.now();
} else {
date = DateTime.now();
}
String desc = extractCleanText(rawContent);
if (desc.isEmpty || desc == title) {
desc = 'Offizielle Aussendung des Bürgerforums Oggau (PDF).';
}
return PostItem(
id: 'wp_${json['id']}',
title: title.isNotEmpty ? title : 'Aussendung',
description: desc,
createdDate: date,
category: 'Aussendung',
status: 'Aussendung',
pdfUrl: pdfUrl,
webUrl: webUrl,
isAussendung: true,
);
}
/// Create a copy of this PostItem with updated fields
PostItem copyWith({
String? id,
String? title,
String? description,
String? imageUrl,
List<String>? imageUrls,
DateTime? createdDate,
String? authorUid,
String? street,
String? category,
String? status,
String? pdfUrl,
String? webUrl,
bool? isAussendung,
}) {
return PostItem(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
imageUrl: imageUrl ?? this.imageUrl,
imageUrls: imageUrls ?? this.imageUrls,
createdDate: createdDate ?? this.createdDate,
authorUid: authorUid ?? this.authorUid,
street: street ?? this.street,
category: category ?? this.category,
status: status ?? this.status,
pdfUrl: pdfUrl ?? this.pdfUrl,
webUrl: webUrl ?? this.webUrl,
isAussendung: isAussendung ?? this.isAussendung,
);
}
}
+13 -192
View File
@@ -1,18 +1,13 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddIssueScreen extends StatefulWidget {
final PostItem? issueToEdit;
const AddIssueScreen({super.key, this.issueToEdit});
const AddIssueScreen({super.key});
@override
State<AddIssueScreen> createState() => _AddIssueScreenState();
@@ -27,18 +22,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
final StorageService _storageService = StorageService();
final List<File> _selectedImages = [];
List<String> _existingImageUrls = [];
String _selectedStatus = 'Gemeldet';
bool _isSaving = false;
bool get isEditing => widget.issueToEdit != null;
final List<String> _statuses = [
'Gemeldet',
'In Bearbeitung',
'Erledigt',
];
final List<String> _streets = [
'Hauptstraße',
'Seestraße',
@@ -71,32 +56,9 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
@override
void initState() {
super.initState();
if (widget.issueToEdit != null) {
final issue = widget.issueToEdit!;
_titleController.text = issue.title;
_descriptionController.text = issue.description;
if (issue.street != null && _streets.contains(issue.street)) {
_selectedStreet = issue.street;
} else {
_selectedStreet = _streets.first;
}
if (issue.category != null && _categories.contains(issue.category)) {
_selectedCategory = issue.category;
} else {
_selectedCategory = _categories.first;
}
_existingImageUrls = List<String>.from(issue.imageUrls);
if (_existingImageUrls.isEmpty && issue.imageUrl.isNotEmpty) {
_existingImageUrls.add(issue.imageUrl);
}
if (issue.status != null && _statuses.contains(issue.status)) {
_selectedStatus = issue.status!;
}
} else {
_selectedStreet = _streets.first;
_selectedCategory = _categories.first;
}
}
@override
void dispose() {
@@ -106,10 +68,9 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
}
Future<void> _pickImage(ImageSource source) async {
if (_existingImageUrls.length + _selectedImages.length >= 2) {
if (_selectedImages.length >= 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
const SnackBar(content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
);
return;
}
@@ -138,7 +99,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
@@ -175,51 +135,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
});
try {
if (isEditing) {
// Delete removed old images from Storage
for (final oldUrl in widget.issueToEdit!.imageUrls) {
if (!_existingImageUrls.contains(oldUrl)) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
// Upload newly selected images to Storage
final List<String> newlyUploadedUrls = [];
for (final file in _selectedImages) {
final url = await _storageService.uploadFile(
file: file,
collectionName: 'Issues',
optimize: true,
);
newlyUploadedUrls.add(url);
}
final allImages = [..._existingImageUrls, ...newlyUploadedUrls];
final updatedIssue = widget.issueToEdit!.copyWith(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: allImages.isNotEmpty ? allImages.first : '',
imageUrls: allImages,
street: _selectedStreet,
category: _selectedCategory,
status: _selectedStatus,
);
await _firestoreService.updatePost('Issues', updatedIssue);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mangel erfolgreich aktualisiert!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, updatedIssue);
} else {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
// Upload selected images to Firebase Storage
@@ -256,8 +171,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context, newIssue);
}
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -277,21 +191,12 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context, listen: false);
final isAdmin = auth.isAdmin;
final totalPhotos = _existingImageUrls.length + _selectedImages.length;
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Mangel bearbeiten' : 'Mangel melden'),
title: const Text('Mangel melden'),
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
@@ -310,7 +215,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedStreet,
value: _selectedStreet,
decoration: const InputDecoration(
labelText: 'Straße / Örtlichkeit *',
border: OutlineInputBorder(),
@@ -327,7 +232,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedCategory,
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Grundlage / Kategorie *',
border: OutlineInputBorder(),
@@ -340,26 +245,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
.toList(),
onChanged: (val) => setState(() => _selectedCategory = val),
),
if (isAdmin && isEditing) ...[
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedStatus,
decoration: const InputDecoration(
labelText: 'Bearbeitungsstatus (Admin) *',
prefixIcon: Icon(Icons.assignment_turned_in_outlined),
border: OutlineInputBorder(),
),
items: _statuses
.map((status) => DropdownMenuItem(
value: status,
child: Text(status),
))
.toList(),
onChanged: (val) {
if (val != null) setState(() => _selectedStatus = val);
},
),
],
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
@@ -380,60 +265,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
Row(
children: [
// Existing images from Firebase Storage
..._existingImageUrls.asMap().entries.map((entry) {
final index = entry.key;
final url = entry.value;
return Stack(
children: [
Container(
margin: const EdgeInsets.only(right: 12),
width: 110,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
placeholder: (ctx, u) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (ctx, u, e) => Container(
color: Colors.grey.shade200,
child: const Icon(Icons.broken_image, size: 28),
),
),
),
Positioned(
top: -4,
right: 4,
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.black54,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close,
color: Colors.white, size: 16),
tooltip: 'Foto entfernen',
onPressed: () {
setState(() {
_existingImageUrls.removeAt(index);
});
},
),
),
),
],
);
}),
// Newly picked local image files
..._selectedImages.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
@@ -455,14 +288,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
Positioned(
top: -4,
right: 4,
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.black54,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close,
color: Colors.white, size: 16),
tooltip: 'Foto entfernen',
icon: const Icon(Icons.cancel, color: Colors.red),
onPressed: () {
setState(() {
_selectedImages.removeAt(index);
@@ -470,11 +297,10 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
},
),
),
),
],
);
}),
if (totalPhotos < 2)
if (_selectedImages.length < 2)
InkWell(
onTap: _showImageSourceModal,
child: Container(
@@ -501,7 +327,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -515,13 +340,9 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(isEditing ? Icons.save : Icons.check_circle_outline),
: const Icon(Icons.check_circle_outline),
label: Text(
_isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Mangel fertigstellen'),
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
style: const TextStyle(fontSize: 16),
),
),
+8 -143
View File
@@ -1,5 +1,4 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
@@ -10,13 +9,11 @@ import '../services/storage_service.dart';
class AddPostScreen extends StatefulWidget {
final String collectionName;
final String screenTitle;
final PostItem? postToEdit;
const AddPostScreen({
super.key,
required this.collectionName,
required this.screenTitle,
this.postToEdit,
});
@override
@@ -32,25 +29,8 @@ class _AddPostScreenState extends State<AddPostScreen> {
final StorageService _storageService = StorageService();
File? _selectedImage;
String? _existingImageUrl;
bool _isSaving = false;
bool get isEditing => widget.postToEdit != null;
@override
void initState() {
super.initState();
if (widget.postToEdit != null) {
_titleController.text = widget.postToEdit!.title;
_descriptionController.text = widget.postToEdit!.description;
if (widget.postToEdit!.imageUrls.isNotEmpty) {
_existingImageUrl = widget.postToEdit!.imageUrls.first;
} else if (widget.postToEdit!.imageUrl.isNotEmpty) {
_existingImageUrl = widget.postToEdit!.imageUrl;
}
}
}
@override
void dispose() {
_titleController.dispose();
@@ -83,7 +63,6 @@ class _AddPostScreenState extends State<AddPostScreen> {
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
@@ -120,52 +99,6 @@ class _AddPostScreenState extends State<AddPostScreen> {
});
try {
if (isEditing) {
String imageUrl = _existingImageUrl ?? '';
if (_selectedImage != null) {
// If a new image was chosen, remove the old one from storage if it existed
if (widget.postToEdit!.imageUrls.isNotEmpty) {
for (final oldUrl in widget.postToEdit!.imageUrls) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
imageUrl = await _storageService.uploadFile(
file: _selectedImage!,
collectionName: widget.collectionName,
optimize: true,
);
} else if (_existingImageUrl == null &&
widget.postToEdit!.imageUrls.isNotEmpty) {
// Existing image was deleted by the user
for (final oldUrl in widget.postToEdit!.imageUrls) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
final updatedPost = widget.postToEdit!.copyWith(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: imageUrl,
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
);
await _firestoreService.updatePost(widget.collectionName, updatedPost);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eintrag erfolgreich aktualisiert!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, updatedPost);
} else {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
String imageUrl = '';
@@ -197,8 +130,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context, newPost);
}
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -220,15 +152,10 @@ class _AddPostScreenState extends State<AddPostScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Beitrag bearbeiten' : widget.screenTitle),
title: Text(widget.screenTitle),
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
@@ -285,68 +212,12 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () =>
setState(() => _selectedImage = null),
onPressed: () => setState(() => _selectedImage = null),
),
),
),
],
)
else if (_existingImageUrl != null &&
_existingImageUrl!.isNotEmpty)
Stack(
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade400),
),
clipBehavior: Clip.antiAlias,
child: CachedNetworkImage(
imageUrl: _existingImageUrl!,
fit: BoxFit.cover,
placeholder: (ctx, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (ctx, url, err) => Container(
color: Colors.grey.shade200,
child: const Icon(Icons.broken_image, size: 40),
),
),
),
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
tooltip: 'Bild entfernen',
onPressed: () =>
setState(() => _existingImageUrl = null),
),
),
),
Positioned(
bottom: 8,
right: 8,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
),
onPressed: _showImageSourceModal,
icon: const Icon(Icons.camera_alt, size: 16),
label: const Text('Ersetzen',
style: TextStyle(fontSize: 12)),
),
),
],
)
else
InkWell(
onTap: _showImageSourceModal,
@@ -361,11 +232,9 @@ class _AddPostScreenState extends State<AddPostScreen> {
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate,
size: 36, color: Colors.grey),
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey),
SizedBox(height: 6),
Text('Foto hinzufügen',
style: TextStyle(color: Colors.black54)),
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)),
],
),
),
@@ -383,13 +252,9 @@ class _AddPostScreenState extends State<AddPostScreen> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(isEditing ? Icons.save : Icons.send),
: const Icon(Icons.send),
label: Text(
_isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Veröffentlichen'),
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
style: const TextStyle(fontSize: 16),
),
),
-614
View File
@@ -1,614 +0,0 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/firestore_service.dart';
class AdminMessagesScreen extends StatefulWidget {
const AdminMessagesScreen({super.key});
@override
State<AdminMessagesScreen> createState() => _AdminMessagesScreenState();
}
class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
final FirestoreService _firestoreService = FirestoreService();
String _selectedFilter = 'all'; // 'all', 'Neu', 'In Bearbeitung', 'Erledigt'
String _searchQuery = '';
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _sendMail(String email) async {
final uri = Uri(scheme: 'mailto', path: email);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('E-Mail-App konnte nicht geöffnet werden: $e')),
);
}
}
}
Future<void> _callPhone(String phone) async {
final cleanPhone = phone.replaceAll(RegExp(r'\s+'), '');
final uri = Uri(scheme: 'tel', path: cleanPhone);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Telefon-App konnte nicht geöffnet werden: $e')),
);
}
}
}
void _showMessageDetail(
BuildContext context,
String docId,
Map<String, dynamic> data,
) {
final firstName = data['firstName'] as String? ?? '';
final lastName = data['lastName'] as String? ?? '';
final rawName = '$firstName $lastName'.trim();
final fullName = rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
final email = data['email'] as String? ?? '';
final phone = data['phone'] as String? ?? '';
final message = data['message'] as String? ?? '';
final currentStatus = data['status'] as String? ?? 'Neu';
DateTime createdDate;
if (data['createdDate'] is Timestamp) {
createdDate = (data['createdDate'] as Timestamp).toDate();
} else {
createdDate = DateTime.now();
}
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setModalState) {
final bottomInset = MediaQuery.viewPaddingOf(ctx).bottom > 0
? MediaQuery.viewPaddingOf(ctx).bottom
: MediaQuery.paddingOf(ctx).bottom;
final keyboardInset = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: bottomInset + keyboardInset + 20,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
fullName,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
),
),
),
_buildStatusBadge(currentStatus),
],
),
const SizedBox(height: 4),
Text(
'Eingegangen: $formattedDate Uhr',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const Divider(height: 24),
if (email.isNotEmpty) ...[
Row(
children: [
const Icon(Icons.email_outlined,
size: 18, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: Text(
email,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextButton.icon(
onPressed: () => _sendMail(email),
icon: const Icon(Icons.send, size: 16),
label: const Text('Antworten'),
),
],
),
],
if (phone.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.phone_outlined,
size: 18, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text(
phone,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextButton.icon(
onPressed: () => _callPhone(phone),
icon: const Icon(Icons.call, size: 16),
label: const Text('Anrufen'),
),
],
),
],
const SizedBox(height: 14),
const Text(
'Nachricht / Anliegen:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
message,
style: const TextStyle(fontSize: 14, height: 1.45),
),
),
const SizedBox(height: 20),
const Text(
'Status ändern:',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
_buildStatusButton(docId, 'Neu', currentStatus, Colors.orange),
const SizedBox(width: 8),
_buildStatusButton(
docId, 'In Bearbeitung', currentStatus, Colors.blue),
const SizedBox(width: 8),
_buildStatusButton(
docId, 'Erledigt', currentStatus, Colors.green),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.red),
icon: const Icon(Icons.delete_outline),
label: const Text('Nachricht löschen'),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (dCtx) => AlertDialog(
title: const Text('Nachricht löschen?'),
content: const Text(
'Möchten Sie diese Bürgernachricht unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirm == true) {
await _firestoreService.deleteContactMessage(docId);
if (context.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nachricht gelöscht.')),
);
}
}
},
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Schließen'),
),
],
),
],
),
),
);
},
),
);
}
Widget _buildStatusButton(
String docId,
String targetStatus,
String currentStatus,
Color color,
) {
final isCurrent = currentStatus == targetStatus;
return Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: isCurrent ? color.withValues(alpha: 0.15) : null,
side: BorderSide(
color: isCurrent ? color : Colors.grey.shade300,
width: isCurrent ? 2 : 1,
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
onPressed: () async {
await _firestoreService.updateContactMessageStatus(
docId, targetStatus);
if (mounted) Navigator.pop(context);
},
child: Text(
targetStatus,
style: TextStyle(
fontSize: 11,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
color: isCurrent ? color : Colors.black87,
),
),
),
);
}
Widget _buildStatusBadge(String status) {
Color bg;
Color fg;
switch (status) {
case 'In Bearbeitung':
bg = Colors.blue.shade50;
fg = Colors.blue.shade800;
break;
case 'Erledigt':
bg = Colors.green.shade50;
fg = Colors.green.shade800;
break;
case 'Neu':
default:
bg = Colors.orange.shade50;
fg = Colors.orange.shade900;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: fg.withValues(alpha: 0.3)),
),
child: Text(
status,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: fg,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: _isSearchActive
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Nachrichten durchsuchen...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: const Text('Posteingang (Bürgernachrichten)'),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
onPressed: () {
setState(() {
if (_isSearchActive) {
_isSearchActive = false;
_searchQuery = '';
_searchController.clear();
} else {
_isSearchActive = true;
}
});
},
),
],
),
body: Column(
children: [
// Filter Chips
Container(
width: double.infinity,
color: Colors.grey.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Alle'),
selected: _selectedFilter == 'all',
onSelected: (_) =>
setState(() => _selectedFilter = 'all'),
),
const SizedBox(width: 8),
ChoiceChip(
avatar: const Icon(Icons.fiber_new,
size: 16, color: Colors.orange),
label: const Text('Neu'),
selected: _selectedFilter == 'Neu',
onSelected: (_) =>
setState(() => _selectedFilter = 'Neu'),
),
const SizedBox(width: 8),
ChoiceChip(
avatar: const Icon(Icons.hourglass_top,
size: 16, color: Colors.blue),
label: const Text('In Bearbeitung'),
selected: _selectedFilter == 'In Bearbeitung',
onSelected: (_) =>
setState(() => _selectedFilter = 'In Bearbeitung'),
),
const SizedBox(width: 8),
ChoiceChip(
avatar: const Icon(Icons.check_circle_outline,
size: 16, color: Colors.green),
label: const Text('Erledigt'),
selected: _selectedFilter == 'Erledigt',
onSelected: (_) =>
setState(() => _selectedFilter = 'Erledigt'),
),
],
),
),
),
Expanded(
child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: _firestoreService.getContactMessagesStream(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Fehler beim Abrufen der Nachrichten: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final docs = snapshot.data?.docs ?? [];
final filteredDocs = docs.where((doc) {
final data = doc.data();
final status = data['status'] as String? ?? 'Neu';
if (_selectedFilter != 'all' && status != _selectedFilter) {
return false;
}
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
final fn = (data['firstName'] as String? ?? '').toLowerCase();
final ln = (data['lastName'] as String? ?? '').toLowerCase();
final em = (data['email'] as String? ?? '').toLowerCase();
final msg = (data['message'] as String? ?? '').toLowerCase();
return fn.contains(q) ||
ln.contains(q) ||
em.contains(q) ||
msg.contains(q);
}
return true;
}).toList();
if (filteredDocs.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mark_email_read_outlined,
size: 64, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
_searchQuery.isNotEmpty
? 'Keine Treffer für "'
: 'Keine Nachrichten vorhanden.',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredDocs.length,
separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) {
final doc = filteredDocs[index];
final data = doc.data();
final firstName = data['firstName'] as String? ?? '';
final lastName = data['lastName'] as String? ?? '';
final rawName = '$firstName $lastName'.trim();
final fullName =
rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
final email = data['email'] as String? ?? '';
final message = data['message'] as String? ?? '';
final status = data['status'] as String? ?? 'Neu';
DateTime createdDate;
if (data['createdDate'] is Timestamp) {
createdDate = (data['createdDate'] as Timestamp).toDate();
} else {
createdDate = DateTime.now();
}
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: status == 'Neu'
? Colors.orange.shade100
: status == 'In Bearbeitung'
? Colors.blue.shade100
: Colors.green.shade100,
child: Text(
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
style: TextStyle(
fontWeight: FontWeight.bold,
color: status == 'Neu'
? Colors.orange.shade900
: status == 'In Bearbeitung'
? Colors.blue.shade900
: Colors.green.shade900,
),
),
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
fullName,
style: TextStyle(
fontSize: 15,
fontWeight: status == 'Neu'
? FontWeight.bold
: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (email.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
email,
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade800,
),
),
],
const SizedBox(height: 4),
Text(
message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade800,
),
),
],
),
trailing: _buildStatusBadge(status),
onTap: () => _showMessageDetail(context, doc.id, data),
);
},
);
},
),
),
],
),
);
}
}
+68 -320
View File
@@ -25,8 +25,7 @@ class _AuthDialogState extends State<AuthDialog>
final _loginPasswordController = TextEditingController();
// Register Controllers
final _regFirstNameController = TextEditingController();
final _regLastNameController = TextEditingController();
final _regNameController = TextEditingController();
final _regEmailController = TextEditingController();
final _regPasswordController = TextEditingController();
final _regConfirmPasswordController = TextEditingController();
@@ -37,14 +36,10 @@ class _AuthDialogState extends State<AuthDialog>
bool _isLoading = false;
String? _errorMessage;
bool _obscureLoginPassword = true;
bool _obscureRegPassword = true;
bool _obscureRegConfirmPassword = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController = TabController(length: 3, vsync: this);
}
@override
@@ -52,8 +47,7 @@ class _AuthDialogState extends State<AuthDialog>
_tabController.dispose();
_loginEmailController.dispose();
_loginPasswordController.dispose();
_regFirstNameController.dispose();
_regLastNameController.dispose();
_regNameController.dispose();
_regEmailController.dispose();
_regPasswordController.dispose();
_regConfirmPasswordController.dispose();
@@ -95,14 +89,13 @@ class _AuthDialogState extends State<AuthDialog>
}
Future<void> _handleRegister() async {
final firstName = _regFirstNameController.text.trim();
final lastName = _regLastNameController.text.trim();
final name = _regNameController.text.trim();
final email = _regEmailController.text.trim();
final password = _regPasswordController.text;
final confirm = _regConfirmPasswordController.text;
if (firstName.isEmpty || lastName.isEmpty || email.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen (Vorname, Nachname, E-Mail, Passwort).');
if (name.isEmpty || email.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen.');
return;
}
@@ -126,46 +119,17 @@ class _AuthDialogState extends State<AuthDialog>
final error = await auth.signUpWithEmail(
email: email,
password: password,
firstName: firstName,
lastName: lastName,
name: name,
);
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Konto erfolgreich erstellt! Wir haben einen Bestätigungslink an $email gesendet. Bitte prüfe dein Postfach.',
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 6),
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleGoogleSignIn() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signInWithGoogle();
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erfolgreich mit Google angemeldet!'),
content: Text('Konto erfolgreich erstellt! Willkommen im Bürgerforum.'),
backgroundColor: Colors.green,
),
);
@@ -264,48 +228,22 @@ class _AuthDialogState extends State<AuthDialog>
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
clipBehavior: Clip.antiAlias,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 440,
maxHeight: screenHeight * 0.82,
),
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/images/logo.png',
height: 28,
errorBuilder: (_, _, _) => const Icon(
Icons.campaign,
color: Colors.blue,
),
),
),
const SizedBox(width: 10),
const Text(
'Bürgerforum Oggau',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
@@ -320,10 +258,11 @@ class _AuthDialogState extends State<AuthDialog>
tabs: const [
Tab(text: 'Anmelden'),
Tab(text: 'Registrieren'),
Tab(text: 'Code-Login'),
],
),
if (_errorMessage != null) ...[
const SizedBox(height: 10),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -347,15 +286,16 @@ class _AuthDialogState extends State<AuthDialog>
),
),
],
const SizedBox(height: 12),
Expanded(
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: SizedBox(
height: 280,
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Anmelden
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
// Tab 1: Login
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
@@ -370,24 +310,11 @@ class _AuthDialogState extends State<AuthDialog>
const SizedBox(height: 12),
TextField(
controller: _loginPasswordController,
obscureText: _obscureLoginPassword,
decoration: InputDecoration(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureLoginPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureLoginPassword =
!_obscureLoginPassword;
});
},
),
border: const OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
),
),
Align(
@@ -400,11 +327,12 @@ class _AuthDialogState extends State<AuthDialog>
),
),
),
const SizedBox(height: 12),
const Spacer(),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
padding:
const EdgeInsets.symmetric(vertical: 12),
),
child: _isLoading
? const SizedBox(
@@ -413,77 +341,23 @@ class _AuthDialogState extends State<AuthDialog>
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Anmelden',
style: TextStyle(fontSize: 15)),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
fontWeight: FontWeight.bold,
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
_buildGoogleButton(label: 'Mit Google anmelden'),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () => _tabController.animateTo(1),
child: const Text(
'Noch kein Konto? Hier registrieren oder Bürger-Code eingeben',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
),
: const Text('Anmelden'),
),
],
),
),
// Tab 2: Registrierung & Bürger-Code
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
// Tab 2: Registrierung
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _regFirstNameController,
textCapitalization: TextCapitalization.words,
TextField(
controller: _regNameController,
decoration: const InputDecoration(
labelText: 'Vorname *',
labelText: 'Name / Vorname',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _regLastNameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nachname *',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 10),
TextField(
controller: _regEmailController,
@@ -492,60 +366,34 @@ class _AuthDialogState extends State<AuthDialog>
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
helperText:
'Erhält einen Bestätigungslink zur Prüfung',
),
),
const SizedBox(height: 10),
TextField(
controller: _regPasswordController,
obscureText: _obscureRegPassword,
decoration: InputDecoration(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort (mind. 6 Zeichen)',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureRegPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureRegPassword = !_obscureRegPassword;
});
},
),
border: const OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextField(
controller: _regConfirmPasswordController,
obscureText: _obscureRegConfirmPassword,
decoration: InputDecoration(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort bestätigen',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureRegConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureRegConfirmPassword =
!_obscureRegConfirmPassword;
});
},
),
border: const OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const Spacer(),
ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
padding:
const EdgeInsets.symmetric(vertical: 12),
),
child: _isLoading
? const SizedBox(
@@ -554,156 +402,56 @@ class _AuthDialogState extends State<AuthDialog>
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text(
'Konto erstellen & E-Mail bestätigen',
style: TextStyle(fontSize: 14)),
: const Text('Konto erstellen'),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
fontWeight: FontWeight.bold,
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
_buildGoogleButton(label: 'Mit Google registrieren'),
const SizedBox(height: 18),
Row(
// Tab 3: Bürger-Code
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER MIT BÜRGER-CODE',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
const Text(
'Du möchtest kein persönliches Konto anlegen? Gib einfach den Zugangscode des Bürgerforums ein, um sofort Mängel und Ideen melden zu können.',
style:
TextStyle(fontSize: 13, color: Colors.black87),
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade100),
),
child: const Text(
'Kein persönliches Konto gewünscht? Gib den Zugangscode des Bürgerforums ein (z.B. oggau), um sofort Mängel & Ideen melden zu können.',
style: TextStyle(
fontSize: 12, color: Colors.black87),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
const SizedBox(height: 16),
TextField(
controller: _codeController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Bürger-Zugangscode',
prefixIcon: Icon(Icons.key_outlined),
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
isDense: true,
),
),
),
const SizedBox(width: 8),
const Spacer(),
ElevatedButton(
onPressed: _isLoading ? null : _handleCodeUnlock,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 16),
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('Freischalten'),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Freischalten'),
),
],
),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () => _tabController.animateTo(0),
child: const Text(
'Bereits ein Konto? Hier anmelden',
style: TextStyle(fontSize: 12),
),
),
),
],
),
),
],
),
),
],
),
),
),
);
}
Widget _buildGoogleButton({required String label}) {
return OutlinedButton(
onPressed: _isLoading ? null : _handleGoogleSignIn,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildGoogleIcon(),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
);
}
Widget _buildGoogleIcon() {
return Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.shade200),
),
alignment: Alignment.center,
child: const Text(
'G',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF4285F4),
),
),
);
+51 -261
View File
@@ -1,9 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import 'admin_messages_screen.dart';
import 'auth_dialog.dart';
class ContactScreen extends StatefulWidget {
const ContactScreen({super.key});
@@ -33,12 +29,11 @@ class _ContactScreenState extends State<ContactScreen> {
super.dispose();
}
Future<void> _submitForm(AuthService auth) async {
if (!auth.isRegisteredUser) {
await AuthDialog.show(context);
return;
bool _isValidEmail(String email) {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate() || _isSubmitting) return;
setState(() {
@@ -46,27 +41,19 @@ class _ContactScreenState extends State<ContactScreen> {
});
try {
final firstName = auth.firstName.isNotEmpty
? auth.firstName
: _firstNameController.text.trim();
final lastName = auth.lastName.isNotEmpty
? auth.lastName
: _lastNameController.text.trim();
final email = (auth.email != null && auth.email!.isNotEmpty)
? auth.email!
: _emailController.text.trim();
await _firestoreService.submitContactMessage(
firstName: firstName,
lastName: lastName,
email: email,
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
message: _messageController.text.trim(),
authorUid: auth.user?.uid,
);
if (!mounted) return;
_firstNameController.clear();
_lastNameController.clear();
_emailController.clear();
_phoneController.clear();
_messageController.clear();
@@ -95,45 +82,10 @@ class _ContactScreenState extends State<ContactScreen> {
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final isRegistered = auth.isRegisteredUser;
// Sync controllers with authenticated user details
if (isRegistered) {
if (_firstNameController.text != auth.firstName) {
_firstNameController.text = auth.firstName;
}
if (_lastNameController.text != auth.lastName) {
_lastNameController.text = auth.lastName;
}
if (_emailController.text != (auth.email ?? '')) {
_emailController.text = auth.email ?? '';
}
} else {
if (_firstNameController.text.isNotEmpty) _firstNameController.clear();
if (_lastNameController.text.isNotEmpty) _lastNameController.clear();
if (_emailController.text.isNotEmpty) _emailController.clear();
}
return Scaffold(
appBar: AppBar(
title: const Text('Kontakt Bürgerforum'),
centerTitle: true,
actions: [
if (auth.isAdmin)
IconButton(
icon: const Icon(Icons.inbox),
tooltip: 'Posteingang: Bürgernachrichten',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
@@ -142,123 +94,20 @@ class _ContactScreenState extends State<ContactScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (auth.isAdmin) ...[
Card(
color: Colors.indigo.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.indigo.shade200),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo.shade700,
child: const Icon(Icons.mark_email_unread, color: Colors.white),
),
title: const Text(
'Admin-Posteingang',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Alle eingegangenen Bürgernachrichten einsehen & verwalten'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
),
const SizedBox(height: 12),
],
if (!isRegistered) ...[
Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300),
),
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lock_outline,
color: Colors.amber.shade900, size: 22),
const SizedBox(width: 8),
Text(
'Anmeldung erforderlich',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
],
),
const SizedBox(height: 6),
Text(
'Um eine Nachricht an das Bürgerforum zu senden, ist ein registriertes Benutzerkonto erforderlich. Deine Kontaktdaten werden dann automatisch hinterlegt.',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade800),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => AuthDialog.show(context),
icon: const Icon(Icons.login),
label: const Text('Jetzt anmelden oder registrieren'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 11),
),
),
),
],
),
),
),
const SizedBox(height: 16),
] else ...[
Card(
color: Colors.blue.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.blue.shade100),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(14.0),
child: Row(
children: [
Icon(Icons.verified_user_outlined,
color: Colors.blue.shade700, size: 24),
const SizedBox(width: 10),
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Angemeldet als ${auth.displayName ?? auth.email}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'Deine Kontaktdaten sind automatisch hinterlegt.',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade700),
),
],
child: Text(
'Hier kannst du direkt mit dem Bürgerforum Oggau in Kontakt treten. Wir freuen uns über deine Anregungen und Fragen!',
style: TextStyle(fontSize: 13),
),
),
],
@@ -266,62 +115,33 @@ class _ContactScreenState extends State<ContactScreen> {
),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: 'Vorname *',
hintText:
isRegistered ? null : 'Wird automatisch ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
border: const OutlineInputBorder(),
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Vorname eingeben'
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: 'Nachname *',
hintText:
isRegistered ? null : 'Wird automatisch ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
border: const OutlineInputBorder(),
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Nachname eingeben'
: null,
),
),
],
@@ -329,58 +149,41 @@ class _ContactScreenState extends State<ContactScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
readOnly: true,
style: TextStyle(
color: isRegistered ? Colors.black87 : Colors.grey.shade600,
fontWeight:
isRegistered ? FontWeight.w500 : FontWeight.normal,
),
decoration: InputDecoration(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse *',
hintText:
isRegistered ? null : 'Wird durch Anmeldung ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.email_outlined),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
helperText: isRegistered
? 'Antworten des Bürgerforums gehen an diese E-Mail'
: null,
border: const OutlineInputBorder(),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Bitte E-Mail-Adresse eingeben';
}
if (!_isValidEmail(value.trim())) {
return 'Bitte eine gültige E-Mail eingeben';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
enabled: isRegistered,
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: 'Rückrufnummer (optional)',
prefixIcon: const Icon(Icons.phone_outlined),
border: const OutlineInputBorder(),
helperText: isRegistered
? 'Optional für eventuelle telefonische Rückfragen'
: null,
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _messageController,
maxLines: 5,
enabled: isRegistered,
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: 'Ihr Anliegen / Ihre Nachricht *',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
border: OutlineInputBorder(),
),
validator: (value) => (value == null || value.trim().isEmpty)
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Ihre Nachricht eingeben'
: null,
),
@@ -398,29 +201,16 @@ class _ContactScreenState extends State<ContactScreen> {
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: !isRegistered
? () => AuthDialog.show(context)
: (_isSubmitting ? null : () => _submitForm(auth)),
style: ElevatedButton.styleFrom(
backgroundColor: isRegistered
? Colors.blue.shade800
: Colors.grey.shade700,
foregroundColor: Colors.white,
),
onPressed: _isSubmitting ? null : _submitForm,
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(!isRegistered ? Icons.login : Icons.send),
: const Icon(Icons.send),
label: Text(
!isRegistered
? 'Anmelden zum Absenden'
: (_isSubmitting
? 'Wird gesendet...'
: 'Nachricht absenden'),
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
style: const TextStyle(fontSize: 16),
),
),
+44 -304
View File
@@ -1,177 +1,41 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_service.dart';
import '../services/storage_service.dart';
import 'add_issue_screen.dart';
import 'add_post_screen.dart';
import 'pdf_viewer_screen.dart';
class DetailScreen extends StatefulWidget {
class DetailScreen extends StatelessWidget {
final PostItem item;
final String? collectionName;
const DetailScreen({
super.key,
required this.item,
this.collectionName,
});
@override
State<DetailScreen> createState() => _DetailScreenState();
}
class _DetailScreenState extends State<DetailScreen> {
late PostItem _item;
int _currentImageIndex = 0;
final PageController _pageController = PageController();
@override
void initState() {
super.initState();
_item = widget.item;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _editItem() async {
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'CitizensForum');
PostItem? updated;
if (collection == 'Issues' || _item.street != null) {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddIssueScreen(issueToEdit: _item),
),
);
} else {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: collection,
screenTitle: 'Beitrag bearbeiten',
postToEdit: _item,
),
),
);
}
if (updated != null && mounted) {
setState(() {
_item = updated!;
});
}
}
Future<void> _deleteItem() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed != true || !mounted) return;
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'News');
try {
final storage = StorageService();
final firestore = FirestoreService();
for (final url in _item.imageUrls) {
try {
await storage.deleteFileByUrl(url);
} catch (_) {}
}
await firestore.deletePost(collection, _item.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Eintrag gelöscht.')),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Löschen: $e')),
);
}
}
const DetailScreen({super.key, required this.item});
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final currentUser = auth.user;
final isAuthor = currentUser != null && _item.authorUid == currentUser.uid;
final canEdit = !_item.isAussendung && (isAuthor || auth.isAdmin);
final canDelete = !_item.isAussendung && (isAuthor || auth.isAdmin);
final isIssue = widget.collectionName == 'Issues' || _item.street != null;
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(_item.createdDate);
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
return Scaffold(
appBar: AppBar(
title: Text(_item.title, overflow: TextOverflow.ellipsis),
title: Text(item.title, overflow: TextOverflow.ellipsis),
actions: [
if (canEdit)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Beitrag bearbeiten',
onPressed: _editItem,
),
if (canDelete)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Beitrag löschen',
onPressed: _deleteItem,
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Als PDF drucken / teilen',
onPressed: () => PdfService.printSinglePost(_item),
onPressed: () => PdfService.printSinglePost(item),
),
],
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_item.imageUrls.isNotEmpty) ...[
if (_item.imageUrls.length == 1)
if (item.imageUrls.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: _item.imageUrls.first,
imageUrl: item.imageUrls.first,
width: double.infinity,
height: 240,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
@@ -180,117 +44,15 @@ class _DetailScreenState extends State<DetailScreen> {
),
),
errorWidget: (context, url, error) => Container(
height: 200,
height: 180,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48),
),
),
)
else
Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 260,
width: double.infinity,
child: PageView.builder(
controller: _pageController,
itemCount: _item.imageUrls.length,
onPageChanged: (index) {
setState(() {
_currentImageIndex = index;
});
},
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: _item.imageUrls[index],
width: double.infinity,
height: 260,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 260,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported,
size: 48),
),
);
},
),
),
),
// Page indicator dots
Positioned(
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
_item.imageUrls.length,
(i) => Container(
margin:
const EdgeInsets.symmetric(horizontal: 3),
width: _currentImageIndex == i ? 16 : 6,
height: 6,
decoration: BoxDecoration(
color: _currentImageIndex == i
? Colors.white
: Colors.white54,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
),
// Photo counter badge (e.g. 1 / 2)
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library,
color: Colors.white, size: 14),
const SizedBox(width: 4),
Text(
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
],
Text(
_item.title,
item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -302,63 +64,48 @@ class _DetailScreenState extends State<DetailScreen> {
formattedDate,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
),
if (isIssue && _item.status != null) ...[
if (item.status != null) ...[
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _item.status == 'Erledigt'
? Colors.green.shade50
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _item.status == 'Erledigt'
? Colors.green.shade200
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade200
: Colors.orange.shade200),
),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
_item.status!,
item.status!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _item.status == 'Erledigt'
? Colors.green.shade800
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
color: Colors.blue.shade800),
),
),
],
],
),
if (_item.street != null && _item.street!.isNotEmpty) ...[
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
const SizedBox(width: 6),
Text(
'Örtlichkeit: ${_item.street}',
'Örtlichkeit: ${item.street}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
],
if (_item.category != null && _item.category!.isNotEmpty) ...[
if (item.category != null && item.category!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.category, size: 16, color: Colors.orange),
const SizedBox(width: 6),
Text(
'Kategorie: ${_item.category}',
'Kategorie: ${item.category}',
style: TextStyle(color: Colors.grey.shade800),
),
],
@@ -373,45 +120,38 @@ class _DetailScreenState extends State<DetailScreen> {
),
const SizedBox(height: 8),
Text(
_item.description,
item.description,
style: const TextStyle(fontSize: 15, height: 1.5),
),
if (_item.pdfUrl != null && _item.pdfUrl!.isNotEmpty) ...[
const SizedBox(height: 16),
Card(
color: Colors.red.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.red.shade200),
),
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.red,
child: Icon(Icons.picture_as_pdf, color: Colors.white),
),
title: const Text(
'PDF-Aussendung ansehen',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle:
const Text('Dokument jetzt in der App öffnen und lesen'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => PdfViewerScreen(
title: _item.title,
pdfUrl: _item.pdfUrl!,
const SizedBox(height: 24),
// Additional images gallery if there are multiple images
if (item.imageUrls.length > 1) ...[
const Text(
'Weitere Fotos',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: item.imageUrls.length - 1,
separatorBuilder: (ctx, i) => const SizedBox(width: 8),
itemBuilder: (ctx, index) {
final url = item.imageUrls[index + 1];
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 120,
height: 120,
fit: BoxFit.cover,
),
);
},
),
),
],
const SizedBox(height: 24),
],
),
),
+5 -39
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import 'auth_dialog.dart';
import 'user_management_screen.dart';
class InfoScreen extends StatelessWidget {
const InfoScreen({super.key});
@@ -52,9 +51,7 @@ class InfoScreen extends StatelessWidget {
const SizedBox(height: 8),
// Bürgerforum Access & Account Card
Card(
color: auth.isAdmin
? Colors.indigo.shade50
: auth.isRegisteredUser
color: auth.isRegisteredUser
? Colors.green.shade50
: auth.isCommunityUnlocked
? Colors.teal.shade50
@@ -68,16 +65,12 @@ class InfoScreen extends StatelessWidget {
Row(
children: [
Icon(
auth.isAdmin
? Icons.admin_panel_settings
: auth.isRegisteredUser
auth.isRegisteredUser
? Icons.account_circle
: auth.isCommunityUnlocked
? Icons.verified_user
: Icons.lock_outline,
color: auth.isAdmin
? Colors.indigo.shade700
: auth.isRegisteredUser
color: auth.isRegisteredUser
? Colors.green.shade700
: auth.isCommunityUnlocked
? Colors.teal.shade800
@@ -90,9 +83,7 @@ class InfoScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isAdmin
? 'Administrator'
: auth.isRegisteredUser
auth.isRegisteredUser
? (auth.displayName?.isNotEmpty == true
? auth.displayName!
: 'Bürger-Konto')
@@ -104,9 +95,7 @@ class InfoScreen extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
auth.isAdmin
? '${auth.email ?? "Angemeldet"} (Admin-Rechte aktiv)'
: auth.isRegisteredUser
auth.isRegisteredUser
? auth.email ?? 'Angemeldet'
: auth.isCommunityUnlocked
? 'Mängel & Ideen freigeschaltet'
@@ -137,29 +126,6 @@ class InfoScreen extends StatelessWidget {
),
],
),
if (auth.isAdmin) ...[
const Divider(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.manage_accounts),
label: const Text('Benutzerverwaltung & Admin-Rechte'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const UserManagementScreen(),
),
);
},
),
),
],
if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[
const Divider(height: 16),
InkWell(
-265
View File
@@ -1,265 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:pdf/pdf.dart';
import 'package:printing/printing.dart';
import 'package:url_launcher/url_launcher.dart';
class PdfViewerScreen extends StatefulWidget {
final String title;
final String pdfUrl;
const PdfViewerScreen({
super.key,
required this.title,
required this.pdfUrl,
});
@override
State<PdfViewerScreen> createState() => _PdfViewerScreenState();
}
class _PdfViewerScreenState extends State<PdfViewerScreen>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController =
TransformationController();
late AnimationController _animationController;
Animation<Matrix4>? _zoomAnimation;
TapDownDetails? _doubleTapDetails;
Uint8List? _cachedBytes;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
)..addListener(() {
if (_zoomAnimation != null) {
_transformationController.value = _zoomAnimation!.value;
}
});
}
@override
void dispose() {
_animationController.dispose();
_transformationController.dispose();
super.dispose();
}
void _handleDoubleTap() {
if (_animationController.isAnimating) return;
final currentMatrix = _transformationController.value;
final currentScale = currentMatrix.getMaxScaleOnAxis();
Matrix4 targetMatrix;
if (currentScale > 1.05) {
// Zoomed in -> smoothly animate back to standard size (scale 1.0)
targetMatrix = Matrix4.identity();
} else {
// At standard size -> zoom in to tapped point (2.5x)
final position = _doubleTapDetails?.localPosition ?? Offset.zero;
final scenePoint = _transformationController.toScene(position);
final x = position.dx - scenePoint.dx * 2.5;
final y = position.dy - scenePoint.dy * 2.5;
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
..setTranslationRaw(x, y, 0.0);
}
_zoomAnimation = Matrix4Tween(
begin: currentMatrix,
end: targetMatrix,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
_animationController.forward(from: 0);
}
Future<void> _openExternal() async {
final uri = Uri.parse(widget.pdfUrl);
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Browser konnte nicht geöffnet werden: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _sharePdf() async {
if (_cachedBytes != null) {
final safeTitle =
widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
await Printing.sharePdf(
bytes: _cachedBytes!,
filename: '$safeTitle.pdf',
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PDF wird noch geladen...')),
);
}
}
@override
Widget build(BuildContext context) {
final safeTitle =
widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
return Scaffold(
appBar: AppBar(
title: Text(widget.title, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
icon: const Icon(Icons.share),
tooltip: 'PDF teilen / drucken',
onPressed: _sharePdf,
),
IconButton(
icon: const Icon(Icons.open_in_browser),
tooltip: 'Im Browser öffnen',
onPressed: _openExternal,
),
],
),
body: SafeArea(
top: false,
child: PdfPreview.builder(
build: (PdfPageFormat format) async {
if (_cachedBytes != null) return _cachedBytes!;
final response = await http
.get(Uri.parse(widget.pdfUrl))
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
_cachedBytes = response.bodyBytes;
return response.bodyBytes;
}
throw Exception(
'Fehler beim Abrufen der Datei (${response.statusCode})');
},
canChangeOrientation: false,
canChangePageFormat: false,
canDebug: false,
allowPrinting: false,
allowSharing: false,
useActions: false,
pdfFileName: '$safeTitle.pdf',
loadingWidget: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Aussendung wird geladen...'),
],
),
),
onError: (context, error) => Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 12),
Text(
'Fehler beim Laden des Dokuments: $error',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _openExternal,
icon: const Icon(Icons.open_in_browser),
label: const Text('Direkt im Browser öffnen'),
),
],
),
),
),
pagesBuilder: (context, pages) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportWidth = constraints.maxWidth;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 5.0,
panEnabled: true,
scaleEnabled: true,
constrained: false,
boundaryMargin: const EdgeInsets.symmetric(
horizontal: 48, vertical: 80),
child: SizedBox(
width: viewportWidth,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < pages.length; i++) ...[
Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: 0.15),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Image(
image: pages[i].image,
fit: BoxFit.contain,
),
),
if (pages.length > 1)
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Seite ${i + 1} von ${pages.length}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
),
],
],
),
),
),
),
);
},
);
},
),
),
);
}
}
+39 -448
View File
@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/post_item.dart';
import '../services/aussendungen_service.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_service.dart';
@@ -13,7 +12,6 @@ import 'add_issue_screen.dart';
import 'add_post_screen.dart';
import 'auth_dialog.dart';
import 'detail_screen.dart';
import 'pdf_viewer_screen.dart';
class PostListScreen extends StatefulWidget {
final String collectionName;
@@ -32,44 +30,12 @@ class PostListScreen extends StatefulWidget {
class _PostListScreenState extends State<PostListScreen> {
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
final AussendungenService _aussendungenService = AussendungenService();
final Map<String, PostItem> _selectedItems = {};
String _searchQuery = '';
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
List<PostItem> _aussendungen = [];
bool _isLoadingAussendungen = false;
String _newsFilter = 'all'; // 'all', 'aussendungen', 'news'
@override
void initState() {
super.initState();
if (widget.collectionName == 'News') {
_loadAussendungen();
}
}
Future<void> _loadAussendungen({bool force = false}) async {
if (widget.collectionName != 'News') return;
if (mounted) setState(() => _isLoadingAussendungen = true);
try {
final items = await _aussendungenService.getAussendungen(forceRefresh: force);
if (mounted) {
setState(() {
_aussendungen = items;
_isLoadingAussendungen = false;
});
}
} catch (e) {
debugPrint('Error loading Aussendungen: $e');
if (mounted) {
setState(() => _isLoadingAussendungen = false);
}
}
}
@override
void dispose() {
_searchController.dispose();
@@ -93,28 +59,9 @@ class _PostListScreenState extends State<PostListScreen> {
}
Future<void> _deletePost(PostItem item) async {
final auth = Provider.of<AuthService>(context, listen: false);
final currentUser = auth.user;
final isAuthor = currentUser != null && item.authorUid == currentUser.uid;
final canDelete = isAuthor || auth.isAdmin;
if (!canDelete) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keine Berechtigung zum Löschen dieses Beitrags.'),
backgroundColor: Colors.red,
),
);
return;
}
try {
for (final url in item.imageUrls) {
try {
await _storageService.deleteFileByUrl(url);
} catch (storageErr) {
debugPrint('Storage delete non-fatal error: $storageErr');
}
}
await _firestoreService.deletePost(widget.collectionName, item.id);
if (!mounted) return;
@@ -129,45 +76,9 @@ class _PostListScreenState extends State<PostListScreen> {
}
}
void _editPost(PostItem item) {
if (widget.collectionName == 'Issues') {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddIssueScreen(issueToEdit: item),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: widget.collectionName,
screenTitle: widget.collectionName == 'News'
? 'Nachricht bearbeiten'
: 'Idee bearbeiten',
postToEdit: item,
),
),
);
}
}
Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(context, listen: false);
// News are official announcements and can only be created by Admins
if (widget.collectionName == 'News' && !auth.isAdmin) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Nur Administratoren können Neuigkeiten veröffentlichen.'),
backgroundColor: Colors.amber,
),
);
return;
}
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
await AuthDialog.show(context);
if (!mounted) return;
@@ -200,8 +111,7 @@ class _PostListScreenState extends State<PostListScreen> {
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final currentUser = auth.user;
final currentUser = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(
@@ -270,86 +180,7 @@ class _PostListScreenState extends State<PostListScreen> {
],
],
),
body: widget.collectionName == 'News'
? Column(
children: [
_buildNewsFilterBar(),
if (_isLoadingAussendungen && _aussendungen.isEmpty)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: RefreshIndicator(
onRefresh: () async {
await _loadAussendungen(force: true);
},
child: _buildPostList(auth, currentUser),
),
),
],
)
: _buildPostList(auth, currentUser),
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
? null
: FloatingActionButton(
onPressed: _onAddNewPressed,
tooltip: widget.collectionName == 'News'
? 'Nachricht verfassen'
: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
Widget _buildNewsFilterBar() {
return Container(
width: double.infinity,
color: Colors.grey.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Alle'),
selected: _newsFilter == 'all',
onSelected: (_) => setState(() => _newsFilter = 'all'),
selectedColor: Colors.blue.shade100,
),
const SizedBox(width: 8),
ChoiceChip(
avatar: Icon(
Icons.picture_as_pdf,
size: 16,
color: _newsFilter == 'aussendungen'
? Colors.red.shade900
: Colors.red.shade700,
),
label: Text('Aussendungen (${_aussendungen.length})'),
selected: _newsFilter == 'aussendungen',
onSelected: (_) => setState(() => _newsFilter = 'aussendungen'),
selectedColor: Colors.red.shade100,
),
const SizedBox(width: 8),
ChoiceChip(
avatar: Icon(
Icons.newspaper,
size: 16,
color: _newsFilter == 'news'
? Colors.blue.shade900
: Colors.blue.shade700,
),
label: const Text('Aktuelles'),
selected: _newsFilter == 'news',
onSelected: (_) => setState(() => _newsFilter = 'news'),
selectedColor: Colors.blue.shade100,
),
],
),
),
);
}
Widget _buildPostList(AuthService auth, dynamic currentUser) {
return StreamBuilder<List<PostItem>>(
body: StreamBuilder<List<PostItem>>(
stream: _firestoreService.getPostsStream(widget.collectionName),
builder: (context, snapshot) {
if (snapshot.hasError) {
@@ -361,27 +192,11 @@ class _PostListScreenState extends State<PostListScreen> {
);
}
if (snapshot.connectionState == ConnectionState.waiting &&
widget.collectionName != 'News') {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final firestoreItems = snapshot.data ?? [];
List<PostItem> allItems;
if (widget.collectionName == 'News') {
allItems = [...firestoreItems, ..._aussendungen];
allItems.sort((a, b) => b.createdDate.compareTo(a.createdDate));
if (_newsFilter == 'aussendungen') {
allItems = allItems.where((item) => item.isAussendung).toList();
} else if (_newsFilter == 'news') {
allItems = allItems.where((item) => !item.isAussendung).toList();
}
} else {
allItems = firestoreItems;
}
final allItems = snapshot.data ?? [];
final filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
@@ -425,16 +240,29 @@ class _PostListScreenState extends State<PostListScreen> {
final isSelected = _selectedItems.containsKey(item.id);
final isAuthor =
currentUser != null && item.authorUid == currentUser.uid;
final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
final canEdit = !item.isAussendung && (isAuthor || auth.isAdmin);
final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
final isIssue =
widget.collectionName == 'Issues' || item.street != null;
Widget? leadingWidget;
if (item.imageUrl.isNotEmpty) {
leadingWidget = Stack(
Widget itemTile = ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () {
if (_selectedItems.isNotEmpty) {
_toggleSelection(item);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(item: item),
),
);
}
},
onLongPress: () => _toggleSelection(item),
leading: item.imageUrl.isNotEmpty
? Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -474,164 +302,13 @@ class _PostListScreenState extends State<PostListScreen> {
),
),
],
);
} else if (item.isAussendung) {
leadingWidget = Stack(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade200),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.picture_as_pdf,
color: Colors.red.shade700, size: 26),
const SizedBox(height: 2),
Text(
'PDF',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.red.shade800,
),
),
],
),
),
if (isSelected)
const Positioned(
top: 2,
left: 2,
child: CircleAvatar(
radius: 12,
backgroundColor: Colors.blue,
child: Icon(Icons.check, size: 16, color: Colors.white),
),
),
],
);
} else if (isSelected) {
leadingWidget = const CircleAvatar(
)
: isSelected
? const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.check, color: Colors.white),
);
}
Widget itemTile = ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () async {
if (_selectedItems.isNotEmpty) {
_toggleSelection(item);
} else if (item.isAussendung) {
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => PdfViewerScreen(
title: item.title,
pdfUrl: item.pdfUrl!,
),
),
);
} else if (item.webUrl != null && item.webUrl!.isNotEmpty) {
final uri = Uri.parse(item.webUrl!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
}
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(
item: item,
collectionName: widget.collectionName,
),
),
);
}
},
onLongPress: () => _toggleSelection(item),
leading: leadingWidget,
trailing: item.isAussendung
? Icon(Icons.arrow_forward_ios,
size: 14, color: Colors.grey.shade400)
: (_selectedItems.isNotEmpty
? null
: ((canEdit || canDelete)
? PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black54),
tooltip: 'Optionen',
onSelected: (value) async {
if (value == 'edit') {
_editPost(item);
} else if (value == 'delete') {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red),
onPressed: () =>
Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed == true) {
_deletePost(item);
}
}
},
itemBuilder: (ctx) => [
if (canEdit)
const PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 20),
SizedBox(width: 8),
Text('Bearbeiten'),
],
),
),
if (canDelete)
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 20, color: Colors.red),
SizedBox(width: 8),
Text('Löschen',
style:
TextStyle(color: Colors.red)),
],
),
),
],
)
: null)),
: null,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -642,7 +319,7 @@ class _PostListScreenState extends State<PostListScreen> {
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
@@ -659,69 +336,11 @@ class _PostListScreenState extends State<PostListScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isIssue && item.status != null && item.status!.isNotEmpty) ...[
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.status == 'Erledigt'
? Colors.green.shade50
: (item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: item.status == 'Erledigt'
? Colors.green.shade300
: (item.status == 'In Bearbeitung'
? Colors.blue.shade300
: Colors.orange.shade300),
),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: item.status == 'Erledigt'
? Colors.green.shade800
: (item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
),
),
if (item.street != null && item.street!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 2),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
),
] else if (item.street != null && item.street!.isNotEmpty) ...[
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
const SizedBox(width: 4),
Text(
item.street!,
@@ -735,40 +354,6 @@ class _PostListScreenState extends State<PostListScreen> {
),
],
const SizedBox(height: 4),
if (item.isAussendung)
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Aussendung (PDF)',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.red.shade900,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
),
],
)
else
Text(
item.description,
maxLines: 2,
@@ -782,7 +367,7 @@ class _PostListScreenState extends State<PostListScreen> {
),
);
if (canDelete) {
if (isAuthor) {
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
@@ -823,6 +408,12 @@ class _PostListScreenState extends State<PostListScreen> {
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _onAddNewPressed,
tooltip: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
}
-555
View File
@@ -1,555 +0,0 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@override
State<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> {
final FirestoreService _firestoreService = FirestoreService();
String _searchQuery = '';
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
String _selectedFilter = 'all'; // 'all', 'admins', 'citizens'
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _toggleAdminRole({
required String uid,
required String email,
required String displayName,
required bool currentIsAdmin,
required String currentAdminUid,
}) async {
if (currentIsAdmin && uid == currentAdminUid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sie können sich nicht selbst die Administrator-Rechte entziehen.'),
backgroundColor: Colors.amber,
),
);
return;
}
final targetAction = currentIsAdmin ? 'entziehen' : 'erteilen';
final confirmed = await showDialog<bool>(
context: context,
builder: (dCtx) => AlertDialog(
title: Text(currentIsAdmin
? 'Admin-Rechte entziehen?'
: 'Zum Administrator ernennen?'),
content: Text(currentIsAdmin
? 'Möchten Sie $displayName die Administrator-Rechte wirklich entziehen?'
: 'Möchten Sie $displayName wirklich zum Administrator ernennen? Dieser Nutzer erhält vollen Zugriff auf News, alle Beiträge, Bürgernachrichten und die Benutzerverwaltung.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
currentIsAdmin ? Colors.red : Colors.indigo.shade700,
),
onPressed: () => Navigator.pop(dCtx, true),
child: Text(currentIsAdmin ? 'Rechte entziehen' : 'Ernennen'),
),
],
),
);
if (confirmed == true) {
try {
await _firestoreService.setAdminRole(
uid: uid,
email: email,
makeAdmin: !currentIsAdmin,
grantedBy: currentAdminUid,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Administrator-Rechte wurden erfolgreich $targetAction.'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Ändern der Rechte: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
Future<void> _showAddAdminDialog(String currentAdminUid) async {
final emailController = TextEditingController();
final uidController = TextEditingController();
await showDialog(
context: context,
builder: (dCtx) => AlertDialog(
title: const Text('Admin manuell hinzufügen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Geben Sie die E-Mail-Adresse oder Firebase-UID des neuen Administrators ein:',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 12),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse *',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextField(
controller: uidController,
decoration: const InputDecoration(
labelText: 'UID (optional)',
hintText: 'z. B. aus Firebase Auth',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final email = emailController.text.trim().toLowerCase();
final uid = uidController.text.trim();
if (email.isEmpty && uid.isEmpty) {
return;
}
final identifier = uid.isNotEmpty ? uid : email;
try {
await _firestoreService.addAdminDirectly(
identifier: identifier,
email: email,
grantedBy: currentAdminUid,
);
if (dCtx.mounted) {
Navigator.pop(dCtx);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Administrator erfolgreich hinzugefügt.'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Hinzufügen: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Hinzufügen'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final currentAdminUid = auth.user?.uid ?? '';
return Scaffold(
appBar: AppBar(
title: _isSearchActive
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Benutzer nach Name oder E-Mail suchen...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: const Text('Benutzerverwaltung'),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
onPressed: () {
setState(() {
if (_isSearchActive) {
_isSearchActive = false;
_searchQuery = '';
_searchController.clear();
} else {
_isSearchActive = true;
}
});
},
),
IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Admin manuell hinzufügen',
onPressed: () => _showAddAdminDialog(currentAdminUid),
),
],
),
body: Column(
children: [
// Filter Chips
Container(
width: double.infinity,
color: Colors.grey.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Alle'),
selected: _selectedFilter == 'all',
onSelected: (_) => setState(() => _selectedFilter = 'all'),
),
const SizedBox(width: 8),
ChoiceChip(
avatar: const Icon(Icons.admin_panel_settings,
size: 16, color: Colors.indigo),
label: const Text('Administratoren'),
selected: _selectedFilter == 'admins',
onSelected: (_) =>
setState(() => _selectedFilter = 'admins'),
selectedColor: Colors.indigo.shade100,
),
const SizedBox(width: 8),
ChoiceChip(
avatar: const Icon(Icons.person_outline,
size: 16, color: Colors.teal),
label: const Text('Bürger'),
selected: _selectedFilter == 'citizens',
onSelected: (_) =>
setState(() => _selectedFilter = 'citizens'),
selectedColor: Colors.teal.shade100,
),
],
),
),
),
// Users and Admins Stream
Expanded(
child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: _firestoreService.getAdminsStream(),
builder: (context, adminSnapshot) {
final Set<String> adminUids = {};
final Set<String> adminEmails = {};
if (adminSnapshot.hasData) {
for (final doc in adminSnapshot.data!.docs) {
adminUids.add(doc.id.toLowerCase());
final data = doc.data();
final email =
(data['email'] as String? ?? '').toLowerCase();
if (email.isNotEmpty) adminEmails.add(email);
final uid =
(data['uid'] as String? ?? '').toLowerCase();
if (uid.isNotEmpty) adminUids.add(uid);
}
}
return StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: _firestoreService.getUsersStream(),
builder: (context, userSnapshot) {
if (userSnapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Fehler beim Laden der Benutzer: ${userSnapshot.error}',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (userSnapshot.connectionState ==
ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final userDocs = userSnapshot.data?.docs ?? [];
final filteredUsers = userDocs.where((doc) {
final data = doc.data();
final uid = doc.id;
final email =
(data['email'] as String? ?? '').toLowerCase();
final isUserAdmin = adminUids.contains(uid.toLowerCase()) ||
adminEmails.contains(email) ||
data['isAdmin'] == true;
if (_selectedFilter == 'admins' && !isUserAdmin) {
return false;
}
if (_selectedFilter == 'citizens' && isUserAdmin) {
return false;
}
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
final name =
(data['displayName'] as String? ?? '').toLowerCase();
final fn =
(data['firstName'] as String? ?? '').toLowerCase();
final ln =
(data['lastName'] as String? ?? '').toLowerCase();
return name.contains(q) ||
email.contains(q) ||
fn.contains(q) ||
ln.contains(q);
}
return true;
}).toList();
if (filteredUsers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline,
size: 64, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
_searchQuery.isNotEmpty
? 'Keine Treffer für "$_searchQuery"'
: 'Noch keine registrierten Benutzer vorhanden.',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredUsers.length,
separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) {
final doc = filteredUsers[index];
final data = doc.data();
final uid = doc.id;
final email = data['email'] as String? ?? '';
final displayName = data['displayName'] as String? ??
'${data['firstName'] ?? ''} ${data['lastName'] ?? ''}'.trim();
final name = displayName.isNotEmpty
? displayName
: (email.isNotEmpty ? email : 'Bürger');
final isCurrentUser = uid == currentAdminUid;
final isUserAdmin =
adminUids.contains(uid.toLowerCase()) ||
adminEmails.contains(email.toLowerCase()) ||
data['isAdmin'] == true;
DateTime? createdAt;
if (data['createdAt'] is Timestamp) {
createdAt = (data['createdAt'] as Timestamp).toDate();
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: isUserAdmin
? Colors.indigo.shade100
: Colors.blue.shade50,
child: Icon(
isUserAdmin
? Icons.admin_panel_settings
: Icons.person,
color: isUserAdmin
? Colors.indigo.shade800
: Colors.blue.shade700,
),
),
title: Row(
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
if (isCurrentUser) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Du',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (email.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
email,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
],
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isUserAdmin
? Colors.indigo.shade50
: Colors.teal.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isUserAdmin
? Colors.indigo.shade200
: Colors.teal.shade200,
),
),
child: Text(
isUserAdmin ? 'Administrator' : 'Bürger',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isUserAdmin
? Colors.indigo.shade800
: Colors.teal.shade800,
),
),
),
if (createdAt != null) ...[
const SizedBox(width: 8),
Text(
'Seit ${DateFormat('dd.MM.yyyy').format(createdAt)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
),
),
],
],
),
],
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: 'Optionen',
onSelected: (action) {
if (action == 'toggleAdmin') {
_toggleAdminRole(
uid: uid,
email: email,
displayName: name,
currentIsAdmin: isUserAdmin,
currentAdminUid: currentAdminUid,
);
}
},
itemBuilder: (ctx) => [
PopupMenuItem(
value: 'toggleAdmin',
enabled: !(isUserAdmin && isCurrentUser),
child: Row(
children: [
Icon(
isUserAdmin
? Icons.remove_moderator
: Icons.add_moderator,
size: 20,
color: isUserAdmin
? Colors.red
: Colors.indigo.shade700,
),
const SizedBox(width: 10),
Text(
isUserAdmin
? 'Admin-Rechte entziehen'
: 'Zum Admin ernennen',
style: TextStyle(
color: isUserAdmin
? Colors.red
: Colors.indigo.shade700,
),
),
],
),
),
],
),
);
},
);
},
);
},
),
),
],
),
);
}
}
-121
View File
@@ -1,121 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/post_item.dart';
class AussendungenService {
static const String _cacheKey = 'cached_aussendungen_v1';
static const String _apiUrl =
'https://buergerforumoggau.at/wp-json/wp/v2/posts?categories=9&per_page=50';
static List<PostItem>? _memoryCache;
/// Returns cached posts immediately if available, followed by fresh network posts
Future<List<PostItem>> getAussendungen({bool forceRefresh = false}) async {
if (!forceRefresh && _memoryCache != null && _memoryCache!.isNotEmpty) {
return _memoryCache!;
}
// Try loading from local SharedPreferences cache first
if (!forceRefresh) {
final cached = await _loadFromLocalCache();
if (cached.isNotEmpty) {
_memoryCache = cached;
// Trigger background refresh silently
_fetchFromNetworkAndCache().catchError((e) {
debugPrint('Silent background refresh failed: ');
return <PostItem>[];
});
return cached;
}
}
// Otherwise fetch fresh from network
try {
final fresh = await _fetchFromNetworkAndCache();
if (fresh.isNotEmpty) {
_memoryCache = fresh;
return fresh;
}
} catch (e) {
debugPrint('Aussendungen network fetch error: ');
final fallback = await _loadFromLocalCache();
if (fallback.isNotEmpty) {
_memoryCache = fallback;
return fallback;
}
rethrow;
}
return _memoryCache ?? [];
}
/// Network request to WordPress REST API
Future<List<PostItem>> _fetchFromNetworkAndCache() async {
final response = await http
.get(Uri.parse(_apiUrl))
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final dynamic decoded = jsonDecode(utf8.decode(response.bodyBytes));
if (decoded is List) {
final List<PostItem> items = [];
for (final item in decoded) {
if (item is Map<String, dynamic>) {
try {
final post = PostItem.fromWordPressJson(item);
items.add(post);
} catch (e) {
debugPrint('Error parsing WP post item: ');
}
}
}
await _saveToLocalCache(decoded);
_memoryCache = items;
return items;
}
} else {
throw Exception('Server returned ');
}
return [];
}
/// Load from SharedPreferences
Future<List<PostItem>> _loadFromLocalCache() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? jsonString = prefs.getString(_cacheKey);
if (jsonString != null && jsonString.isNotEmpty) {
final dynamic decoded = jsonDecode(jsonString);
if (decoded is List) {
final List<PostItem> items = [];
for (final item in decoded) {
if (item is Map<String, dynamic>) {
try {
items.add(PostItem.fromWordPressJson(item));
} catch (e) {
debugPrint('Error parsing cached WP item: ');
}
}
}
return items;
}
}
} catch (e) {
debugPrint('Error loading cached aussendungen: ');
}
return [];
}
/// Save to SharedPreferences
Future<void> _saveToLocalCache(List<dynamic> jsonList) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cacheKey, jsonEncode(jsonList));
} catch (e) {
debugPrint('Error saving aussendungen to cache: ');
}
}
}
+6 -194
View File
@@ -1,6 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthService extends ChangeNotifier {
@@ -19,112 +18,16 @@ class AuthService extends ChangeNotifier {
String? get email => _user?.email;
String? get displayName => _user?.displayName;
String? _cachedFirstName;
String? _cachedLastName;
bool _isAdmin = false;
bool get isAdmin => _isAdmin;
String get firstName {
if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) {
return _cachedFirstName!;
}
final name = _user?.displayName ?? '';
if (name.isEmpty) return '';
final parts = name.trim().split(RegExp(r'\s+'));
return parts.isNotEmpty ? parts.first : '';
}
String get lastName {
if (_cachedLastName != null && _cachedLastName!.isNotEmpty) {
return _cachedLastName!;
}
final name = _user?.displayName ?? '';
if (name.isEmpty) return '';
final parts = name.trim().split(RegExp(r'\s+'));
return parts.length > 1 ? parts.sublist(1).join(' ') : '';
}
AuthService() {
_initAuth();
}
Future<void> _checkAdminClaim(User? user) async {
if (user != null && !user.isAnonymous) {
try {
// 1. Custom claims check (token.admin == true)
final tokenResult = await user.getIdTokenResult();
if (tokenResult.claims?['admin'] == true) {
_isAdmin = true;
return;
}
final firestore = FirebaseFirestore.instance;
// 2. Check Firestore 'Admins' collection by user UID (doc id)
final docByUid =
await firestore.collection('Admins').doc(user.uid).get();
if (docByUid.exists) {
_isAdmin = true;
return;
}
// 3. Check Firestore 'Admins' collection by email (doc id)
final userEmail = user.email?.trim().toLowerCase();
if (userEmail != null && userEmail.isNotEmpty) {
final docByEmail =
await firestore.collection('Admins').doc(userEmail).get();
if (docByEmail.exists) {
_isAdmin = true;
return;
}
// 4. Check Firestore 'Admins' collection where email field == userEmail
final queryByEmail = await firestore
.collection('Admins')
.where('email', isEqualTo: userEmail)
.limit(1)
.get();
if (queryByEmail.docs.isNotEmpty) {
_isAdmin = true;
return;
}
}
// 5. Check Firestore 'Admins' collection where uid field == user.uid
final queryByUid = await firestore
.collection('Admins')
.where('uid', isEqualTo: user.uid)
.limit(1)
.get();
if (queryByUid.docs.isNotEmpty) {
_isAdmin = true;
return;
}
_isAdmin = false;
} catch (e) {
if (kDebugMode) {
debugPrint('Error checking admin status: $e');
}
_isAdmin = false;
}
} else {
_isAdmin = false;
}
}
Future<void> _initAuth() async {
final prefs = await SharedPreferences.getInstance();
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
_cachedFirstName = prefs.getString('user_first_name');
_cachedLastName = prefs.getString('user_last_name');
_auth.authStateChanges().listen((User? user) async {
_auth.authStateChanges().listen((User? user) {
_user = user;
await _checkAdminClaim(user);
if (user != null && !user.isAnonymous) {
await _syncUserProfile(user);
}
notifyListeners();
});
@@ -133,33 +36,6 @@ class AuthService extends ChangeNotifier {
}
}
Future<void> _syncUserProfile(User user, {String? firstName, String? lastName}) async {
if (user.isAnonymous) return;
try {
final cleanEmail = user.email?.trim().toLowerCase() ?? '';
if (cleanEmail.isEmpty) return;
final cleanFirst = firstName ?? this.firstName;
final cleanLast = lastName ?? this.lastName;
final name = user.displayName ?? '$cleanFirst $cleanLast'.trim();
await FirebaseFirestore.instance.collection('Users').doc(user.uid).set({
'uid': user.uid,
'email': cleanEmail,
'displayName': name,
'firstName': cleanFirst,
'lastName': cleanLast,
'emailVerified': user.emailVerified,
'lastLogin': FieldValue.serverTimestamp(),
if (_isAdmin) 'isAdmin': true,
}, SetOptions(merge: true));
} catch (e) {
if (kDebugMode) {
debugPrint('Error syncing user profile in auth service: $e');
}
}
}
/// Verifies community access code
Future<bool> verifyCommunityCode(String enteredCode) async {
final cleanCode = enteredCode.trim().toLowerCase();
@@ -195,10 +71,6 @@ class AuthService extends ChangeNotifier {
password: password,
);
_user = credential.user;
await _checkAdminClaim(_user);
if (_user != null) {
await _syncUserProfile(_user!);
}
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
@@ -208,12 +80,11 @@ class AuthService extends ChangeNotifier {
}
}
/// Sign up with Email, Password, First Name, and Last Name
/// Sign up with Email, Password, and Full Name
Future<String?> signUpWithEmail({
required String email,
required String password,
required String firstName,
required String lastName,
required String name,
}) async {
try {
final credential = await _auth.createUserWithEmailAndPassword(
@@ -221,31 +92,8 @@ class AuthService extends ChangeNotifier {
password: password,
);
_user = credential.user;
if (_user != null) {
final cleanFirst = firstName.trim();
final cleanLast = lastName.trim();
final fullName = '$cleanFirst $cleanLast'.trim();
if (fullName.isNotEmpty) {
await _user!.updateDisplayName(fullName);
}
_cachedFirstName = cleanFirst;
_cachedLastName = cleanLast;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_first_name', cleanFirst);
await prefs.setString('user_last_name', cleanLast);
await _checkAdminClaim(_user);
await _syncUserProfile(_user!,
firstName: cleanFirst, lastName: cleanLast);
// Send email verification so user verifies they own the email address
try {
await _user!.sendEmailVerification();
} catch (e) {
if (kDebugMode) {
debugPrint('Error sending verification email: $e');
}
}
if (_user != null && name.isNotEmpty) {
await _user!.updateDisplayName(name.trim());
}
notifyListeners();
return null; // success
@@ -256,36 +104,6 @@ class AuthService extends ChangeNotifier {
}
}
/// Check if user's email is verified
bool get isEmailVerified => _user?.emailVerified ?? false;
/// Reload current user from Firebase to refresh emailVerified status and admin claim
Future<void> reloadUser() async {
await _user?.reload();
_user = _auth.currentUser;
await _checkAdminClaim(_user);
notifyListeners();
}
/// Re-send verification email
Future<String?> sendEmailVerification() async {
try {
await _user?.sendEmailVerification();
return null;
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Fehler beim Senden der Bestätigungs-E-Mail: $e';
}
}
/// Sign in with Google (UI stub / preparation)
Future<String?> signInWithGoogle() async {
// Note: Once SHA-1 fingerprint is registered in Firebase Console,
// the full interactive Google OAuth sign-in flow can be activated.
return 'Google Sign-In ist in Vorbereitung. Bitte nutze derzeit die E-Mail-Anmeldung oder den Bürger-Code.';
}
/// Send password reset email
Future<String?> sendPasswordReset(String email) async {
try {
@@ -312,12 +130,6 @@ class AuthService extends ChangeNotifier {
/// Sign out
Future<void> signOut() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('user_first_name');
await prefs.remove('user_last_name');
_cachedFirstName = null;
_cachedLastName = null;
_isAdmin = false;
await _auth.signOut();
} catch (e) {
if (kDebugMode) {
-144
View File
@@ -43,14 +43,6 @@ class FirestoreService {
await _firestore.collection(collectionName).doc(documentId).delete();
}
/// Update an existing post by ID
Future<void> updatePost(String collectionName, PostItem item) async {
await _firestore
.collection(collectionName)
.doc(item.id)
.update(item.toFirestore());
}
/// Send a contact form submission to Firestore
Future<void> submitContactMessage({
required String firstName,
@@ -58,7 +50,6 @@ class FirestoreService {
required String email,
String? phone,
required String message,
String? authorUid,
}) async {
await _firestore.collection('ContactMessages').add({
'firstName': firstName,
@@ -66,143 +57,8 @@ class FirestoreService {
'email': email,
'phone': phone ?? '',
'message': message,
'author_uid': authorUid ?? '',
'createdDate': Timestamp.now(),
'status': 'Neu',
});
}
/// Stream of all contact messages (for admins)
Stream<QuerySnapshot<Map<String, dynamic>>> getContactMessagesStream() {
return _firestore
.collection('ContactMessages')
.orderBy('createdDate', descending: true)
.snapshots();
}
/// Update the status of a contact message
Future<void> updateContactMessageStatus(String docId, String status) async {
await _firestore.collection('ContactMessages').doc(docId).update({
'status': status,
'statusUpdatedAt': FieldValue.serverTimestamp(),
});
}
/// Delete a contact message
Future<void> deleteContactMessage(String docId) async {
await _firestore.collection('ContactMessages').doc(docId).delete();
}
/// Stream of all registered users
Stream<QuerySnapshot<Map<String, dynamic>>> getUsersStream() {
return _firestore.collection('Users').snapshots();
}
/// Stream of all admin documents
Stream<QuerySnapshot<Map<String, dynamic>>> getAdminsStream() {
return _firestore.collection('Admins').snapshots();
}
/// Upsert a registered user profile into Firestore
Future<void> syncUserProfile({
required String uid,
required String email,
String? displayName,
String? firstName,
String? lastName,
bool? emailVerified,
bool? isAdmin,
}) async {
try {
final docRef = _firestore.collection('Users').doc(uid);
final doc = await docRef.get();
final data = <String, dynamic>{
'uid': uid,
'email': email.trim().toLowerCase(),
'lastLogin': FieldValue.serverTimestamp(),
if (displayName != null && displayName.isNotEmpty)
'displayName': displayName,
if (firstName != null && firstName.isNotEmpty)
'firstName': firstName,
if (lastName != null && lastName.isNotEmpty)
'lastName': lastName,
'emailVerified': ?emailVerified,
'isAdmin': ?isAdmin,
};
if (!doc.exists) {
data['createdAt'] = FieldValue.serverTimestamp();
}
await docRef.set(data, SetOptions(merge: true));
} catch (e) {
if (kDebugMode) {
debugPrint('Error syncing user profile to Firestore: $e');
}
}
}
/// Promote or demote a user to Admin in Firestore
Future<void> setAdminRole({
required String uid,
required String email,
required bool makeAdmin,
String? grantedBy,
}) async {
final cleanEmail = email.trim().toLowerCase();
final adminRef = _firestore.collection('Admins').doc(uid);
final userRef = _firestore.collection('Users').doc(uid);
if (makeAdmin) {
// 1. Add UID to Admins collection
await adminRef.set({
'uid': uid,
'email': cleanEmail,
'grantedAt': FieldValue.serverTimestamp(),
'grantedBy': grantedBy ?? '',
}, SetOptions(merge: true));
// 2. Also ensure email doc exists if email is known
if (cleanEmail.isNotEmpty) {
await _firestore.collection('Admins').doc(cleanEmail).set({
'uid': uid,
'email': cleanEmail,
'grantedAt': FieldValue.serverTimestamp(),
'grantedBy': grantedBy ?? '',
}, SetOptions(merge: true));
}
// 3. Mark user document as admin
await userRef.set({'isAdmin': true}, SetOptions(merge: true));
} else {
// 1. Remove UID from Admins collection
await adminRef.delete();
// 2. Remove email from Admins collection if present
if (cleanEmail.isNotEmpty) {
try {
await _firestore.collection('Admins').doc(cleanEmail).delete();
} catch (_) {}
}
// 3. Update user document
await userRef.set({'isAdmin': false}, SetOptions(merge: true));
}
}
/// Add admin directly by UID or email (e.g. manual entry)
Future<void> addAdminDirectly({
required String identifier,
required String email,
String? grantedBy,
}) async {
final cleanId = identifier.trim();
final cleanEmail = email.trim().toLowerCase();
await _firestore.collection('Admins').doc(cleanId).set({
'uid': cleanId,
'email': cleanEmail,
'grantedAt': FieldValue.serverTimestamp(),
'grantedBy': grantedBy ?? '',
}, SetOptions(merge: true));
}
}
+14 -102
View File
@@ -35,15 +35,8 @@ class PdfService {
/// Print single post item as PDF
static Future<void> printSinglePost(PostItem item) async {
final pdf = pw.Document();
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
final List<String> urlsToFetch = item.imageUrls.isNotEmpty
? item.imageUrls
: (item.imageUrl.isNotEmpty ? [item.imageUrl] : []);
final results =
await Future.wait(urlsToFetch.map((url) => _fetchImage(url)));
final imageProviders = results.whereType<pw.ImageProvider>().toList();
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
pdf.addPage(
pw.MultiPage(
@@ -65,8 +58,7 @@ class PdfService {
),
pw.Text(
formattedDate,
style:
const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
),
],
),
@@ -76,53 +68,6 @@ class PdfService {
item.title,
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold),
),
if (item.status != null && item.status!.isNotEmpty) ...[
pw.SizedBox(height: 6),
pw.Row(
children: [
pw.Text(
'Status: ',
style: pw.TextStyle(
fontSize: 12,
fontWeight: pw.FontWeight.bold,
color: PdfColors.grey800,
),
),
pw.Container(
padding:
const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: pw.BoxDecoration(
color: item.status == 'Erledigt'
? const PdfColor.fromInt(0xFFE8F5E9)
: (item.status == 'In Bearbeitung'
? const PdfColor.fromInt(0xFFE3F2FD)
: const PdfColor.fromInt(0xFFFFF3E0)),
borderRadius:
const pw.BorderRadius.all(pw.Radius.circular(4)),
border: pw.Border.all(
color: item.status == 'Erledigt'
? const PdfColor.fromInt(0xFF81C784)
: (item.status == 'In Bearbeitung'
? const PdfColor.fromInt(0xFF64B5F6)
: const PdfColor.fromInt(0xFFFFB74D)),
),
),
child: pw.Text(
item.status!,
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: item.status == 'Erledigt'
? const PdfColor.fromInt(0xFF2E7D32)
: (item.status == 'In Bearbeitung'
? const PdfColor.fromInt(0xFF1565C0)
: const PdfColor.fromInt(0xFFE65100)),
),
),
),
],
),
],
if (item.street != null && item.street!.isNotEmpty) ...[
pw.SizedBox(height: 6),
pw.Text(
@@ -139,28 +84,12 @@ class PdfService {
],
pw.Divider(thickness: 1, color: PdfColors.grey400),
pw.SizedBox(height: 10),
if (imageProviders.isNotEmpty) ...[
if (imageProviders.length == 1)
if (imageProvider != null) ...[
pw.Center(
child: pw.Container(
height: 220,
child: pw.Image(imageProviders.first, fit: pw.BoxFit.contain),
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
),
)
else
pw.Row(
children: [
for (int i = 0; i < imageProviders.length; i++) ...[
if (i > 0) pw.SizedBox(width: 12),
pw.Expanded(
child: pw.Container(
height: 180,
child: pw.Image(imageProviders[i],
fit: pw.BoxFit.contain),
),
),
],
],
),
pw.SizedBox(height: 14),
],
@@ -194,10 +123,8 @@ class PdfService {
// Preload all images in parallel
final Map<String, pw.ImageProvider?> imageMap = {};
await Future.wait(items.map((item) async {
final url =
item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl;
if (url.isNotEmpty) {
imageMap[item.id] = await _fetchImage(url);
if (item.imageUrl.isNotEmpty) {
imageMap[item.id] = await _fetchImage(item.imageUrl);
}
}));
@@ -210,8 +137,7 @@ class PdfService {
margin: const pw.EdgeInsets.only(bottom: 12),
padding: const pw.EdgeInsets.only(bottom: 6),
decoration: const pw.BoxDecoration(
border: pw.Border(
bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
@@ -226,8 +152,7 @@ class PdfService {
),
pw.Text(
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
style:
const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
),
],
),
@@ -235,8 +160,7 @@ class PdfService {
build: (context) => [
pw.TableHelper.fromTextArray(
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
headerStyle:
pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellHeight: 65,
cellAlignments: {
@@ -246,32 +170,20 @@ class PdfService {
3: pw.Alignment.centerLeft,
4: pw.Alignment.center,
},
headers: <String>[
'Datum',
'Örtlichkeit / Kat.',
'Titel & Mangel',
'Status',
'Foto'
],
headers: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
data: items.map((item) {
final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
final locInfo =
'${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate);
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
final descSnippet = item.description.length > 120
? '${item.description.substring(0, 120)}...'
: item.description;
final imageCount = item.imageUrls.length;
final imageLabel = imageMap[item.id] != null
? (imageCount > 1 ? '$imageCount Fotos' : 'Foto vorhanden')
: '-';
return [
formattedDate,
locInfo,
'${item.title}\n$descSnippet',
item.status ?? 'Gemeldet',
imageLabel,
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
];
}).toList(),
),
+213 -309
View File
File diff suppressed because it is too large Load Diff
+82 -35
View File
@@ -1,61 +1,108 @@
name: oggauergemeindetrommler
description: Gemeinde Trommler
# Private app – never publish to pub.dev.
publish_to: 'none'
# Version + build number (build-name+build-number). Can be overridden with
# `flutter build ... --build-name --build-number`.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# 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.1
environment:
# Flutter 3.47.x ships Dart 3.13; keep the constraint in sync with the
# Flutter version pinned in the CI workflows (FLUTTER_VERSION).
sdk: ^3.11.0
flutter: '>=3.47.0'
sdk: '>=2.18.2 <3.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# Firebase (Auth, Firestore, Storage)
firebase_core: ^4.15.0
firebase_auth: ^6.7.0
cloud_firestore: ^6.10.0
firebase_storage: ^13.6.0
# UI / state
provider: ^6.1.5
cupertino_icons: ^1.0.8
cached_network_image: ^3.4.1
intl: ^0.20.2
# Media
image_picker: ^1.2.3
image: ^4.5.4
# PDF export & sharing
pdf: ^3.11.3
printing: ^5.14.3
# Misc
http: ^1.2.2
shared_preferences: ^2.5.3
url_launcher: ^6.3.1
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
image_picker: ^1.1.2
firebase_core: ^3.4.0
firebase_auth: ^5.2.0
firebase_storage: ^12.2.0
cloud_firestore: ^5.4.0
change_app_package_name: ^1.1.0
cached_network_image: ^3.2.3
image: ^4.0.16
pdf: ^3.10.1
printing: ^5.10.4
image_cropper: ^8.0.1
logging: ^1.1.1
provider: ^6.0.5
http: ^1.2.0
shared_preferences: ^2.2.2
# Transitive (via flutter_cache_manager / printing), pinned on purpose:
# path_provider_android >= 2.3 is implemented with `jni` and compiles native
# code (CMake + NDK). The ARM64 Gitea runner would have to run the x86_64 NDK
# toolchain through QEMU, so stay on the pure-Java implementation for now.
path_provider_android: '>=2.2.0 <2.3.0'
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^4.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/images/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
-57
View File
@@ -1,57 +0,0 @@
const https = require('https');
const fs = require('fs');
const path = require('path');
const PROJECT_ID = 'gemeinde-trommler';
const COLLECTIONS = ['News', 'Issues', 'CitizensForum', 'ContactMessages', 'Admins'];
function fetchCollection(collection) {
return new Promise((resolve) => {
const url = `https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/(default)/documents/${collection}?pageSize=300`;
https.get(url, (res) => {
let raw = '';
res.on('data', (chunk) => raw += chunk);
res.on('end', () => {
try {
const json = JSON.parse(raw);
if (json.documents) {
resolve({ collection, status: 'OK', count: json.documents.length, documents: json.documents });
} else if (json.error) {
resolve({ collection, status: 'PERMISSION_OR_EMPTY', error: json.error.message, documents: [] });
} else {
resolve({ collection, status: 'EMPTY', count: 0, documents: [] });
}
} catch (e) {
resolve({ collection, status: 'PARSE_ERROR', error: e.message, documents: [] });
}
});
}).on('error', (err) => {
resolve({ collection, status: 'NETWORK_ERROR', error: err.message, documents: [] });
});
});
}
async function runBackup() {
console.log(`[Backup] Starte Sicherung fuer Firebase Projekt: ${PROJECT_ID}...`);
const results = {};
const backupDir = path.join(__dirname, '..', 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
for (const col of COLLECTIONS) {
process.stdout.write(`[Backup] Lade ${col}... `);
const res = await fetchCollection(col);
results[col] = res;
console.log(`${res.status} (${res.documents ? res.documents.length : 0} Dokumente)`);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `firestore_backup_${timestamp}.json`;
const filePath = path.join(backupDir, filename);
fs.writeFileSync(filePath, JSON.stringify(results, null, 2), 'utf-8');
console.log(`\n[Backup] Erfolgreich gespeichert in: ${filePath}`);
}
runBackup();
-108
View File
@@ -1,108 +0,0 @@
const fs = require('fs');
const path = require('path');
async function main() {
const token = process.env.GITEA_TOKEN;
const repo = process.env.REPO;
let apiUrl = (process.env.GITEA_API || 'https://git.djnizz.at/api/v1').replace(/\/$/, '');
if (!apiUrl.includes('/api/v1')) {
apiUrl += '/api/v1';
}
const tag = process.env.RELEASE_TAG || 'latest';
const apksDir = path.resolve('release-apks');
if (!token) {
console.log('No GITEA_TOKEN provided, skipping direct release publishing.');
return;
}
if (!fs.existsSync(apksDir)) {
console.log('No release-apks directory found, skipping.');
return;
}
const apkFiles = fs.readdirSync(apksDir).filter(f => f.endsWith('.apk'));
if (apkFiles.length === 0) {
console.log('No APK files found in release-apks, skipping.');
return;
}
console.log(`Publishing ${apkFiles.length} APK(s) to Gitea release tag '${tag}'...`);
// 1. Get or create release
let release = null;
const tagRes = await fetch(`${apiUrl}/repos/${repo}/releases/tags/${tag}`, {
headers: { 'Authorization': `token ${token}` }
});
if (tagRes.ok) {
release = await tagRes.json();
console.log(`Found existing release (ID: ${release.id}) for tag '${tag}'.`);
} else {
console.log(`Release for tag '${tag}' does not exist, creating new release...`);
const createRes = await fetch(`${apiUrl}/repos/${repo}/releases`, {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tag_name: tag,
name: `Oggauer Gemeindetrommler - ${tag.toUpperCase()}`,
body: `Automatische Builds der Android APKs (Split per ABI).\nDirekte APK-Downloads ohne ZIP.`,
draft: false,
prerelease: false
})
});
if (!createRes.ok) {
console.warn(`Could not create release: ${createRes.status} ${await createRes.text()}`);
return;
}
release = await createRes.json();
console.log(`Created release with ID ${release.id}.`);
}
// 2. Query existing assets to delete duplicates
const assetsRes = await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets`, {
headers: { 'Authorization': `token ${token}` }
});
const existingAssets = assetsRes.ok ? await assetsRes.json() : [];
// 3. Upload each APK
for (const fileName of apkFiles) {
const filePath = path.join(apksDir, fileName);
const existing = existingAssets.find(a => a.name === fileName);
if (existing) {
console.log(`Deleting old asset ${fileName} (ID: ${existing.id})...`);
await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets/${existing.id}`, {
method: 'DELETE',
headers: { 'Authorization': `token ${token}` }
});
}
console.log(`Uploading ${fileName} (${(fs.statSync(filePath).size / (1024 * 1024)).toFixed(1)} MB)...`);
const fileBytes = fs.readFileSync(filePath);
const form = new FormData();
form.append('attachment', new Blob([fileBytes]), fileName);
const uploadRes = await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(fileName)}`, {
method: 'POST',
headers: {
'Authorization': `token ${token}`
},
body: form
});
if (uploadRes.ok) {
const asset = await uploadRes.json();
console.log(`✓ Uploaded ${fileName}: ${asset.browser_download_url || fileName}`);
} else {
console.warn(`Failed to upload ${fileName}: ${uploadRes.status} ${await uploadRes.text()}`);
}
}
console.log('Finished publishing APKs to Gitea Releases!');
}
main().catch(err => {
console.error('Error in publish_release.js:', err);
});
-162
View File
@@ -1,162 +0,0 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <stdint.h>
#include <spawn.h>
static int is_x86_64_elf(const char *path) {
if (!path) return 0;
int fd = open(path, O_RDONLY);
if (fd < 0) return 0;
unsigned char header[20];
ssize_t n = read(fd, header, sizeof(header));
close(fd);
if (n < 20) return 0;
if (header[0] != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F') return 0;
if (header[4] != 2 || header[5] != 1) return 0;
uint16_t machine = (uint16_t)(header[18] | (header[19] << 8));
return machine == 62;
}
static char **clean_env(char *const envp[]) {
extern char **environ;
char *const *src = envp ? envp : environ;
int count = 0;
while (src && src[count]) count++;
char **new_env = (char **)malloc((count + 1) * sizeof(char *));
if (!new_env) return (char **)src;
int j = 0;
for (int i = 0; i < count; i++) {
if (strncmp(src[i], "LD_PRELOAD=", 11) != 0) {
new_env[j++] = src[i];
}
}
new_env[j] = NULL;
return new_env;
}
static char **build_qemu_argv(const char *pathname, char *const argv[]) {
int argc = 0;
while (argv && argv[argc]) argc++;
char **new_argv = (char **)malloc((argc + 4) * sizeof(char *));
if (!new_argv) return NULL;
new_argv[0] = "/usr/bin/qemu-x86_64-static";
new_argv[1] = "-L";
new_argv[2] = "/usr/x86_64-linux-gnu";
new_argv[3] = (char *)pathname;
for (int i = 1; i < argc; i++) {
new_argv[i + 3] = argv[i];
}
new_argv[argc + 3] = NULL;
return new_argv;
}
int execve(const char *pathname, char *const argv[], char *const envp[]) {
static int (*real_execve)(const char *, char *const [], char *const []) = NULL;
if (!real_execve) real_execve = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
if (pathname && is_x86_64_elf(pathname)) {
char **new_argv = build_qemu_argv(pathname, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
return real_execve("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execve(pathname, argv, envp);
}
int execv(const char *pathname, char *const argv[]) {
static int (*real_execv)(const char *, char *const []) = NULL;
if (!real_execv) real_execv = (int (*)(const char *, char *const []))dlsym(RTLD_NEXT, "execv");
if (pathname && is_x86_64_elf(pathname)) {
char **new_argv = build_qemu_argv(pathname, argv);
if (new_argv) {
extern char **environ;
char **clean_envp = clean_env(environ);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execv(pathname, argv);
}
int execvp(const char *file, char *const argv[]) {
static int (*real_execvp)(const char *, char *const []) = NULL;
if (!real_execvp) real_execvp = (int (*)(const char *, char *const []))dlsym(RTLD_NEXT, "execvp");
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
extern char **environ;
char **clean_envp = clean_env(environ);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execvp(file, argv);
}
int execvpe(const char *file, char *const argv[], char *const envp[]) {
static int (*real_execvpe)(const char *, char *const [], char *const []) = NULL;
if (!real_execvpe) real_execvpe = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execvpe");
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execvpe(file, argv, envp);
}
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]) {
static int (*real_posix_spawn)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawn) {
real_posix_spawn = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawn");
}
if (path && is_x86_64_elf(path)) {
char **new_argv = build_qemu_argv(path, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
return real_posix_spawn(pid, "/usr/bin/qemu-x86_64-static", file_actions, attrp, new_argv, clean_envp);
}
}
return real_posix_spawn(pid, path, file_actions, attrp, argv, envp);
}
int posix_spawnp(pid_t *pid, const char *file,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]) {
static int (*real_posix_spawnp)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawnp) {
real_posix_spawnp = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawnp");
}
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
static int (*real_posix_spawn_local)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawn_local) {
real_posix_spawn_local = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawn");
}
return real_posix_spawn_local(pid, "/usr/bin/qemu-x86_64-static", file_actions, attrp, new_argv, clean_envp);
}
}
return real_posix_spawnp(pid, file, file_actions, attrp, argv, envp);
}
+2 -7
View File
@@ -12,13 +12,8 @@ service firebase.storage {
&& request.resource.size < 10 * 1024 * 1024
&& request.resource.contentType.matches('image/.*');
// Löschen streng gesichert: Nur der eigene Verfasser ODER ein Admin aus der Admins-Sammlung
allow delete: if request.auth != null && (
request.auth.uid == userId ||
request.auth.token.admin == true ||
firestore.exists(/databases/(default)/documents/Admins/$(request.auth.uid)) ||
(request.auth.token.email != null && firestore.exists(/databases/(default)/documents/Admins/$(request.auth.token.email)))
);
// Nur der Verfasser (oder berechtigte Nutzer) darf sein Foto löschen
allow delete: if request.auth != null && request.auth.uid == userId;
}
}
}
-335
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:oggauergemeindetrommler/models/post_item.dart';
@@ -14,7 +13,6 @@ void main() {
createdDate: now,
street: 'Hauptstraße',
category: 'Straßenbeleuchtung',
status: 'Gemeldet',
);
expect(item.id, 'test-123');
@@ -25,46 +23,6 @@ void main() {
expect(item.status, 'Gemeldet');
});
test('defaults status to Gemeldet only for issues and null for news/ideas', () {
final issueJson = {
'id': 'issue-1',
'title': 'Schlagloch',
'description': 'Tiefes Schlagloch',
'createdDate': '2026-09-18T10:00:00.000',
'street': 'Kirchengasse',
'category': 'Straße',
};
final issueItem = PostItem.fromJson(issueJson);
expect(issueItem.status, 'Gemeldet');
final newsJson = {
'id': 'news-1',
'title': 'Dorffest Ankündigung',
'description': 'Am Samstag findet das Fest statt.',
'createdDate': '2026-09-18T10:00:00.000',
};
final newsItem = PostItem.fromJson(newsJson);
expect(newsItem.status, isNull);
});
test('handles multiple images in PostItem', () {
final item = PostItem(
id: 'multi-img-1',
title: 'Baustelle',
description: 'Zwei Fotos vorhanden',
createdDate: DateTime.now(),
imageUrls: [
'https://example.com/img1.jpg',
'https://example.com/img2.jpg',
],
);
expect(item.imageUrls.length, 2);
expect(item.imageUrls[0], 'https://example.com/img1.jpg');
expect(item.imageUrls[1], 'https://example.com/img2.jpg');
expect(item.imageUrl, 'https://example.com/img1.jpg');
});
test('serializes to Firestore map accurately', () {
final now = DateTime.now();
final item = PostItem(
@@ -83,298 +41,5 @@ void main() {
expect(map['category'], 'Müll / Verunreinigung');
expect(map['status'], 'In Bearbeitung');
});
test('parses WordPress JSON accurately with HTML unescape and PDF extraction', () {
final wpJson = {
'id': 659,
'date': '2026-09-10T10:42:00',
'title': {'rendered': 'Aussendung 17 &#8211; Konzept &amp; Camping'},
'content': {
'rendered':
'<h2 class="wp-block-heading">Aussendung 17</h2><div class="wp-block-file"><object data="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf" type="application/pdf"></object><a href="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf">Download</a></div>'
},
'link': 'https://buergerforumoggau.at/2026/09/aussendung-17/',
};
final item = PostItem.fromWordPressJson(wpJson);
expect(item.id, 'wp_659');
expect(item.title, 'Aussendung 17 – Konzept & Camping');
expect(item.isAussendung, true);
expect(item.pdfUrl,
'https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf');
expect(item.webUrl, 'https://buergerforumoggau.at/2026/09/aussendung-17/');
expect(item.category, 'Aussendung');
expect(item.createdDate, DateTime.parse('2026-09-10T10:42:00'));
});
test('serializes to and from local JSON cache correctly', () {
final item = PostItem(
id: 'wp_999',
title: 'Aussendung 99',
description: 'Test Aussendung',
createdDate: DateTime(2026, 9, 14, 12, 0),
pdfUrl: 'https://buergerforumoggau.at/test.pdf',
webUrl: 'https://buergerforumoggau.at/test/',
isAussendung: true,
);
final json = item.toJson();
final restored = PostItem.fromJson(json);
expect(restored.id, item.id);
expect(restored.title, item.title);
expect(restored.pdfUrl, item.pdfUrl);
expect(restored.webUrl, item.webUrl);
expect(restored.isAussendung, true);
expect(restored.createdDate, item.createdDate);
});
test('sorts mixed list of Firestore news and Aussendungen chronologically', () {
final oldNews = PostItem(
id: 'news_1',
title: 'Ältere News',
description: 'Text',
createdDate: DateTime(2026, 8, 1),
);
final newAussendung = PostItem(
id: 'wp_659',
title: 'Aussendung 17',
description: 'Text',
createdDate: DateTime(2026, 9, 10),
isAussendung: true,
);
final newestNews = PostItem(
id: 'news_2',
title: 'Neueste Nachricht',
description: 'Text',
createdDate: DateTime(2026, 9, 14),
);
final list = [oldNews, newAussendung, newestNews];
list.sort((a, b) => b.createdDate.compareTo(a.createdDate));
expect(list[0].id, 'news_2');
expect(list[1].id, 'wp_659');
expect(list[2].id, 'news_1');
});
});
group('Contact Message & Admin Management Tests', () {
test('validates contact message status states and defaults', () {
const allowedStatuses = ['Neu', 'In Bearbeitung', 'Erledigt'];
expect(allowedStatuses.contains('Neu'), isTrue);
expect(allowedStatuses.contains('In Bearbeitung'), isTrue);
expect(allowedStatuses.contains('Erledigt'), isTrue);
expect(allowedStatuses.contains('Unbekannt'), isFalse);
const messageMap = {
'subject': 'Straßenbeleuchtung kaputt',
'message': 'Vor der Schule ist es dunkel.',
'senderName': 'Max Mustermann',
'senderEmail': 'max@example.com',
'senderPhone': '0664 1234567',
'status': 'Neu',
'userId': 'user-123',
};
expect(messageMap['status'], 'Neu');
expect(messageMap['senderEmail'], 'max@example.com');
expect(messageMap['userId'], 'user-123');
});
test('validates user profile normalization and admin lookup', () {
String normalizeEmail(String email) => email.trim().toLowerCase();
const rawEmail = ' Admin.Test@Oggau.at ';
expect(normalizeEmail(rawEmail), 'admin.test@oggau.at');
const adminUids = {'uid_123', 'uid_456'};
const adminEmails = {'admin.test@oggau.at', 'buergermeister@oggau.at'};
bool isUserAdmin(String uid, String email) {
return adminUids.contains(uid) || adminEmails.contains(normalizeEmail(email));
}
expect(isUserAdmin('uid_123', 'other@mail.com'), isTrue);
expect(isUserAdmin('uid_999', 'Admin.Test@oggau.at'), isTrue);
expect(isUserAdmin('uid_999', 'regular.user@oggau.at'), isFalse);
});
test('prevents self-demotion check logic', () {
bool canDemote(String targetUid, String currentUid) {
return targetUid != currentUid;
}
expect(canDemote('target_user', 'current_admin'), isTrue);
expect(canDemote('current_admin', 'current_admin'), isFalse);
});
});
group('PDF Viewer & Navigation Overlap Tests', () {
test('verifies double-tap zoom matrix calculation and reset', () {
// Test at standard scale (1.0) -> zoom to 2.5x
final currentMatrix = Matrix4.identity();
final currentScale = currentMatrix.getMaxScaleOnAxis();
expect(currentScale, 1.0);
const tapPosition = Offset(100, 200);
Matrix4 targetMatrix;
if (currentScale > 1.05) {
targetMatrix = Matrix4.identity();
} else {
final x = -tapPosition.dx * (2.5 - 1.0);
final y = -tapPosition.dy * (2.5 - 1.0);
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
..setTranslationRaw(x, y, 0.0);
}
expect(targetMatrix.getMaxScaleOnAxis(), 2.5);
expect(targetMatrix.getTranslation().x, -150.0);
expect(targetMatrix.getTranslation().y, -300.0);
// Test when zoomed in -> reset to standard size (scale 1.0)
final zoomedScale = targetMatrix.getMaxScaleOnAxis();
expect(zoomedScale > 1.05, isTrue);
Matrix4 resetMatrix;
if (zoomedScale > 1.05) {
resetMatrix = Matrix4.identity();
} else {
resetMatrix = targetMatrix;
}
expect(resetMatrix.getMaxScaleOnAxis(), 1.0);
expect(resetMatrix.getTranslation().x, 0.0);
expect(resetMatrix.getTranslation().y, 0.0);
});
test('calculates safe bottom padding above 3-button navigation bar', () {
// 3-button navigation typical height is ~48dp
const systemNavBarHeight = 48.0;
const keyboardHeight = 0.0;
const extraPadding = 20.0;
final totalBottomPadding = systemNavBarHeight + keyboardHeight + extraPadding;
expect(totalBottomPadding, 68.0);
// Ensures interactive action buttons sit strictly above the navigation bar
expect(totalBottomPadding > systemNavBarHeight, isTrue);
});
});
group('Post Edit & Permissions Tests', () {
test('PostItem.copyWith updates fields while preserving existing values', () {
final original = PostItem(
id: 'post-1',
title: 'Original Title',
description: 'Original Desc',
createdDate: DateTime(2026, 1, 1),
authorUid: 'user-123',
imageUrl: 'https://example.com/img1.jpg',
street: 'Hauptstraße',
category: 'Sonstiges',
status: 'Gemeldet',
);
final edited = original.copyWith(
title: 'Updated Title',
description: 'Updated Desc',
imageUrl: 'https://example.com/img2.jpg',
imageUrls: ['https://example.com/img2.jpg'],
status: 'In Bearbeitung',
);
expect(edited.id, 'post-1');
expect(edited.authorUid, 'user-123');
expect(edited.createdDate, DateTime(2026, 1, 1));
expect(edited.street, 'Hauptstraße');
expect(edited.category, 'Sonstiges');
expect(edited.title, 'Updated Title');
expect(edited.description, 'Updated Desc');
expect(edited.imageUrl, 'https://example.com/img2.jpg');
expect(edited.imageUrls, ['https://example.com/img2.jpg']);
expect(edited.status, 'In Bearbeitung');
});
test('verifies post edit permissions: admins can edit all, users can edit only their own, Aussendungen cannot be edited', () {
bool canEditPost({
required PostItem post,
required String? currentUserId,
required bool isAdmin,
}) {
if (post.isAussendung) return false;
final isAuthor = currentUserId != null && post.authorUid == currentUserId;
return isAuthor || isAdmin;
}
final userPost = PostItem(
id: 'user_post_1',
title: 'Schlagloch',
description: 'Großes Schlagloch',
createdDate: DateTime.now(),
authorUid: 'author_1',
);
final aussendung = PostItem(
id: 'wp_123',
title: 'Aussendung',
description: 'PDF',
createdDate: DateTime.now(),
isAussendung: true,
);
// 1. Author user can edit their own post
expect(
canEditPost(post: userPost, currentUserId: 'author_1', isAdmin: false),
isTrue,
);
// 2. Different user cannot edit other user's post
expect(
canEditPost(post: userPost, currentUserId: 'other_user', isAdmin: false),
isFalse,
);
// 3. Admin can edit any post (even if not author)
expect(
canEditPost(post: userPost, currentUserId: 'other_user', isAdmin: true),
isTrue,
);
// 4. Admin cannot edit external WordPress Aussendungen
expect(
canEditPost(post: aussendung, currentUserId: 'author_1', isAdmin: true),
isFalse,
);
// 5. Unauthenticated user cannot edit
expect(
canEditPost(post: userPost, currentUserId: null, isAdmin: false),
isFalse,
);
});
test('validates issue status editing for admins', () {
const allowedStatuses = ['Gemeldet', 'In Bearbeitung', 'Erledigt'];
final issue = PostItem(
id: 'issue_1',
title: 'Straßenlaterne',
description: 'Laterne dunkel',
createdDate: DateTime.now(),
authorUid: 'user_1',
status: 'Gemeldet',
);
expect(issue.status, 'Gemeldet');
// Admin transitions status
final inProgress = issue.copyWith(status: 'In Bearbeitung');
expect(inProgress.status, 'In Bearbeitung');
expect(allowedStatuses.contains(inProgress.status), isTrue);
final done = inProgress.copyWith(status: 'Erledigt');
expect(done.status, 'Erledigt');
expect(allowedStatuses.contains(done.status), isTrue);
});
});
}