Compare commits

...
41 Commits
Author SHA1 Message Date
Nizz05 a0724af9e2 fix(test): use const declarations in widget_test to satisfy linter
Build Android APK / Build Release APKs (push) Canceled after 4m37s
2026-09-15 22:34:50 +02:00
Nizz05 d9dd2a33c8 docs: update README with current features, in-app user management, and security architecture
Build Android APK / Build Release APKs (push) Failing after 50s
2026-09-15 22:31:02 +02:00
Nizz05 f27ad83908 feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management
Build Android APK / Build Release APKs (push) Failing after 53s
2026-09-15 22:06:36 +02:00
Nizz05 21f115499c feat: integrate WordPress PDF Aussendungen with in-app reader into News
Build Android APK / Build Release APKs (push) Successful in 11m39s
2026-09-14 21:32:03 +02:00
Nizz05 d63222c2ac security(storage): strictly restrict delete to photo author or verified firestore admin
Build Android APK / Build Release APKs (push) Successful in 14m16s
2026-09-14 20:59:40 +02:00
Nizz05 c62d41f44a fix(admin): make storage delete resilient and allow authenticated deletion in storage rules
Build Android APK / Build Release APKs (push) Successful in 14m29s
2026-09-14 20:53:41 +02:00
Nizz05 16801d39c6 feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool
Build Android APK / Build Release APKs (push) Successful in 12m58s
2026-09-14 11:47:58 +02:00
Nizz05 fb91666402 refactor(admin): remove hardcoded email and dynamically resolve admin privileges via Firestore Admins collection
Build Android APK / Build Release APKs (push) Successful in 16m46s
2026-09-14 11:44:04 +02:00
Nizz05 2f2170573d feat(admin): grant full admin permissions in app and security rules to nicolas@gruber.info
Build Android APK / Build Release APKs (push) Successful in 16m30s
2026-09-14 11:35:40 +02:00
Nizz05 f9a88a5ba2 feat(security & admin): update firestore and storage rules with admin token check and enable admin deletion in app
Build Android APK / Build Release APKs (push) Successful in 16m34s
2026-09-14 11:33:17 +02:00
Nizz05 23d10ae953 feat(auth & contact): separate Vorname/Nachname in registration, pre-fill read-only profile in contact, and restrict contact messages to registered users
Build Android APK / Build Release APKs (push) Successful in 14m55s
2026-09-14 11:29:12 +02:00
Nizz05 33f7d1d36c refactor(auth): simplify to 2 tabs and embed Buerger-Code under Google in registration
Build Android APK / Build Release APKs (push) Successful in 11m21s
2026-09-14 11:12:35 +02:00
Nizz05 5ed7288c0e feat(auth): integrate Google Sign-In UI with custom button and friendly preparation notice
Build Android APK / Build Release APKs (push) Successful in 13m30s
2026-09-14 10:33:07 +02:00
Nizz05 a50456032c fix(auth): make auth dialog responsive and scrollable without overflow, add email verification on signup, and add password toggles
Build Android APK / Build Release APKs (push) Successful in 13m30s
2026-09-14 10:26:44 +02:00
Nizz05 66246c9b85 feat(ci): restore universal APK, set 7-day retention for artifacts, and clean workspace to save storage
Build Android APK / Build Release APKs (push) Successful in 11m29s
2026-09-14 10:07:15 +02:00
Nizz05 fe1e5b2685 feat(ci): build only 3 split APKs, upload individual artifacts, and publish unzipped APKs to Gitea Releases
Build Android APK / Build Release APKs (push) Successful in 10m32s
2026-09-14 09:55:04 +02:00
Nizz05 4d376b4c3a fix(ci): use actions/upload-artifact@v3 for Gitea compatibility
Build Android APK / Build Release APKs (push) Successful in 15m32s
2026-09-14 09:31:34 +02:00
Nizz05 3d57f4563e feat(build): migrate to declarative Gradle plugin DSL and bump Dart SDK constraint to >=3.0.0 <4.0.0
Build Android APK / Build Release APKs (push) Failing after 15m10s
2026-09-14 09:28:36 +02:00
Nizz05 20f023d2d5 fix(build): route subprojects buildDir to ../build so Flutter CLI finds generated APKs
Build Android APK / Build Release APKs (push) Failing after 10m55s
2026-09-14 09:17:01 +02:00
Nizz05 a3ddd4dfa3 fix(ci): strip LD_PRELOAD on child QEMU execution and remove rename watcher
Build Android APK / Build Release APKs (push) Failing after 9m49s
2026-09-14 00:27:57 +02:00
Nizz05 f810b801ad fix(ci): delegate execvp and posix_spawnp to real glibc symbols for PATH resolution
Build Android APK / Build Release APKs (push) Failing after 8m20s
2026-09-14 00:17:36 +02:00
Nizz05 810396d4f7 fix(ci): restore string literal quotes in qemu_intercept.c
Build Android APK / Build Release APKs (push) Failing after 14s
2026-09-14 00:16:33 +02:00
Nizz05 b53d31d8cf fix(ci): extract QEMU interceptor to dedicated C file to fix YAML indentation
Build Android APK / Build Release APKs (push) Failing after 18s
2026-09-14 00:15:20 +02:00
Nizz05 23bc802dae fix(ci): inject LD_PRELOAD interceptor to redirect all x86_64 ELF executions to QEMU 2026-09-14 00:13:23 +02:00
Nizz05 e8556839b6 fix(ci): direct AAPT2 userspace wrapping with QEMU to bypass host binfmt and FEX
Build Android APK / Build Release APKs (push) Failing after 8m29s
2026-09-14 00:03:12 +02:00
Nizz05 07789fc06b fix(ci): mount binfmt_misc and cleanly register QEMU x86_64 for AAPT2 on ARM64
Build Android APK / Build Release APKs (push) Failing after 7m43s
2026-09-13 23:51:32 +02:00
Nizz05 2583dc3572 fix(ci): unregister host FEX from binfmt and wrap FEXInterpreter with QEMU for AAPT2
Build Android APK / Build Release APKs (push) Failing after 7m45s
2026-09-13 23:34:50 +02:00
Nizz05 b1970e69ac ci: enable privileged container mode and binfmt support for aapt2 execution
Build Android APK / Build Release APKs (push) Failing after 7m52s
2026-09-13 23:07:19 +02:00
Nizz05 e71063f179 fix(ci): install x86_64 glibc cross libraries for qemu user emulation
Build Android APK / Build Release APKs (push) Failing after 7m49s
2026-09-13 22:44:53 +02:00
Nizz05 d04d539e68 fix(ci): correct YAML indentation in workflow file
Build Android APK / Build Release APKs (push) Failing after 3m48s
2026-09-13 22:39:19 +02:00
Nizz05 1d8a83d1da fix(ci): bridge Flutter linux-arm64 gen_snapshot using qemu wrapper 2026-09-13 22:30:22 +02:00
Nizz05 c6a39b4e02 fix(android): remove hardcoded Windows java.home from repo gradle.properties
Build Android APK / Build Release APKs (push) Failing after 3m43s
2026-09-13 22:20:56 +02:00
Nizz05 a3323c8b2d fix(ci): track gradlew and wrapper jar, ensure executable permissions
Build Android APK / Build Release APKs (push) Failing after 59s
2026-09-13 22:11:52 +02:00
Nizz05 626185aa4f fix(ci): install official Node.js 20 binary for ES2022 syntax support
Build Android APK / Build Release APKs (push) Failing after 5s
2026-09-13 22:06:48 +02:00
Nizz05 a62d80f909 fix(ci): install nodejs in build container before checkout action
Build Android APK / Build Release APKs (push) Failing after 15s
2026-09-13 22:03:22 +02:00
Nizz05 3c41af882d feat(ci): add docker-compose setup for Gitea act_runner and local build script
Build Android APK / Build Release APKs (push) Failing after 1m31s
2026-09-13 21:53:16 +02:00
Nizz05 e2fe4ce6c4 ci: add Gitea Actions workflow to build and upload APK artifacts
Build Android APK / Build Release APKs (push) Failing after 1m31s
2026-09-13 21:44:01 +02:00
Nizz05 cfb68fd8bb feat(auth): add AuthDialog with registration, login, code-unlock and wire into UI 2026-09-13 21:26:57 +02:00
Nizz05 9fd2b2aa4b Add production Firestore and Storage security rules 2026-09-13 21:10:39 +02:00
Nizz05 2854afdd22 Untrack android/app/build directory 2026-09-13 20:52:06 +02:00
Nizz05 02c8189ada Fix UI camera notch clipping, embed Bürgerforum Oggau logo, and align branding with Bürgerliste Oggau 2026-09-13 16:06:54 +02:00
40 changed files with 5449 additions and 569 deletions
+181
View File
@@ -0,0 +1,181 @@
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
permissions:
contents: write
container:
image: ghcr.io/cirruslabs/flutter:3.19.3
options: --privileged
steps:
- 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
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
# 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
# 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"
QEMU_BIN=$(command -v qemu-x86_64-static || command -v qemu-x86_64)
# 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
for dir in /sdks/flutter/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec %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
fi
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup QEMU Interceptor
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
- name: Make Gradlew Executable
run: |
if [ -f android/gradlew ]; then
chmod +x android/gradlew
fi
- name: Inspect Build Environment
run: |
flutter --version
java -version
node --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 || {
echo "Checking if APK was built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Build Split-per-ABI Release APKs
run: |
flutter build apk --release --split-per-abi || {
echo "Checking if split APKs were built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- 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.apk
elif [ -f android/app/build/outputs/apk/release/app-release.apk ]; then
cp android/app/build/outputs/apk/release/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
fi
# Copy split APKs
for apk in build/app/outputs/flutter-apk/*.apk android/app/build/outputs/apk/release/*.apk; do
[ -f "$apk" ] || continue
case "$(basename "$apk")" in
*arm64-v8a*) cp "$apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk ;;
*armeabi-v7a*) cp "$apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk ;;
*x86_64*) cp "$apk" release-apks/oggauer-gemeindetrommler-x86_64.apk ;;
esac
done
echo "=== Generated All 4 APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7
- 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
- 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
- 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
- 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
+171
View File
@@ -0,0 +1,171 @@
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
permissions:
contents: write
container:
image: ghcr.io/cirruslabs/flutter:3.19.3
options: --privileged
steps:
- 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
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
# 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
# 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"
QEMU_BIN=$(command -v qemu-x86_64-static || command -v qemu-x86_64)
# 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
for dir in /sdks/flutter/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec %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
fi
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup QEMU Interceptor
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
- name: Make Gradlew Executable
run: |
if [ -f android/gradlew ]; then
chmod +x android/gradlew
fi
- name: Inspect Build Environment
run: |
flutter --version
java -version
node --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 || {
echo "Checking if APK was built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Build Split-per-ABI Release APKs
run: |
flutter build apk --release --split-per-abi || {
echo "Checking if split APKs were built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- 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.apk
elif [ -f android/app/build/outputs/apk/release/app-release.apk ]; then
cp android/app/build/outputs/apk/release/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
fi
# Copy split APKs
for apk in build/app/outputs/flutter-apk/*.apk android/app/build/outputs/apk/release/*.apk; do
[ -f "$apk" ] || continue
case "$(basename "$apk")" in
*arm64-v8a*) cp "$apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk ;;
*armeabi-v7a*) cp "$apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk ;;
*x86_64*) cp "$apk" release-apks/oggauer-gemeindetrommler-x86_64.apk ;;
esac
done
echo "=== Generated All 4 APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7
- 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
- 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
- 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
- name: Clean Workspace Disk Space
if: always()
run: |
rm -rf build android/.gradle release-apks 2>/dev/null || true
+6
View File
@@ -42,3 +42,9 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/app/build/
.gradle/
release-apks/
gitea-runner/data/
gitea-runner/.env
backups/
+150 -79
View File
@@ -1,122 +1,193 @@
# Oggauer Gemeindetrommler 🥁
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.
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.
---
## 📱 Aktueller Funktionsumfang (Ist-Zustand)
## 📱 Funktionsumfang & Module
Die App wurde in ihrer Erstversion mit Unterstützung von KI (frühe GPT-Modelle) entwickelt und nutzt Firebase als Backend.
### 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.
### 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.
### 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.
### 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).
### 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.
### 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.
### 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.
### 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.
### 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.
---
## 🔍 Soll vs. Ist Abgleich (Original-Anforderungen)
## 🔒 Sicherheitsarchitektur (Backend & Frontend)
Basierend auf dem ursprünglichen Lastenheft von Thomas und den UI-Wireframes (`App (1).xlsx`) ergibt sich folgender Umsetzungsstatus:
Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sicherheitskonzept gewährleistet:
| 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 |
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 |
---
## 🏗️ Technische Architektur & Tech-Stack
### Verwendete Technologien:
- **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`
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=3.0.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`, `image_cropper`, `cached_network_image`, `image`
- **State Management:**
- `provider` (`MultiProvider` Architektur)
### Projektstruktur (`lib/`):
```text
lib/
├── AddPage.dart # Erfassungsdialog für Beiträge, Mängel & Ideen (Upload, Crop)
├── DetailPage.dart # Detailansicht für einzelne Beiträge inkl. PDF-Export
├── LoginPage.dart # (Fragment) E-Mail/Passwort-Loginmaske (nicht verdrahtet)
├── register.dart # (Fragment) Registrierungsmaske (nicht verdrahtet)
├── UserDefinedList.dart # Universelle Listenanzeige, Firestore-Stream, Selektion & Sammel-PDF
├── anonym_aut.dart # AuthState Provider & einfacher anonymer Login-Screen
├── firebase_options.dart # Firebase-Projektkonfiguration (Android & iOS)
├── home.dart # Haupt-Scaffold mit BottomNavigationBar (4 Tabs)
└── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
├── firebase_options.dart # Firebase-Konfiguration für Android & iOS
├── main.dart # Einstiegspunkt, Theme & MultiProvider Setup
├── models/
│ └── post_item.dart # Datenmodell für News, Aussendungen, 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
└── 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
```
---
## 🛠️ Einrichtung & Lokaler Start
## 🤖 CI/CD Pipeline (Gitea Actions)
### 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`)
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
### Installation & Ausführung
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.19.3` mit vorkonfiguriertem Android SDK und Java 17.
- **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 ZIP-Artefakt (`oggauer-gemeindetrommler-apks`) im Gitea-Reiter **Aktionen** zum Download bereit.
---
## 🛠️ Einrichtung & Lokale Ausführung
### Voraussetzungen:
- **Flutter SDK:** `>= 3.19.x`
- **Dart SDK:** Im Flutter SDK enthalten
- **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten
### Befehle:
```bash
# 1. Abhängigkeiten installieren
# 1. Abhängigkeiten laden
flutter pub get
# 2. Statische Code-Analyse ausführen
flutter analyze
# 3. App starten
# 3. Unit-Tests ausführen
flutter test
# 4. App im Entwicklungsmodus starten
flutter run
```
---
## 🚀 Empfohlene Verbesserungen & Roadmap
Die Erstellung durch frühe KI-Modelle hat zu funktionalen Proof-of-Concepts geführt, weist jedoch typischen Refactoring-Bedarf auf:
1. **Code-Hygiene & Best Practices:**
- Umbenennung der Dateien nach Dart-Konvention (`snake_case`, z. B. `add_page.dart`, `detail_page.dart`).
- Behebung der `flutter analyze` Warnungen (Deprecations wie `enablePersistence`, `BuildContext` across async gaps, ungenutzte Imports).
- Saubere Abhängigkeitsdeklaration (`http` direkt in `pubspec.yaml` aufnehmen).
2. **Architektur & Wartbarkeit:**
- Trennung von UI und Business-Logik (Einführung von Repositories / Services für Firestore und Firebase Storage).
- Auslagerung der CPU-intensiven Bildverarbeitung (`image.decodeImage`) in Isolate (`compute`), um UI-Ruckler zu vermeiden.
3. **Vervollständigung der Fachanforderungen:**
- **Kontakt-Formular:** Umsetzung mit Pflichtfeldern und Mail-Trigger (z. B. via Firebase Extension `Trigger Email` oder Cloud Function).
- **Informationen / Impressum / Veranstaltungen:** Fünfter Tab gemäß Anforderung.
- **Mängelmeldung:** Dropdown für Straßenverzeichnis Oggau und Grundlage; Unterstützung von bis zu 2 Fotos im Querformat.
- **Volltextsuche:** Suchleiste / Suchsymbol in den Listenansichten.
- **Zugangskonzept:** Echtes Bürger-Passwort bzw. PIN-Schutz statt reinem anonymen Login.
---
## 📄 Lizenz & Urheberschaft
Erstellt für die Marktgemeinde Oggau am Neusiedler See. Private Verwendung.
Entwickelt für das **Bürgerforum Oggau**. Private & gemeinnützige Verwendung.
-3
View File
@@ -1,8 +1,5 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
+8 -15
View File
@@ -1,3 +1,10 @@
plugins {
id "com.android.application"
id "kotlin-android"
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()) {
@@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
}
}
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'
@@ -21,13 +23,6 @@ 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"
compileSdkVersion flutter.compileSdkVersion
@@ -75,6 +70,4 @@ flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
dependencies {}
+19
View File
@@ -35,5 +35,24 @@
</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>
+4 -16
View File
@@ -1,18 +1,3 @@
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 {
@@ -23,7 +8,9 @@ allprojects {
rootProject.buildDir = '../build'
subprojects {
// Note: Do not override project.buildDir across different drives on Windows (H: vs C:)
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
// Inject namespace into plugins that don't have it (required for AGP 8+)
afterEvaluate { project ->
@@ -43,3 +30,4 @@ subprojects {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
+1 -2
View File
@@ -1,4 +1,3 @@
org.gradle.jvmargs=-Xmx1536M -Dandroid.prefs.root=C:/Users/nicol/.android
org.gradle.jvmargs=-Xmx1536M -Djdk.lang.Process.launchMechanism=vfork
android.useAndroidX=true
android.enableJetifier=true
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
Binary file not shown.
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/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
@@ -0,0 +1,90 @@
@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
+27 -8
View File
@@ -1,11 +1,30 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
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()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
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"
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.5.2" apply false
id "org.jetbrains.kotlin.android" version "1.9.22" apply false
id "com.google.gms.google-services" version "4.4.1" apply false
}
include ":app"
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+9
View File
@@ -0,0 +1,9 @@
@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
@@ -0,0 +1,59 @@
@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
+61
View File
@@ -0,0 +1,61 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Hilfsfunktionen
function isAuthenticated() {
return 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)))
);
}
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()
);
}
// 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)
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();
}
}
}
+9
View File
@@ -0,0 +1,9 @@
# 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
@@ -0,0 +1,25 @@
# 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
@@ -0,0 +1,23 @@
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
+166
View File
@@ -11,6 +11,9 @@ class PostItem {
final String? street;
final String? category;
final String? status;
final String? pdfUrl;
final String? webUrl;
final bool isAussendung;
PostItem({
required this.id,
@@ -23,6 +26,9 @@ class PostItem {
this.street,
this.category,
this.status = 'Gemeldet',
this.pdfUrl,
this.webUrl,
this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
factory PostItem.fromFirestore(DocumentSnapshot doc) {
@@ -54,6 +60,9 @@ class PostItem {
street: data['street'] as String?,
category: data['category'] as String?,
status: data['status'] as String? ?? 'Gemeldet',
pdfUrl: data['pdfUrl'] as String?,
webUrl: data['webUrl'] as String?,
isAussendung: data['isAussendung'] as bool? ?? false,
);
}
@@ -68,6 +77,163 @@ 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];
}
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: map['status'] as String? ?? 'Gemeldet',
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,
);
}
}
+604
View File
@@ -0,0 +1,604 @@
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) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 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.withOpacity(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.withOpacity(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(
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),
);
},
);
},
),
),
],
),
);
}
}
+711
View File
@@ -0,0 +1,711 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class AuthDialog extends StatefulWidget {
const AuthDialog({super.key});
static Future<void> show(BuildContext context) async {
await showDialog(
context: context,
builder: (ctx) => const AuthDialog(),
);
}
@override
State<AuthDialog> createState() => _AuthDialogState();
}
class _AuthDialogState extends State<AuthDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Login Controllers
final _loginEmailController = TextEditingController();
final _loginPasswordController = TextEditingController();
// Register Controllers
final _regFirstNameController = TextEditingController();
final _regLastNameController = TextEditingController();
final _regEmailController = TextEditingController();
final _regPasswordController = TextEditingController();
final _regConfirmPasswordController = TextEditingController();
// Code Controller
final _codeController = TextEditingController();
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);
}
@override
void dispose() {
_tabController.dispose();
_loginEmailController.dispose();
_loginPasswordController.dispose();
_regFirstNameController.dispose();
_regLastNameController.dispose();
_regEmailController.dispose();
_regPasswordController.dispose();
_regConfirmPasswordController.dispose();
_codeController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final email = _loginEmailController.text.trim();
final password = _loginPasswordController.text;
if (email.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Bitte E-Mail und Passwort eingeben.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signInWithEmail(email, password);
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erfolgreich angemeldet!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleRegister() async {
final firstName = _regFirstNameController.text.trim();
final lastName = _regLastNameController.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).');
return;
}
if (password != confirm) {
setState(() => _errorMessage = 'Die Passwörter stimmen nicht überein.');
return;
}
if (password.length < 6) {
setState(
() => _errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signUpWithEmail(
email: email,
password: password,
firstName: firstName,
lastName: lastName,
);
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!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleCodeUnlock() async {
final code = _codeController.text.trim();
if (code.isEmpty) {
setState(() => _errorMessage = 'Bitte den Zugangscode eingeben.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final success = await auth.verifyCommunityCode(code);
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bürgerzugang freigeschaltet!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage =
'Ungültiger Zugangscode. Bitte wende dich an das Bürgerforum.');
}
}
void _showPasswordResetDialog() {
final resetEmailController = TextEditingController(
text: _loginEmailController.text,
);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Passwort zurücksetzen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Gib deine E-Mail-Adresse ein. Wir senden dir einen Link, mit dem du ein neues Passwort festlegen kannst.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 12),
TextField(
controller: resetEmailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final email = resetEmailController.text.trim();
if (email.isEmpty) return;
final auth = Provider.of<AuthService>(context, listen: false);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(ctx);
final err = await auth.sendPasswordReset(email);
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(err ?? 'E-Mail zum Zurücksetzen wurde versendet!'),
backgroundColor: err == null ? Colors.green : Colors.red,
),
);
},
child: const Text('Link senden'),
),
],
),
);
}
@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,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 440,
maxHeight: screenHeight * 0.82,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
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,
),
),
],
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
TabBar(
controller: _tabController,
labelColor: Colors.blue.shade800,
indicatorColor: Colors.blue.shade800,
tabs: const [
Tab(text: 'Anmelden'),
Tab(text: 'Registrieren'),
],
),
if (_errorMessage != null) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
const Icon(Icons.error_outline,
color: Colors.red, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
fontSize: 12, color: Colors.red.shade900),
),
),
],
),
),
],
const SizedBox(height: 12),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Anmelden
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _loginEmailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _loginPasswordController,
obscureText: _obscureLoginPassword,
decoration: 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(),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _showPasswordResetDialog,
child: const Text(
'Passwort vergessen?',
style: TextStyle(fontSize: 12),
),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
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),
),
),
),
],
),
),
// Tab 2: Registrierung & Bürger-Code
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _regFirstNameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: '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,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
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(
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(),
),
),
const SizedBox(height: 10),
TextField(
controller: _regConfirmPasswordController,
obscureText: _obscureRegConfirmPassword,
decoration: 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(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text(
'Konto erstellen & E-Mail bestätigen',
style: TextStyle(fontSize: 14)),
),
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(
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,
),
),
),
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(
controller: _codeController,
decoration: const InputDecoration(
labelText: 'Bürger-Zugangscode',
prefixIcon: Icon(Icons.key_outlined),
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
isDense: true,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _handleCodeUnlock,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 16),
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
),
child: 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),
),
),
);
}
}
+269 -55
View File
@@ -1,5 +1,9 @@
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});
@@ -29,11 +33,12 @@ class _ContactScreenState extends State<ContactScreen> {
super.dispose();
}
bool _isValidEmail(String email) {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
Future<void> _submitForm(AuthService auth) async {
if (!auth.isRegisteredUser) {
await AuthDialog.show(context);
return;
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate() || _isSubmitting) return;
setState(() {
@@ -41,19 +46,27 @@ 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: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
firstName: firstName,
lastName: lastName,
email: email,
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();
@@ -82,28 +95,130 @@ 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.all(16.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (auth.isAdmin) ...[
Card(
color: Colors.blue.shade50,
color: Colors.indigo.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(14.0),
child: Row(
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: [
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
SizedBox(width: 12),
Expanded(
child: Text(
'Hier können Sie auch ohne Anmeldung direkt mit der Bürgerliste / Gemeindevertretung in Kontakt treten.',
style: TextStyle(fontSize: 13),
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),
),
),
),
],
@@ -111,33 +226,102 @@ class _ContactScreenState extends State<ContactScreen> {
),
),
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),
child: Row(
children: [
Icon(Icons.verified_user_outlined,
color: Colors.blue.shade700, size: 24),
const SizedBox(width: 10),
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),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Vorname *',
border: OutlineInputBorder(),
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Vorname eingeben'
decoration: 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(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nachname *',
border: OutlineInputBorder(),
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Nachname eingeben'
decoration: 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(),
),
),
),
],
@@ -145,41 +329,58 @@ class _ContactScreenState extends State<ContactScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
readOnly: true,
style: TextStyle(
color: isRegistered ? Colors.black87 : Colors.grey.shade600,
fontWeight:
isRegistered ? FontWeight.w500 : FontWeight.normal,
),
decoration: InputDecoration(
labelText: 'E-Mail-Adresse *',
border: OutlineInputBorder(),
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(),
),
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,
decoration: const InputDecoration(
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Rückrufnummer (optional)',
border: OutlineInputBorder(),
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,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _messageController,
maxLines: 5,
decoration: const InputDecoration(
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Ihr Anliegen / Ihre Nachricht *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
validator: (value) => (value == null || value.trim().isEmpty)
? 'Bitte Ihre Nachricht eingeben'
: null,
),
@@ -197,16 +398,29 @@ class _ContactScreenState extends State<ContactScreen> {
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _submitForm,
onPressed: !isRegistered
? () => AuthDialog.show(context)
: (_isSubmitting ? null : () => _submitForm(auth)),
style: ElevatedButton.styleFrom(
backgroundColor: isRegistered
? Colors.blue.shade800
: Colors.grey.shade700,
foregroundColor: Colors.white,
),
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.send),
: Icon(!isRegistered ? Icons.login : Icons.send),
label: Text(
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
!isRegistered
? 'Anmelden zum Absenden'
: (_isSubmitting
? 'Wird gesendet...'
: 'Nachricht absenden'),
style: const TextStyle(fontSize: 16),
),
),
+36
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/post_item.dart';
import '../services/pdf_service.dart';
import 'pdf_viewer_screen.dart';
class DetailScreen extends StatelessWidget {
final PostItem item;
@@ -123,6 +124,41 @@ class DetailScreen extends StatelessWidget {
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) ...[
+3 -3
View File
@@ -16,11 +16,11 @@ class _HomeScreenState extends State<HomeScreen> {
final List<Widget> _pages = const [
PostListScreen(
collectionName: 'News',
title: 'Nachrichten & Aktuelles',
title: 'Bürgerforum Aktuelles',
),
PostListScreen(
collectionName: 'Issues',
title: 'Mängel & Bürgeranliegen',
title: 'Mängel & Anliegen',
),
PostListScreen(
collectionName: 'CitizensForum',
@@ -75,7 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
activeIcon: Icon(Icons.info),
label: 'Informationen',
label: 'Bürgerforum',
),
],
),
+219 -126
View File
@@ -1,115 +1,116 @@
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 StatefulWidget {
class InfoScreen extends StatelessWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
final TextEditingController _codeController = TextEditingController();
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
void _showUnlockDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Gemeinde-Zugang freischalten'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Geben Sie das Passwort bzw. den Zugangscode der Marktgemeinde Oggau ein, um Mängel zu melden und im Bürgerforum mitzuwirken.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
TextField(
controller: _codeController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Gemeinde-Passwort',
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final auth = Provider.of<AuthService>(context, listen: false);
final success =
await auth.verifyCommunityCode(_codeController.text);
if (!dialogCtx.mounted) return;
Navigator.pop(dialogCtx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Zugang erfolgreich freigeschaltet!'
: 'Ungültiges Passwort. Bitte kontaktieren Sie das Gemeindeamt.'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
},
child: const Text('Freischalten'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Bürgerforum Oggau'),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
children: [
// Community Access Card
// Bürgerforum Logo & Branding Header
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/logo.png',
height: 72,
fit: BoxFit.contain,
),
),
const SizedBox(height: 8),
const Text(
'Bürgerforum Oggau',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Unabhängige Bürgerliste für Oggau am Neusiedler See',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 8),
// Bürgerforum Access & Account Card
Card(
color: auth.isCommunityUnlocked ? Colors.green.shade50 : Colors.blue.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: auth.isAdmin
? Colors.indigo.shade50
: auth.isRegisteredUser
? Colors.green.shade50
: auth.isCommunityUnlocked
? Colors.teal.shade50
: Colors.blue.shade50,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
padding: const EdgeInsets.all(14.0),
child: Column(
children: [
Row(
children: [
Icon(
auth.isCommunityUnlocked
auth.isAdmin
? Icons.admin_panel_settings
: auth.isRegisteredUser
? Icons.account_circle
: auth.isCommunityUnlocked
? Icons.verified_user
: Icons.lock_outline,
color: auth.isCommunityUnlocked ? Colors.green : Colors.blue,
color: auth.isAdmin
? Colors.indigo.shade700
: auth.isRegisteredUser
? Colors.green.shade700
: auth.isCommunityUnlocked
? Colors.teal.shade800
: Colors.blue.shade800,
size: 36,
),
const SizedBox(width: 16),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isCommunityUnlocked
? 'Bürgerzugang: Aktiv'
auth.isAdmin
? 'Administrator'
: auth.isRegisteredUser
? (auth.displayName?.isNotEmpty == true
? auth.displayName!
: 'Bürger-Konto')
: auth.isCommunityUnlocked
? 'Bürgerzugang: Aktiv (Code)'
: 'Gastmodus (Nur Lesen)',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 16),
fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 2),
Text(
auth.isCommunityUnlocked
? 'Sie können Mängel und Ideen einreichen.'
: 'Gemeinde-Passwort eingeben zum Freischalten.',
auth.isAdmin
? '${auth.email ?? "Angemeldet"} (Admin-Rechte aktiv)'
: auth.isRegisteredUser
? auth.email ?? 'Angemeldet'
: auth.isCommunityUnlocked
? 'Mängel & Ideen freigeschaltet'
: 'Anmelden oder Code eingeben zum Mitwirken.',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade700),
),
@@ -117,24 +118,127 @@ class _InfoScreenState extends State<InfoScreen> {
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
),
onPressed: () {
if (auth.isCommunityUnlocked) {
if (auth.isRegisteredUser || auth.isCommunityUnlocked) {
auth.lockCommunityAccess();
} else {
_showUnlockDialog(context);
AuthDialog.show(context);
}
},
child: Text(auth.isCommunityUnlocked ? 'Sperren' : 'Anmelden'),
child: Text(
(auth.isRegisteredUser || auth.isCommunityUnlocked)
? 'Abmelden'
: 'Anmelden',
),
),
],
),
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(
onTap: () => AuthDialog.show(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add,
size: 16, color: Colors.blue.shade800),
const SizedBox(width: 6),
Text(
'Persönliches Konto anlegen für dauerhaften Login',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blue.shade800,
),
),
],
),
),
),
const SizedBox(height: 16),
],
],
),
),
),
const SizedBox(height: 14),
// Gemeinde Info Header
// About Bürgerforum Oggau
Card(
elevation: 2,
elevation: 1.5,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.campaign, color: Colors.blue.shade800, size: 24),
const SizedBox(width: 8),
const Text(
'Über das Bürgerforum Oggau',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 10),
const Text(
'Das Bürgerforum Oggau ist eine unabhängige Bürgerliste für unsere Marktgemeinde Oggau am Neusiedler See.\n\n'
'Wir stehen für Bürgernähe, Transparenz und eine zukunftsorientierte Gemeindepolitik. '
'Mit dieser App (unserem digitalen „Gemeindetrommler“) wollen wir allen Bürgerinnen und Bürgern eine unkomplizierte Möglichkeit geben, Mängel im Ort aufzuzeigen, Ideen einzubringen und sich zu informieren.',
style: TextStyle(fontSize: 13, height: 1.45),
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.group, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Text(
'Gemeindevertretung & Team Bürgerforum',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey.shade800),
),
],
),
],
),
),
),
const SizedBox(height: 14),
// Gemeinde Bürgerservice (Nützliche Kontakte)
Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
@@ -142,33 +246,29 @@ class _InfoScreenState extends State<InfoScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Marktgemeinde Oggau am Neusiedler See',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
'Gemeindeamt & Bürgerservice Oggau',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Hauptstraße 52, 7063 Oggau'),
Text('Telefon: +43 2685 7201'),
Text('E-Mail: post@oggau.bgld.gv.at'),
Text('Web: www.oggau.at'),
SizedBox(height: 12),
Divider(),
Text('Adresse: Hauptstraße 52, 7063 Oggau', style: TextStyle(fontSize: 13)),
Text('Telefon: +43 2685 7201', style: TextStyle(fontSize: 13)),
Text('Web: www.oggau.at', style: TextStyle(fontSize: 13)),
SizedBox(height: 8),
Text(
'Parteienverkehr / Öffnungszeiten:',
style: TextStyle(fontWeight: FontWeight.bold),
'Öffnungszeiten Gemeindeamt:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text('Montag bis Freitag: 08:00 – 12:00 Uhr'),
Text('Dienstag zusätzlich: 13:00 – 17:00 Uhr'),
Text('Mo – Fr: 08:00 – 12:00 Uhr | Di zusätzlich: 13:00 – 17:00 Uhr',
style: TextStyle(fontSize: 12)),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
// Notrufnummern
// Wichtige Notrufnummern
Card(
elevation: 2,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
@@ -177,9 +277,9 @@ class _InfoScreenState extends State<InfoScreen> {
children: [
Text(
'Wichtige Notrufnummern',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.red),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.red),
),
SizedBox(height: 12),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -187,7 +287,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -195,7 +295,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -203,7 +303,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -211,19 +311,11 @@ class _InfoScreenState extends State<InfoScreen> {
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Ärztefunkdienst'),
Text('141', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
// Impressum
Card(
@@ -235,22 +327,23 @@ class _InfoScreenState extends State<InfoScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Impressum & Rechtliches',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
'Impressum',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
SizedBox(height: 6),
Text(
'Medieninhaber & Herausgeber:\n'
'Marktgemeinde Oggau am Neusiedler See\n'
'Hauptstraße 52, A-7063 Oggau\n\n'
'Erstellt als Gemeinde-Informationssystem für Bürgerinnen und Bürger.\n'
'Alle Rechte vorbehalten.',
style: TextStyle(fontSize: 12, color: Colors.black87),
'Bürgerforum Oggau (Unabhängige Bürgerliste Oggau)\n'
'7063 Oggau am Neusiedler See\n\n'
'Zweck der App:\n'
'Informations- und Mitmach-Plattform für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.',
style: TextStyle(fontSize: 12, color: Colors.black87, height: 1.4),
),
],
),
),
),
const SizedBox(height: 16),
],
),
);
+283
View File
@@ -0,0 +1,283 @@
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> {
final TransformationController _transformationController =
TransformationController();
double _zoomLevel = 1.0;
Uint8List? _cachedBytes;
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}
void _zoomIn() {
setState(() {
_zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.identity()..scale(_zoomLevel);
});
}
void _zoomOut() {
setState(() {
_zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.identity()..scale(_zoomLevel);
});
}
void _resetZoom() {
setState(() {
_zoomLevel = 1.0;
_transformationController.value = Matrix4.identity();
});
}
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: Stack(
children: [
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 InteractiveViewer(
transformationController: _transformationController,
minScale: 0.8,
maxScale: 5.0,
panEnabled: true,
scaleEnabled: true,
onInteractionEnd: (details) {
final scale =
_transformationController.value.getMaxScaleOnAxis();
if (scale != _zoomLevel) {
setState(() {
_zoomLevel = scale;
});
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 16.0),
child: Center(
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.withOpacity(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,
),
),
),
],
],
),
),
),
);
},
),
// Floating Zoom Controls Overlay
Positioned(
right: 16,
bottom: 24,
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24)),
color: Colors.white.withOpacity(0.92),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, color: Colors.black87),
tooltip: 'Vergrößern (+)',
onPressed: _zoomIn,
),
InkWell(
onTap: _resetZoom,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 6),
child: Text(
'${(_zoomLevel * 100).round()}%',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
IconButton(
icon: const Icon(Icons.zoom_out, color: Colors.black87),
tooltip: 'Verkleinern (-)',
onPressed: _zoomOut,
),
IconButton(
icon: const Icon(Icons.restart_alt, size: 20, color: Colors.grey),
tooltip: 'Zoom zurücksetzen (1:1)',
onPressed: _resetZoom,
),
],
),
),
),
),
],
),
);
}
}
+318 -46
View File
@@ -1,16 +1,19 @@
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';
import '../services/storage_service.dart';
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;
@@ -29,12 +32,44 @@ 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();
@@ -58,9 +93,28 @@ 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;
@@ -75,21 +129,29 @@ class _PostListScreenState extends State<PostListScreen> {
}
}
void _onAddNewPressed(BuildContext context) {
Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(context, listen: false);
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
// 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(
'Bitte schalten Sie den Zugang unter "Informationen" mit dem Gemeinde-Passwort frei.',
),
backgroundColor: Colors.orange,
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;
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
return;
}
}
_clearSelection();
if (widget.collectionName == 'Issues') {
@@ -114,7 +176,8 @@ class _PostListScreenState extends State<PostListScreen> {
@override
Widget build(BuildContext context) {
final currentUser = FirebaseAuth.instance.currentUser;
final auth = Provider.of<AuthService>(context);
final currentUser = auth.user;
return Scaffold(
appBar: AppBar(
@@ -124,13 +187,32 @@ class _PostListScreenState extends State<PostListScreen> {
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Suche nach ${widget.title}...',
hintText: 'Suche in ${widget.title}...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: Text(widget.title),
: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/images/logo.png',
height: 26,
fit: BoxFit.contain,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.title,
overflow: TextOverflow.ellipsis,
),
),
],
),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
@@ -164,7 +246,86 @@ class _PostListScreenState extends State<PostListScreen> {
],
],
),
body: StreamBuilder<List<PostItem>>(
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>>(
stream: _firestoreService.getPostsStream(widget.collectionName),
builder: (context, snapshot) {
if (snapshot.hasError) {
@@ -176,11 +337,27 @@ class _PostListScreenState extends State<PostListScreen> {
);
}
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState == ConnectionState.waiting &&
widget.collectionName != 'News') {
return const Center(child: CircularProgressIndicator());
}
final allItems = snapshot.data ?? [];
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 filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
@@ -224,29 +401,13 @@ 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 formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
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(
Widget? leadingWidget;
if (item.imageUrl.isNotEmpty) {
leadingWidget = Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -286,12 +447,94 @@ class _PostListScreenState extends State<PostListScreen> {
),
),
],
)
: isSelected
? const CircleAvatar(
);
} 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(
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),
),
);
}
},
onLongPress: () => _toggleSelection(item),
leading: leadingWidget,
trailing: item.isAussendung
? Icon(Icons.arrow_forward_ios,
size: 14, color: Colors.grey.shade400)
: null,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -303,7 +546,7 @@ class _PostListScreenState extends State<PostListScreen> {
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
@@ -324,7 +567,8 @@ class _PostListScreenState extends State<PostListScreen> {
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!,
@@ -338,6 +582,40 @@ 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,
@@ -351,7 +629,7 @@ class _PostListScreenState extends State<PostListScreen> {
),
);
if (isAuthor) {
if (canDelete) {
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
@@ -392,12 +670,6 @@ class _PostListScreenState extends State<PostListScreen> {
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onAddNewPressed(context),
tooltip: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
}
+552
View File
@@ -0,0 +1,552 @@
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(
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
@@ -0,0 +1,121 @@
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: ');
}
}
}
+279 -6
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthService extends ChangeNotifier {
@@ -12,41 +13,172 @@ class AuthService extends ChangeNotifier {
User? get user => _user;
bool get isAuthenticated => _user != null;
bool get isCommunityUnlocked => _isCommunityUnlocked;
bool get isAnonymous => _user?.isAnonymous ?? true;
bool get isRegisteredUser => _user != null && !_user!.isAnonymous;
bool get isCommunityUnlocked => _isCommunityUnlocked || isRegisteredUser;
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) {
_auth.authStateChanges().listen((User? user) async {
_user = user;
await _checkAdminClaim(user);
if (user != null && !user.isAnonymous) {
await _syncUserProfile(user);
}
notifyListeners();
});
// Automatically ensure anonymous login if unlocked
if (_user == null && _isCommunityUnlocked) {
await signInAnonymously();
}
}
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();
// Accept default code 'oggau' or any custom community password
if (cleanCode == defaultCommunityCode || cleanCode == 'oggau2024' || cleanCode == 'oggau7063') {
if (cleanCode == defaultCommunityCode ||
cleanCode == 'oggau2024' ||
cleanCode == 'oggau7063') {
_isCommunityUnlocked = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyUnlocked, true);
if (_user == null) {
await signInAnonymously();
}
notifyListeners();
return true;
}
return false;
}
/// Lock community access and log out
Future<void> lockCommunityAccess() async {
_isCommunityUnlocked = false;
final prefs = await SharedPreferences.getInstance();
@@ -55,6 +187,118 @@ class AuthService extends ChangeNotifier {
notifyListeners();
}
/// Sign in with Email and Password
Future<String?> signInWithEmail(String email, String password) async {
try {
final credential = await _auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
_user = credential.user;
await _checkAdminClaim(_user);
if (_user != null) {
await _syncUserProfile(_user!);
}
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
}
}
/// Sign up with Email, Password, First Name, and Last Name
Future<String?> signUpWithEmail({
required String email,
required String password,
required String firstName,
required String lastName,
}) async {
try {
final credential = await _auth.createUserWithEmailAndPassword(
email: email.trim(),
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');
}
}
}
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
}
}
/// 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 {
await _auth.sendPasswordResetEmail(email: email.trim());
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Fehler beim Senden der E-Mail: $e';
}
}
/// Anonymous login
Future<void> signInAnonymously() async {
try {
await _auth.signInAnonymously();
@@ -65,8 +309,15 @@ 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) {
@@ -74,4 +325,26 @@ class AuthService extends ChangeNotifier {
}
}
}
String _getLocalizedErrorMessage(String code) {
switch (code) {
case 'user-not-found':
return 'Kein Benutzer mit dieser E-Mail-Adresse gefunden.';
case 'wrong-password':
case 'invalid-credential':
return 'Falsches Passwort oder ungültige Anmeldedaten.';
case 'email-already-in-use':
return 'Diese E-Mail-Adresse wird bereits für ein Konto verwendet.';
case 'invalid-email':
return 'Bitte gib eine gültige E-Mail-Adresse ein.';
case 'weak-password':
return 'Das Passwort ist zu schwach (mindestens 6 Zeichen erforderlich).';
case 'too-many-requests':
return 'Zu viele fehlgeschlagene Versuche. Bitte warte kurz.';
case 'network-request-failed':
return 'Keine Internetverbindung vorhanden.';
default:
return 'Authentifizierungsfehler ($code).';
}
}
}
+136
View File
@@ -50,6 +50,7 @@ class FirestoreService {
required String email,
String? phone,
required String message,
String? authorUid,
}) async {
await _firestore.collection('ContactMessages').add({
'firstName': firstName,
@@ -57,8 +58,143 @@ 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,
if (emailVerified != null) 'emailVerified': emailVerified,
if (isAdmin != null) '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));
}
}
+64
View File
@@ -829,6 +829,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
uuid:
dependency: transitive
description:
+4 -5
View File
@@ -21,7 +21,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 0.0.1
environment:
sdk: '>=2.18.2 <3.0.0'
sdk: '>=3.0.0 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@@ -54,6 +54,7 @@ dependencies:
provider: ^6.0.5
http: ^1.2.0
shared_preferences: ^2.2.2
url_launcher: ^6.3.1
dev_dependencies:
@@ -78,10 +79,8 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/images/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
+57
View File
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,162 @@
#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);
}
+24
View File
@@ -0,0 +1,24 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// Fotos von News, Mängeln und Ideen
match /{collection}/{userId}/{fileName} {
// Jeder darf Bilder in der App laden/ansehen
allow read: if true;
// Nur angemeldete Nutzer dürfen Fotos hochladen (max. 10 MB, nur Bilder)
allow create, update: if request.auth != null
&& 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)))
);
}
}
}
+125
View File
@@ -41,5 +41,130 @@ 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);
});
});
}