Compare commits

...
45 Commits
Author SHA1 Message Date
Nizz05 2b34d4a16f ci: add GitHub Actions iOS IPA build workflow on macos-14
Build Android APK / Build Release APKs (push) Successful in 13m58s
2026-09-15 22:42:39 +02:00
Nizz05 c1cdd54964 fix(test): use const declarations in widget_test to satisfy linter 2026-09-15 22:34:50 +02:00
Nizz05 fc95c6e6f7 docs: update README with current features, in-app user management, and security architecture 2026-09-15 22:31:02 +02:00
Nizz05 75f1610cde feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management 2026-09-15 22:06:36 +02:00
Nizz05 9a823cbcdb feat: integrate WordPress PDF Aussendungen with in-app reader into News 2026-09-14 21:32:03 +02:00
Nizz05 e512c081e3 security(storage): strictly restrict delete to photo author or verified firestore admin 2026-09-14 20:59:40 +02:00
Nizz05 cd3b71736d fix(admin): make storage delete resilient and allow authenticated deletion in storage rules 2026-09-14 20:53:41 +02:00
Nizz05 3df2e4c5c8 feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool 2026-09-14 11:47:58 +02:00
Nizz05 877862aaf2 refactor(admin): remove hardcoded email and dynamically resolve admin privileges via Firestore Admins collection 2026-09-14 11:44:04 +02:00
Nizz05 c3513e85e5 feat(admin): grant full admin permissions in app and security rules to nicolas@gruber.info 2026-09-14 11:35:40 +02:00
Nizz05 e64b7a085c feat(security & admin): update firestore and storage rules with admin token check and enable admin deletion in app 2026-09-14 11:33:17 +02:00
Nizz05 2e9abac25f feat(auth & contact): separate Vorname/Nachname in registration, pre-fill read-only profile in contact, and restrict contact messages to registered users 2026-09-14 11:29:12 +02:00
Nizz05 010904a48a refactor(auth): simplify to 2 tabs and embed Buerger-Code under Google in registration 2026-09-14 11:12:35 +02:00
Nizz05 a4755f6d53 feat(auth): integrate Google Sign-In UI with custom button and friendly preparation notice 2026-09-14 10:33:07 +02:00
Nizz05 80ff6cd95d fix(auth): make auth dialog responsive and scrollable without overflow, add email verification on signup, and add password toggles 2026-09-14 10:26:44 +02:00
Nizz05 1420d0a113 feat(ci): restore universal APK, set 7-day retention for artifacts, and clean workspace to save storage 2026-09-14 10:07:15 +02:00
Nizz05 adc65e056d feat(ci): build only 3 split APKs, upload individual artifacts, and publish unzipped APKs to Gitea Releases 2026-09-14 09:55:04 +02:00
Nizz05 ab9b22fc4a fix(ci): use actions/upload-artifact@v3 for Gitea compatibility 2026-09-14 09:31:34 +02:00
Nizz05 b6baaf67e8 feat(build): migrate to declarative Gradle plugin DSL and bump Dart SDK constraint to >=3.0.0 <4.0.0 2026-09-14 09:28:36 +02:00
Nizz05 2917f93323 fix(build): route subprojects buildDir to ../build so Flutter CLI finds generated APKs 2026-09-14 09:17:01 +02:00
Nizz05 7f4218b0a2 fix(ci): strip LD_PRELOAD on child QEMU execution and remove rename watcher 2026-09-14 00:27:57 +02:00
Nizz05 8be95162d2 fix(ci): delegate execvp and posix_spawnp to real glibc symbols for PATH resolution 2026-09-14 00:17:36 +02:00
Nizz05 565a7d1347 fix(ci): restore string literal quotes in qemu_intercept.c 2026-09-14 00:16:33 +02:00
Nizz05 e44fcf1223 fix(ci): extract QEMU interceptor to dedicated C file to fix YAML indentation 2026-09-14 00:15:20 +02:00
Nizz05 f446fd044b fix(ci): inject LD_PRELOAD interceptor to redirect all x86_64 ELF executions to QEMU 2026-09-14 00:13:23 +02:00
Nizz05 9226ce4b33 fix(ci): direct AAPT2 userspace wrapping with QEMU to bypass host binfmt and FEX 2026-09-14 00:03:12 +02:00
Nizz05 1acbeddc79 fix(ci): mount binfmt_misc and cleanly register QEMU x86_64 for AAPT2 on ARM64 2026-09-13 23:51:32 +02:00
Nizz05 46dc3368c6 fix(ci): unregister host FEX from binfmt and wrap FEXInterpreter with QEMU for AAPT2 2026-09-13 23:34:50 +02:00
Nizz05 b443213224 ci: enable privileged container mode and binfmt support for aapt2 execution 2026-09-13 23:07:19 +02:00
Nizz05 ca59dff4db fix(ci): install x86_64 glibc cross libraries for qemu user emulation 2026-09-13 22:44:53 +02:00
Nizz05 f6f3a5ff06 fix(ci): correct YAML indentation in workflow file 2026-09-13 22:39:19 +02:00
Nizz05 ac395dad07 fix(ci): bridge Flutter linux-arm64 gen_snapshot using qemu wrapper 2026-09-13 22:30:22 +02:00
Nizz05 3c8ab00991 fix(android): remove hardcoded Windows java.home from repo gradle.properties 2026-09-13 22:20:56 +02:00
Nizz05 5969d23a98 fix(ci): track gradlew and wrapper jar, ensure executable permissions 2026-09-13 22:11:52 +02:00
Nizz05 6b9703d79c fix(ci): install official Node.js 20 binary for ES2022 syntax support 2026-09-13 22:06:48 +02:00
Nizz05 91c2e9659b fix(ci): install nodejs in build container before checkout action 2026-09-13 22:03:22 +02:00
Nizz05 8b4916246b feat(ci): add docker-compose setup for Gitea act_runner and local build script 2026-09-13 21:53:16 +02:00
Nizz05 0aa5fda335 ci: add Gitea Actions workflow to build and upload APK artifacts 2026-09-13 21:44:01 +02:00
Nizz05 3b5531a9d6 feat(auth): add AuthDialog with registration, login, code-unlock and wire into UI 2026-09-13 21:26:57 +02:00
Nizz05 9a8ee35fc2 Add production Firestore and Storage security rules 2026-09-13 21:10:39 +02:00
Nizz05 50a6025937 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
Nizz05 588309649c Fix Windows cross-drive buildDir issue (H: vs C:) in android/build.gradle 2026-09-13 15:55:55 +02:00
Nizz05 e9b91e5452 Modernize buildFeatures buildConfig setting and clean deprecated gradle property 2026-09-13 15:45:45 +02:00
Nizz05 d84b0002ac Fix Android NDK version in app/build.gradle to ensure clean build in Android Studio 2026-09-13 15:40:16 +02:00
Nizz05 e6ce9c5012 Refactor architecture, fix all linter issues, and implement specification features (5 tabs, issue dropdowns, contact, info, search, pdf) 2026-09-13 15:30:36 +02:00
53 changed files with 7475 additions and 1424 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
+57
View File
@@ -0,0 +1,57 @@
name: Build iOS IPA
on:
push:
branches:
- main
- master
tags:
- 'v*'
- 'latest'
workflow_dispatch:
jobs:
build-ios:
name: Build iOS IPA
runs-on: macos-14
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.3'
channel: 'stable'
cache: true
- name: Inspect Environment
run: |
flutter --version
xcodebuild -version
pod --version
- name: Fetch Dependencies
run: flutter pub get
- name: Install CocoaPods Pods
run: |
cd ios
pod install --repo-update
cd ..
- name: Build iOS (Unsigned IPA)
run: |
flutter build ios --release --no-codesign
mkdir -p build/ios/iphoneos/Payload
cp -r build/ios/iphoneos/Runner.app build/ios/iphoneos/Payload/
cd build/ios/iphoneos
zip -r oggauer-gemeindetrommler.ipa Payload
- name: Upload IPA Artifact
uses: actions/upload-artifact@v4
with:
name: oggauer-gemeindetrommler-ios
path: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
retention-days: 7
+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/
+171 -70
View File
@@ -1,92 +1,193 @@
# DemoApp_1
# Oggauer Gemeindetrommler 🥁
Die **Oggauer Gemeindetrommler App** ist die offizielle Smartphone-Anwendung (Flutter für Android & iOS) des **Bürgerforums Oggau** für alle Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.
Die App vereint aktuelle Gemeinde-Nachrichten, offizielle PDF-Aussendungen des Bürgerforums, einen bürgernahen Mängelmelder mit Foto-Upload, eine Ideenplattform sowie einen direkten Kontaktkanal mit integriertem Admin-Posteingang und vollständiger In-App-Benutzerverwaltung.
## Getting started
---
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
## 📱 Funktionsumfang & Module
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
### 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.
## Add your files
### 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.
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
### 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.
```
cd existing_repo
git remote add origin https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1.git
git branch -M main
git push -uf origin main
### 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.
### 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.
---
## 🔒 Sicherheitsarchitektur (Backend & Frontend)
Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sicherheitskonzept gewährleistet:
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 `>=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/
├── 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
```
## Integrate with your tools
---
- [ ] [Set up project integrations](https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1/-/settings/integrations)
## 🤖 CI/CD Pipeline (Gitea Actions)
## Collaborate with your team
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
- **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.
## Test and Deploy
---
Use the built-in continuous integration in GitLab.
## 🛠️ Einrichtung & Lokale Ausführung
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
### 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 laden
flutter pub get
# Editing this README
# 2. Statische Code-Analyse ausführen
flutter analyze
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
# 3. Unit-Tests ausführen
flutter test
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
# 4. App im Entwicklungsmodus starten
flutter run
```
## Name
Choose a self-explaining name for your project.
---
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
## 📄 Lizenz & Urheberschaft
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
+13 -16
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,17 +23,10 @@ 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
ndkVersion flutter.ndkVersion
ndkVersion "26.1.10909125"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -42,6 +37,10 @@ android {
jvmTarget = '17'
}
buildFeatures {
buildConfig = true
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
@@ -71,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>
+3 -15
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 {
@@ -24,6 +9,8 @@ allprojects {
rootProject.buildDir = '../build'
subprojects {
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 -3
View File
@@ -1,5 +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
android.defaults.buildfeatures.buildconfig=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'
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()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
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"
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
-358
View File
@@ -1,358 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:image/image.dart' as img;
class AddScreen extends StatefulWidget {
final int initialIndex;
const AddScreen({Key? key, required this.initialIndex}) : super(key: key);
@override
_AddScreenState createState() => _AddScreenState();
}
class _AddScreenState extends State<AddScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
File? _imageFile;
bool _uploading = false;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
_selectedIndex = widget.initialIndex;
}
Future<bool> isFileSizeValid(File file, int maxSizeInMB) async {
final fileSize = await file.length();
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
return fileSize <= maxSizeInBytes;
}
Future<File> _cropImage(File imageFile) async {
final originalImage = img.decodeImage(await imageFile.readAsBytes());
if (originalImage != null) {
int originSize = min(originalImage.width, originalImage.height);
final croppedImage = img.copyCrop(originalImage,
x: (originalImage.width - originSize) ~/ 2,
y: (originalImage.height - originSize) ~/ 2,
width: originSize,
height: originSize);
final newPath =
imageFile.path.substring(0, imageFile.path.lastIndexOf('/'));
final newFile = File('$newPath/cropped.jpg');
await newFile.writeAsBytes(img.encodePng(croppedImage));
return newFile;
} else {
throw Exception('Unable to decode image file.');
}
}
Future<void> _getImage(ImageSource source) async {
final pickedFile = await ImagePicker().pickImage(source: source);
if (pickedFile != null) {
final tempFile = File(pickedFile.path);
final bool isValid = await isFileSizeValid(tempFile, 10);
if (isValid) {
// Bildinformationen lesen
final originalImage = img.decodeImage(await tempFile.readAsBytes());
if (originalImage != null) {
if (originalImage.width == originalImage.height) {
// Wenn das Bild bereits im 1:1-Format ist, überspringen Sie das Zuschneiden
if (mounted) {
setState(() {
_imageFile = tempFile;
print("1:1");
});
}
} else {
// Wenn das Bild nicht im 1:1-Format ist, schneiden Sie es zu
File croppedFile = await _cropImage(tempFile);
if (mounted) {
setState(() {
_imageFile = croppedFile;
});
print("decode");
}
}
} else {
throw Exception('Unable to decode image file.');
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Die Dateigröße darf maximal 10 MB betragen.')),
);
}
}
}
Future<void> addNewElementToFirestore(
String title, String description, File imageFile,
{required Function(bool) onComplete}) async {
String collectionName;
if (_selectedIndex == 0) {
collectionName = "CitizensForum";
} else if (_selectedIndex == 1) {
collectionName = "News";
} else {
collectionName = "Issues";
}
String imageUrl =
await uploadImageToFirebaseStorage(imageFile, collectionName);
String? userId = FirebaseAuth.instance.currentUser?.uid; // get the user ID
try {
await FirebaseFirestore.instance.collection(collectionName).add({
"title": title,
"description": description,
"imageUrl": imageUrl,
"createdDate": DateTime.now(),
"author_uid": userId, // add the user ID to the document
});
onComplete(true);
} catch (e) {
onComplete(false);
}
}
Future<String> uploadImageToFirebaseStorage(
File imageFile, String collectionName) async {
String? userId = FirebaseAuth.instance.currentUser?.uid;
if (userId != null) {
Reference ref = FirebaseStorage.instance
.ref()
.child("$collectionName/$userId/${DateTime.now().toString()}");
UploadTask uploadTask = ref.putFile(imageFile);
return await (await uploadTask).ref.getDownloadURL();
} else {
throw Exception('Cannot upload without user ID');
}
}
@override
Widget build(BuildContext context) {
String appBarTitle = '';
double keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
if (_selectedIndex == 0) {
appBarTitle = 'Neue BürgerForum Information';
} else if (_selectedIndex == 1) {
appBarTitle = 'Neue Neuigkeit';
} else if (_selectedIndex == 2) {
appBarTitle = 'Neuer Mängel';
}
return Scaffold(
appBar: AppBar(
title: Text(appBarTitle),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
_titleController.clear();
_descriptionController.clear();
setState(() {
_imageFile = null;
});
},
),
),
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(Icons.check),
onPressed: () async {
if (_formKey.currentState!.validate() &&
_imageFile != null &&
!_uploading) {
setState(() {
_uploading = true;
});
Navigator.pop(
context); // Fenster direkt schließen, nachdem der Upload-Button gedrückt wurde.
await addNewElementToFirestore(
_titleController.text,
_descriptionController.text,
_imageFile!, onComplete: (bool success) {
if (mounted) {
setState(() {
_uploading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Upload erfolgreich.'
: 'Upload fehlgeschlagen.'),
),
);
}
});
}
},
),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: InputDecoration(
labelText: 'Titel',
filled: true,
fillColor: Colors.grey[200],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16.0),
),
),
controller: _titleController,
validator: (value) {
if (value!.isEmpty) {
return 'Bitte geben Sie einen Titel ein.';
}
return null;
},
),
const SizedBox(height: 16.0),
TextFormField(
decoration: InputDecoration(
labelText: 'Beschreibung',
filled: true,
fillColor: Colors.grey[200],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16.0),
),
),
controller: _descriptionController,
validator: (value) {
if (value!.isEmpty) {
return 'Bitte geben Sie eine Beschreibung ein.';
}
return null;
},
),
const SizedBox(height: 16.0),
_imageFile != null
? Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
double imageHeight =
max(0, constraints.maxHeight - keyboardHeight);
return Container(
height: imageHeight,
child: Image.file(_imageFile!),
);
},
),
)
: Container(
height: 200.0,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(16.0),
),
child: Center(
child: TextButton(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 60,
padding: const EdgeInsets.symmetric(
horizontal: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text(
'Bild hinzufügen',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.pop(context);
},
),
],
),
),
Center(
child: SizedBox(
height: 80,
child: ListTile(
leading: const Icon(Icons.photo_library,
size: 40),
title: const Text(
'Galerie auswählen',
style: TextStyle(fontSize: 20),
),
onTap: () {
Navigator.pop(context);
_getImage(ImageSource.gallery);
},
),
),
),
Center(
child: SizedBox(
height: 80,
child: ListTile(
leading: const Icon(Icons.camera_alt,
size: 40),
title: const Text(
'Kamera verwenden',
style: TextStyle(fontSize: 20),
),
onTap: () {
Navigator.pop(context);
_getImage(ImageSource.camera);
},
),
),
),
],
),
);
},
child: Text(
'Bild hinzufügen',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
),
);
}
}
-130
View File
@@ -1,130 +0,0 @@
import 'dart:typed_data';
import 'package:pdf/widgets.dart' as pdfWidgets;
import 'package:printing/printing.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:cached_network_image/cached_network_image.dart';
class DetailPage extends StatelessWidget {
final String title;
final String datum;
final String description;
final String imageUrl;
const DetailPage({
Key? key,
required this.title,
required this.datum,
required this.description,
required this.imageUrl,
}) : super(key: key);
Future<void> _printPdf() async {
final pdf = pdfWidgets.Document();
final image = await _loadNetworkImage(imageUrl);
pdf.addPage(
pdfWidgets.MultiPage(
build: (context) => [
pdfWidgets.Center(
child: pdfWidgets.SizedBox(
width: double.infinity, // Die Breite auf die maximal mögliche Breite setzen
child: pdfWidgets.AspectRatio(
aspectRatio: 1, // Sie können das Seitenverhältnis entsprechend Ihrem Bild anpassen
child: pdfWidgets.Image(image),
),
),
),
pdfWidgets.Text(
title,
style: pdfWidgets.TextStyle(
fontSize: 20,
fontWeight: pdfWidgets.FontWeight.bold // Titel fett
)
),
pdfWidgets.Text(datum, style: const pdfWidgets.TextStyle(fontSize: 14, )),
pdfWidgets.Paragraph(text: description, style: const pdfWidgets.TextStyle(fontSize: 12)),
],
),
);
await Printing.sharePdf(bytes: await pdf.save(), filename: '$title.pdf');
}
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to load network image.');
}
final Uint8List bytes = response.bodyBytes;
final imageProvider = pdfWidgets.MemoryImage(bytes);
return imageProvider;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.picture_as_pdf),
onPressed: _printPdf,
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8.0),
child: CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => const Center(
child: SizedBox(
width: 50,
height: 50,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
Container(
padding: const EdgeInsets.all(8.0),
child: Text(
title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: const EdgeInsets.only(left: 8.0, bottom: 8.0),
child: Text(
datum,
style: const TextStyle(
fontSize: 18,
),
),
),
Container(
padding: const EdgeInsets.all(8.0),
child: Text(
description,
style: const TextStyle(
fontSize: 18,
),
),
),
],
),
),
);
}
}
-100
View File
@@ -1,100 +0,0 @@
import 'package:flutter/material.dart';
import 'package:oggauergemeindetrommler/register.dart';
class LoginPage extends StatefulWidget {
LoginPage({Key? key}) : super(key: key);
@override
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
bool isValidEmail(String email) {
// Hier kannst du deine Validierungslogik implementieren
// Rückgabe true, wenn die E-Mail gültig ist, andernfalls false
// Beispiel:
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-Mail',
),
validator: (value) {
if (value!.isEmpty) {
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
} else if (!isValidEmail(value)) {
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
),
),
ElevatedButton(
onPressed: () {
String email = _emailController.text;
if (isValidEmail(email)) {
// E-Mail ist gültig, führe die entsprechende Aktion aus
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ungültige E-Mail-Adresse'),
duration: Duration(seconds: 2),
),
);
}
},
child: const Text('Anmelden'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Noch kein Konto?'),
TextButton(
onPressed: () {
// Navigiere zur Registrierungsseite
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RegisterPage()),
);
},
child: const Text('Registrieren'),
),
],
),
],
),
),
],
),
);
}
}
-469
View File
@@ -1,469 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:printing/printing.dart';
import 'package:provider/provider.dart';
import 'AddPage.dart';
import 'DetailPage.dart';
import 'package:intl/intl.dart';
import 'package:pdf/widgets.dart' as pdfWidgets;
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
import 'anonym_aut.dart';
class UserDefinedItem extends StatelessWidget {
final String title;
final String datum;
final String description;
final String imageUrl;
final Function onLongPress;
final bool isSelected;
final VoidCallback onTap;
const UserDefinedItem({
Key? key,
required this.title,
required this.description,
required this.datum,
required this.imageUrl,
required this.onLongPress,
required this.isSelected,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap:onTap,
onLongPress: onLongPress as void Function()?,
child: Card(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
SizedBox(
width: 120,
height: 120,
child: CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => const Center(
child: SizedBox(
width: 50,
height: 50,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
if (isSelected) // Wenn das Element ausgewählt ist, zeigen Sie die Checkbox an
Positioned(
top: 0,
left: 0,
child: Checkbox(
value: isSelected,
onChanged: (bool? value) {
onLongPress(); // Wenn die Checkbox angeklickt wird, rufen Sie die onLongPress-Funktion auf
},
),
),
],
),
Flexible(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
),
Text(
datum,
style: const TextStyle(
fontSize: 15,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
),
),
),
],
),
),
],
),
),
);
}
}
class GenericList extends StatefulWidget {
final String collectionName;
final int selectedIndex;
const GenericList({required this.collectionName, required this.selectedIndex});
@override
_GenericListState createState() => _GenericListState();
}
class _GenericListState extends State<GenericList> {
final FirebaseFirestore firestore = FirebaseFirestore.instance;
Map<String, Map<String, dynamic>> selectedItems = {};
@override
void initState() {
super.initState();
// Enable persistence
firestore.enablePersistence();
// Enable network
firestore.settings =
const Settings(cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED);
}
@override
void didUpdateWidget(GenericList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedIndex != oldWidget.selectedIndex) {
selectedItems.clear();
}
}
void clearSelectedItems() {
setState(() {
selectedItems.clear();
});
}
Stream<QuerySnapshot> getDocumentStream() {
return firestore
.collection(widget.collectionName)
.orderBy('createdDate', descending: true)
.snapshots(includeMetadataChanges: true);
}
Future<void> _deleteDocument(String documentId, String? imageUrl) async {
try {
if (imageUrl != null) {
// Löschen Sie das Bild vom Firebase Storage
await FirebaseStorage.instance.refFromURL(imageUrl).delete();
}
// Löschen Sie das Dokument von der Firestore-Kollektion
await firestore.collection(widget.collectionName)
.doc(documentId)
.delete();
} catch (e) {
if (kDebugMode) {
print('Error deleting document: $e');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
StreamBuilder<QuerySnapshot>(
stream: getDocumentStream(),
builder: (BuildContext context,
AsyncSnapshot<QuerySnapshot> snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Text('Loading...');
default:
final List<QueryDocumentSnapshot> documents = snapshot.data!
.docs;
return ListView.builder(
itemCount: documents.length,
itemBuilder: (BuildContext context, int index) {
final Map<String, dynamic> data =
documents[index].data()! as Map<String, dynamic>;
final String documentId = documents[index].id;
final DateTime createdDate = data['createdDate'].toDate();
final formattedDate =
DateFormat('dd.MM.yyyy').format(createdDate);
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser != null && data['author_uid'] == currentUser.uid) {
return Dismissible(
key: UniqueKey(),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
_deleteDocument(documentId, data['imageUrl']);
},
background: Container(
color: Colors.red,
child: const Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: Colors.white),
),
),
),
child: UserDefinedItem(
title: data['title'] ?? '',
datum: formattedDate,
description: data['description'] ?? '',
imageUrl: data['imageUrl'] ?? '',
isSelected: selectedItems.containsKey(documentId),
onTap: () {
clearSelectedItems();
// Open a new screen when the user taps on the list item
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailPage(
title: data['title'],
datum: formattedDate,
description: data['description'],
imageUrl: data['imageUrl'],
),
),
);
},
onLongPress: () {
setState(() {
if (selectedItems.containsKey(documentId)) {
selectedItems.remove(documentId);
} else {
selectedItems[documentId] = data;
}
});
},
),
);
} else {
return UserDefinedItem(
title: data['title'] ?? '',
datum: formattedDate,
description: data['description'] ?? '',
imageUrl: data['imageUrl'] ?? '',
isSelected: selectedItems.containsKey(documentId),
onTap: () {
clearSelectedItems();
// Open a new screen when the user taps on the list item
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailPage(
title: data['title'],
datum: formattedDate,
description: data['description'],
imageUrl: data['imageUrl'],
),
),
);
},
onLongPress: () {
setState(() {
if (selectedItems.containsKey(documentId)) {
selectedItems.remove(documentId);
} else {
selectedItems[documentId] = data;
}
});
},
);
}
},
);
}
},
),
if (selectedItems.isNotEmpty)
Stack(
children: [
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: FloatingActionButton(
backgroundColor: Colors.blue,
onPressed: () {
clearSelectedItems();
},
tooltip: "Auswahl zurücksetzen",
child: const Icon(Icons.refresh),
),
),
),
Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () => _printSelectedItemsAsPdf(widget.selectedIndex),
tooltip: "Ausgewählte Elemente als pdf speichern",
child: const Icon(Icons.picture_as_pdf),
),
),
),
],
),
],
),
floatingActionButton: Consumer<AuthState>(
builder: (context, authState, child) {
if (authState.user != null) {
return FloatingActionButton(
onPressed: () {
clearSelectedItems();
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AddScreen(initialIndex: widget.selectedIndex)),
);
},
tooltip: "Neues Element hinzufügen",
child: const Icon(Icons.add),
);
} else {
return Container(); // Return an empty container when the user is not signed in
}
},
),
);
}
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to load network image.');
}
final Uint8List bytes = response.bodyBytes;
final img.Image? image = img.decodeImage(bytes);
final img.Image resizedImage = img.copyResize(image!, width: 290); // Beispielwert für die Breite
final Uint8List resizedBytes = Uint8List.fromList(img.encodeJpg(resizedImage));
final imageProvider = pdfWidgets.MemoryImage(resizedBytes);
return imageProvider;
}
Future<void> _printSelectedItemsAsPdf(int selectedIndex) async {
final pdfWidgets.Document pdf = pdfWidgets.Document();
final List<pdfWidgets.Widget> items = [];
for (final entry in selectedItems.entries) {
final String title = entry.value['title'] ?? '';
final String description = (entry.value['description'] ?? '').length > 165 ? (entry.value['description'] ?? '').substring(0, 165) + '...' : (entry.value['description'] ?? '');
final String imageUrl = entry.value['imageUrl'] ?? '';
final DateTime date = entry.value['createdDate'].toDate();
final String formattedDate = DateFormat('dd.MM.yyyy').format(date);
final pdfWidgets.ImageProvider imageProvider = await _loadNetworkImage(imageUrl);
items.add(
pdfWidgets.Container(
height: 145, // Begrenzt die Höhe des Containers
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
child: pdfWidgets.Row(
children: [
pdfWidgets.Container(
width: 145, // Reduziert die Breite des Bildes
height: 145, // Reduziert die Höhe des Bildes
child: pdfWidgets.Image(imageProvider),
),
pdfWidgets.Flexible(
child: pdfWidgets.Padding(
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
child: pdfWidgets.Column(
crossAxisAlignment: pdfWidgets.CrossAxisAlignment.start,
children: [
pdfWidgets.Row(
mainAxisAlignment: pdfWidgets.MainAxisAlignment.spaceBetween,
children: [
pdfWidgets.Flexible(
child: pdfWidgets.Text(
title,
style: pdfWidgets.TextStyle(
fontSize: 20, fontWeight: pdfWidgets.FontWeight.bold),
),
),
pdfWidgets.Text(
formattedDate,
style: const pdfWidgets.TextStyle(
fontSize: 15,
),
),
],
),
pdfWidgets.Padding(
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
child: pdfWidgets.Text(
description,
style: const pdfWidgets.TextStyle(
fontSize: 15,
),
),
),
],
),
),
),
],
),
),
);
}
pdf.addPage(
pdfWidgets.MultiPage(
build: (pdfWidgets.Context context) => items,
),
);
String filename;
switch(selectedIndex) {
case 0:
filename = 'Bürgerforum_items.pdf';
break;
case 1:
filename = 'Neuigkeiten_items.pdf';
break;
case 2:
filename = 'Mängel_items.pdf';
break;
default:
filename = 'selected_items.pdf';
}
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
clearSelectedItems();
}
}
-55
View File
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:provider/provider.dart';
class AuthState extends ChangeNotifier {
FirebaseAuth auth = FirebaseAuth.instance;
User? _user;
User? get user => _user;
AuthState() {
auth.authStateChanges().listen((User? user) {
if (user == null) {
print('User is currently signed out!');
_user = null;
} else {
print('User is signed in!');
_user = user;
}
notifyListeners();
});
}
Future<void> signInAnonymously() async {
try {
await auth.signInAnonymously();
} catch (e) {
print(e);
}
}
Future<void> signOut() async {
await auth.signOut();
}
}
class AnonymAuthScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Consumer<AuthState>(
builder: (context, authState, child) {
return ElevatedButton(
onPressed: authState.user == null
? authState.signInAnonymously
: authState.signOut,
child: Text(authState.user == null ? 'Anonym anmelden' : 'Abmelden'),
);
},
),
),
);
}
}
-63
View File
@@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:oggauergemeindetrommler/LoginPage.dart';
import 'UserDefinedList.dart';
import 'anonym_aut.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
final List<Widget> widgetOptions = <Widget>[
const GenericList(collectionName: 'CitizensForum', selectedIndex: 0,),
const GenericList(collectionName: 'News', selectedIndex: 1,),
const GenericList(collectionName: 'Issues', selectedIndex: 2,),
AnonymAuthScreen()
];
return Scaffold(
appBar: AppBar(
title: const Text('Oggauer Gemeinde Trommler'),
),
body: Center(
child: widgetOptions.elementAt(_selectedIndex),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'BürgerForum',
),
BottomNavigationBarItem(
icon: Icon(Icons.newspaper),
label: 'Neuigkeiten',
),
BottomNavigationBarItem(
icon: Icon(Icons.report),
label: 'Mängel',
),
BottomNavigationBarItem(
icon: Icon(Icons.account_circle),
label: 'Profil',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.black26,
onTap: _onItemTapped,
),
);
}
}
+24 -8
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'anonym_aut.dart';
import 'home.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'screens/home_screen.dart';
import 'services/auth_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -12,24 +12,40 @@ void main() async {
);
runApp(
ChangeNotifierProvider(
create: (context) => AuthState(),
create: (context) => AuthService(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(
return MaterialApp(
title: 'Oggauer Gemeindetrommler',
home: MyHomePage(title: 'Oggauer Gemeindetrommler'),
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1565C0), // Municipal blue
primary: const Color(0xFF1565C0),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 1,
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
),
cardTheme: CardTheme(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
home: const HomeScreen(),
);
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class PostItem {
final String id;
final String title;
final String description;
final String imageUrl;
final List<String> imageUrls;
final DateTime createdDate;
final String? authorUid;
final String? street;
final String? category;
final String? status;
final String? pdfUrl;
final String? webUrl;
final bool isAussendung;
PostItem({
required this.id,
required this.title,
required this.description,
this.imageUrl = '',
List<String>? imageUrls,
required this.createdDate,
this.authorUid,
this.street,
this.category,
this.status = 'Gemeldet',
this.pdfUrl,
this.webUrl,
this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
factory PostItem.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
DateTime date;
if (data['createdDate'] is Timestamp) {
date = (data['createdDate'] as Timestamp).toDate();
} else {
date = DateTime.now();
}
final String legacyImage = data['imageUrl'] as String? ?? '';
List<String> images = [];
if (data['imageUrls'] is List) {
images = List<String>.from(data['imageUrls'] as List);
} else if (legacyImage.isNotEmpty) {
images = [legacyImage];
}
return PostItem(
id: doc.id,
title: data['title'] as String? ?? '',
description: data['description'] as String? ?? '',
imageUrl: legacyImage.isNotEmpty ? legacyImage : (images.isNotEmpty ? images.first : ''),
imageUrls: images,
createdDate: date,
authorUid: data['author_uid'] as String?,
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,
);
}
Map<String, dynamic> toFirestore() {
return {
'title': title,
'description': description,
'imageUrl': imageUrls.isNotEmpty ? imageUrls.first : '',
'imageUrls': imageUrls,
'createdDate': Timestamp.fromDate(createdDate),
'author_uid': authorUid,
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,
);
}
}
-98
View File
@@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({Key? key}) : super(key: key);
@override
_RegisterPageState createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
bool isValidEmail(String email) {
// Die gleiche Validierung wie in Ihrer LoginPage
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
}
bool arePasswordsMatching(String password, String confirmPassword) {
return password == confirmPassword;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-Mail',
),
validator: (value) {
if (value!.isEmpty) {
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
} else if (!isValidEmail(value)) {
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
),
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort bestätigen',
),
),
ElevatedButton(
onPressed: () {
String email = _emailController.text;
String password = _passwordController.text;
String confirmPassword = _confirmPasswordController.text;
if (isValidEmail(email) && arePasswordsMatching(password, confirmPassword)) {
// E-Mail und Passwörter sind gültig, führe die entsprechende Aktion aus
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ungültige Eingaben'),
duration: Duration(seconds: 2),
),
);
}
},
child: const Text('Registrieren'),
),
],
),
),
],
),
);
}
}
+356
View File
@@ -0,0 +1,356 @@
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../models/post_item.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddIssueScreen extends StatefulWidget {
const AddIssueScreen({super.key});
@override
State<AddIssueScreen> createState() => _AddIssueScreenState();
}
class _AddIssueScreenState extends State<AddIssueScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
final List<File> _selectedImages = [];
bool _isSaving = false;
final List<String> _streets = [
'Hauptstraße',
'Seestraße',
'Florianigasse',
'Sebastiansplatz',
'Sportplatzgasse',
'Feldgasse',
'Kirchengasse',
'Quergasse',
'Weinberggasse',
'Hafen / Seebad',
'Flur / Rad- & Wanderwege',
'Sonstige / Außerorts',
];
final List<String> _categories = [
'Fahrbahn / Gehsteig / Radweg',
'Straßenbeleuchtung',
'Grünflächen / Bäume',
'Müll / Verunreinigung',
'Spielplatz / Freizeiteinrichtung',
'Kanal / Entwässerung',
'Verkehrszeichen / Markierung',
'Sonstiges',
];
String? _selectedStreet;
String? _selectedCategory;
@override
void initState() {
super.initState();
_selectedStreet = _streets.first;
_selectedCategory = _categories.first;
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
if (_selectedImages.length >= 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
);
return;
}
try {
final picked = await ImagePicker().pickImage(
source: source,
maxWidth: 1600,
maxHeight: 1200,
imageQuality: 85,
);
if (picked != null) {
setState(() {
_selectedImages.add(File(picked.path));
});
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
);
}
}
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Foto aus Galerie wählen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Foto mit Kamera aufnehmen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
],
),
),
);
}
Future<void> _submitIssue() async {
if (!_formKey.currentState!.validate() || _isSaving) return;
setState(() {
_isSaving = true;
});
try {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
// Upload selected images to Firebase Storage
final List<String> uploadedUrls = [];
for (final file in _selectedImages) {
final url = await _storageService.uploadFile(
file: file,
collectionName: 'Issues',
optimize: true,
);
uploadedUrls.add(url);
}
final newIssue = PostItem(
id: '',
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: uploadedUrls.isNotEmpty ? uploadedUrls.first : '',
imageUrls: uploadedUrls,
createdDate: DateTime.now(),
authorUid: currentUserId,
street: _selectedStreet,
category: _selectedCategory,
status: 'Gemeldet',
);
await _firestoreService.addPost('Issues', newIssue);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mangel wurde erfolgreich gemeldet!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mangel melden'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Mängelüberschrift *',
border: OutlineInputBorder(),
hintText: 'z.B. Schlagloch, defekte Straßenlaterne',
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Überschrift eingeben'
: null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedStreet,
decoration: const InputDecoration(
labelText: 'Straße / Örtlichkeit *',
border: OutlineInputBorder(),
),
items: _streets
.map((street) => DropdownMenuItem(
value: street,
child: Text(street),
))
.toList(),
onChanged: (val) => setState(() => _selectedStreet = val),
validator: (val) =>
(val == null || val.isEmpty) ? 'Bitte Ort auswählen' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Grundlage / Kategorie *',
border: OutlineInputBorder(),
),
items: _categories
.map((cat) => DropdownMenuItem(
value: cat,
child: Text(cat),
))
.toList(),
onChanged: (val) => setState(() => _selectedCategory = val),
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Mangeltext (Beschreibung) *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
hintText: 'Genaue Beschreibung des Schadens...',
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Beschreibung angeben'
: null,
),
const SizedBox(height: 20),
const Text(
'Fotos (maximal 2 im Querformat)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
Row(
children: [
..._selectedImages.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Stack(
children: [
Container(
margin: const EdgeInsets.only(right: 12),
width: 110,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(file),
fit: BoxFit.cover,
),
),
),
Positioned(
top: -4,
right: 4,
child: IconButton(
icon: const Icon(Icons.cancel, color: Colors.red),
onPressed: () {
setState(() {
_selectedImages.removeAt(index);
});
},
),
),
],
);
}),
if (_selectedImages.length < 2)
InkWell(
onTap: _showImageSourceModal,
child: Container(
width: 110,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.shade400,
style: BorderStyle.solid),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo, color: Colors.grey),
SizedBox(height: 4),
Text('Foto hinzufügen',
style: TextStyle(
fontSize: 11, color: Colors.black54)),
],
),
),
),
],
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _submitIssue,
icon: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check_circle_outline),
label: Text(
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../models/post_item.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddPostScreen extends StatefulWidget {
final String collectionName;
final String screenTitle;
const AddPostScreen({
super.key,
required this.collectionName,
required this.screenTitle,
});
@override
State<AddPostScreen> createState() => _AddPostScreenState();
}
class _AddPostScreenState extends State<AddPostScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
File? _selectedImage;
bool _isSaving = false;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
try {
final picked = await ImagePicker().pickImage(
source: source,
maxWidth: 1600,
maxHeight: 1200,
imageQuality: 85,
);
if (picked != null) {
setState(() {
_selectedImage = File(picked.path);
});
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
);
}
}
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Aus Galerie wählen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Kamera verwenden'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
],
),
),
);
}
Future<void> _submitPost() async {
if (!_formKey.currentState!.validate() || _isSaving) return;
setState(() {
_isSaving = true;
});
try {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
String imageUrl = '';
if (_selectedImage != null) {
imageUrl = await _storageService.uploadFile(
file: _selectedImage!,
collectionName: widget.collectionName,
optimize: true,
);
}
final newPost = PostItem(
id: '',
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: imageUrl,
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
createdDate: DateTime.now(),
authorUid: currentUserId,
);
await _firestoreService.addPost(widget.collectionName, newPost);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eintrag erfolgreich veröffentlicht!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.screenTitle),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Überschrift *',
border: OutlineInputBorder(),
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Überschrift eingeben'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Inhalt / Text *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Text eingeben'
: null,
),
const SizedBox(height: 20),
const Text(
'Anhang / Beitragsbild',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
if (_selectedImage != null)
Stack(
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade400),
image: DecorationImage(
image: FileImage(_selectedImage!),
fit: BoxFit.cover,
),
),
),
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => setState(() => _selectedImage = null),
),
),
),
],
)
else
InkWell(
onTap: _showImageSourceModal,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade400),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey),
SizedBox(height: 6),
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)),
],
),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _submitPost,
icon: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.send),
label: Text(
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+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),
),
),
);
}
}
+434
View File
@@ -0,0 +1,434 @@
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});
@override
State<ContactScreen> createState() => _ContactScreenState();
}
class _ContactScreenState extends State<ContactScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _messageController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
bool _isSubmitting = false;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_messageController.dispose();
super.dispose();
}
Future<void> _submitForm(AuthService auth) async {
if (!auth.isRegisteredUser) {
await AuthDialog.show(context);
return;
}
if (!_formKey.currentState!.validate() || _isSubmitting) return;
setState(() {
_isSubmitting = true;
});
try {
final firstName = auth.firstName.isNotEmpty
? auth.firstName
: _firstNameController.text.trim();
final lastName = auth.lastName.isNotEmpty
? auth.lastName
: _lastNameController.text.trim();
final email = (auth.email != null && auth.email!.isNotEmpty)
? auth.email!
: _emailController.text.trim();
await _firestoreService.submitContactMessage(
firstName: firstName,
lastName: lastName,
email: email,
phone: _phoneController.text.trim(),
message: _messageController.text.trim(),
authorUid: auth.user?.uid,
);
if (!mounted) return;
_phoneController.clear();
_messageController.clear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ihre Nachricht wurde erfolgreich übermittelt!'),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Senden: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final isRegistered = auth.isRegisteredUser;
// Sync controllers with authenticated user details
if (isRegistered) {
if (_firstNameController.text != auth.firstName) {
_firstNameController.text = auth.firstName;
}
if (_lastNameController.text != auth.lastName) {
_lastNameController.text = auth.lastName;
}
if (_emailController.text != (auth.email ?? '')) {
_emailController.text = auth.email ?? '';
}
} else {
if (_firstNameController.text.isNotEmpty) _firstNameController.clear();
if (_lastNameController.text.isNotEmpty) _lastNameController.clear();
if (_emailController.text.isNotEmpty) _emailController.clear();
}
return Scaffold(
appBar: AppBar(
title: const Text('Kontakt Bürgerforum'),
centerTitle: true,
actions: [
if (auth.isAdmin)
IconButton(
icon: const Icon(Icons.inbox),
tooltip: 'Posteingang: Bürgernachrichten',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (auth.isAdmin) ...[
Card(
color: Colors.indigo.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.indigo.shade200),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo.shade700,
child: const Icon(Icons.mark_email_unread, color: Colors.white),
),
title: const Text(
'Admin-Posteingang',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Alle eingegangenen Bürgernachrichten einsehen & verwalten'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
),
const SizedBox(height: 12),
],
if (!isRegistered) ...[
Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300),
),
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lock_outline,
color: Colors.amber.shade900, size: 22),
const SizedBox(width: 8),
Text(
'Anmeldung erforderlich',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
],
),
const SizedBox(height: 6),
Text(
'Um eine Nachricht an das Bürgerforum zu senden, ist ein registriertes Benutzerkonto erforderlich. Deine Kontaktdaten werden dann automatisch hinterlegt.',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade800),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => AuthDialog.show(context),
icon: const Icon(Icons.login),
label: const Text('Jetzt anmelden oder registrieren'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 11),
),
),
),
],
),
),
),
const SizedBox(height: 16),
] else ...[
Card(
color: Colors.blue.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.blue.shade100),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
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,
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
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,
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
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(),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
readOnly: true,
style: TextStyle(
color: isRegistered ? Colors.black87 : Colors.grey.shade600,
fontWeight:
isRegistered ? FontWeight.w500 : FontWeight.normal,
),
decoration: InputDecoration(
labelText: 'E-Mail-Adresse *',
hintText:
isRegistered ? null : 'Wird durch Anmeldung ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.email_outlined),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
helperText: isRegistered
? 'Antworten des Bürgerforums gehen an diese E-Mail'
: null,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Rückrufnummer (optional)',
prefixIcon: const Icon(Icons.phone_outlined),
border: const OutlineInputBorder(),
helperText: isRegistered
? 'Optional für eventuelle telefonische Rückfragen'
: null,
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _messageController,
maxLines: 5,
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Ihr Anliegen / Ihre Nachricht *',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
),
validator: (value) => (value == null || value.trim().isEmpty)
? 'Bitte Ihre Nachricht eingeben'
: null,
),
const SizedBox(height: 12),
Text(
'Hinweis: Strafrechtlich relevante Kommentare und Verleumdungen werden polizeilich verfolgt.',
style: TextStyle(
fontSize: 11,
color: Colors.red.shade700,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: !isRegistered
? () => AuthDialog.show(context)
: (_isSubmitting ? null : () => _submitForm(auth)),
style: ElevatedButton.styleFrom(
backgroundColor: isRegistered
? Colors.blue.shade800
: Colors.grey.shade700,
foregroundColor: Colors.white,
),
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(!isRegistered ? Icons.login : Icons.send),
label: Text(
!isRegistered
? 'Anmelden zum Absenden'
: (_isSubmitting
? 'Wird gesendet...'
: 'Nachricht absenden'),
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'package:cached_network_image/cached_network_image.dart';
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;
const DetailScreen({super.key, required this.item});
@override
Widget build(BuildContext context) {
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
return Scaffold(
appBar: AppBar(
title: Text(item.title, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Als PDF drucken / teilen',
onPressed: () => PdfService.printSinglePost(item),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.imageUrls.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.imageUrls.first,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 180,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48),
),
),
),
const SizedBox(height: 16),
Text(
item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 6),
Text(
formattedDate,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
),
if (item.status != null) ...[
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800),
),
),
],
],
),
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
const SizedBox(width: 6),
Text(
'Örtlichkeit: ${item.street}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
],
if (item.category != null && item.category!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.category, size: 16, color: Colors.orange),
const SizedBox(width: 6),
Text(
'Kategorie: ${item.category}',
style: TextStyle(color: Colors.grey.shade800),
),
],
),
],
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
const Text(
'Beschreibung',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
item.description,
style: const TextStyle(fontSize: 15, height: 1.5),
),
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) ...[
const SizedBox(height: 16),
Card(
color: Colors.red.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.red.shade200),
),
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.red,
child: Icon(Icons.picture_as_pdf, color: Colors.white),
),
title: const Text(
'PDF-Aussendung ansehen',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle:
const Text('Dokument jetzt in der App öffnen und lesen'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => PdfViewerScreen(
title: item.title,
pdfUrl: item.pdfUrl!,
),
),
);
},
),
),
],
const SizedBox(height: 24),
// Additional images gallery if there are multiple images
if (item.imageUrls.length > 1) ...[
const Text(
'Weitere Fotos',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: item.imageUrls.length - 1,
separatorBuilder: (ctx, i) => const SizedBox(width: 8),
itemBuilder: (ctx, index) {
final url = item.imageUrls[index + 1];
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 120,
height: 120,
fit: BoxFit.cover,
),
);
},
),
),
],
],
),
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'contact_screen.dart';
import 'info_screen.dart';
import 'post_list_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
final List<Widget> _pages = const [
PostListScreen(
collectionName: 'News',
title: 'Bürgerforum Aktuelles',
),
PostListScreen(
collectionName: 'Issues',
title: 'Mängel & Anliegen',
),
PostListScreen(
collectionName: 'CitizensForum',
title: 'Ideen für Oggau',
),
ContactScreen(),
InfoScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
selectedItemColor: Colors.blue.shade800,
unselectedItemColor: Colors.grey.shade600,
selectedFontSize: 11,
unselectedFontSize: 11,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.newspaper_outlined),
activeIcon: Icon(Icons.newspaper),
label: 'Nachrichten',
),
BottomNavigationBarItem(
icon: Icon(Icons.report_problem_outlined),
activeIcon: Icon(Icons.report_problem),
label: 'Mängel',
),
BottomNavigationBarItem(
icon: Icon(Icons.lightbulb_outline),
activeIcon: Icon(Icons.lightbulb),
label: 'Ideen',
),
BottomNavigationBarItem(
icon: Icon(Icons.mail_outline),
activeIcon: Icon(Icons.mail),
label: 'Kontakt',
),
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
activeIcon: Icon(Icons.info),
label: 'Bürgerforum',
),
],
),
);
}
}
+351
View File
@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import 'auth_dialog.dart';
import 'user_management_screen.dart';
class InfoScreen extends StatelessWidget {
const InfoScreen({super.key});
@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.symmetric(horizontal: 16.0, vertical: 8.0),
children: [
// 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.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(14.0),
child: Column(
children: [
Row(
children: [
Icon(
auth.isAdmin
? Icons.admin_panel_settings
: auth.isRegisteredUser
? Icons.account_circle
: auth.isCommunityUnlocked
? Icons.verified_user
: Icons.lock_outline,
color: auth.isAdmin
? Colors.indigo.shade700
: auth.isRegisteredUser
? Colors.green.shade700
: auth.isCommunityUnlocked
? Colors.teal.shade800
: Colors.blue.shade800,
size: 36,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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: 15),
),
const SizedBox(height: 2),
Text(
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),
),
],
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
),
onPressed: () {
if (auth.isRegisteredUser || auth.isCommunityUnlocked) {
auth.lockCommunityAccess();
} else {
AuthDialog.show(context);
}
},
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: 14),
// About Bürgerforum Oggau
Card(
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gemeindeamt & Bürgerservice Oggau',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
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(
'Öffnungszeiten Gemeindeamt:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
Text('Mo – Fr: 08:00 – 12:00 Uhr | Di zusätzlich: 13:00 – 17:00 Uhr',
style: TextStyle(fontSize: 12)),
],
),
),
),
const SizedBox(height: 14),
// Wichtige Notrufnummern
Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wichtige Notrufnummern',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.red),
),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Feuerwehr'),
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Polizei'),
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Rettung / Notarzt'),
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Euronotruf'),
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
),
),
const SizedBox(height: 14),
// Impressum
Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Impressum',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 6),
Text(
'Medieninhaber & Herausgeber:\n'
'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,
),
],
),
),
),
),
],
),
);
}
}
+675
View File
@@ -0,0 +1,675 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '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;
final String title;
const PostListScreen({
super.key,
required this.collectionName,
required this.title,
});
@override
State<PostListScreen> createState() => _PostListScreenState();
}
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();
super.dispose();
}
void _toggleSelection(PostItem item) {
setState(() {
if (_selectedItems.containsKey(item.id)) {
_selectedItems.remove(item.id);
} else {
_selectedItems[item.id] = item;
}
});
}
void _clearSelection() {
setState(() {
_selectedItems.clear();
});
}
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;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Eintrag gelöscht.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Löschen: $e')),
);
}
}
Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(context, listen: false);
// News are official announcements and can only be created by Admins
if (widget.collectionName == 'News' && !auth.isAdmin) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Nur Administratoren können Neuigkeiten veröffentlichen.'),
backgroundColor: Colors.amber,
),
);
return;
}
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
await AuthDialog.show(context);
if (!mounted) return;
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
return;
}
}
_clearSelection();
if (widget.collectionName == 'Issues') {
Navigator.push(
context,
MaterialPageRoute(builder: (ctx) => const AddIssueScreen()),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: widget.collectionName,
screenTitle: widget.collectionName == 'News'
? 'Neue Nachricht'
: 'Neue Idee einbringen',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final currentUser = auth.user;
return Scaffold(
appBar: AppBar(
title: _isSearchActive
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Suche in ${widget.title}...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: 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),
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
onPressed: () {
setState(() {
if (_isSearchActive) {
_isSearchActive = false;
_searchQuery = '';
_searchController.clear();
} else {
_isSearchActive = true;
}
});
},
),
if (_selectedItems.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Auswahl als PDF-Tabelle drucken',
onPressed: () => PdfService.printSelectedItemsTable(
items: _selectedItems.values.toList(),
title: widget.title,
),
),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: 'Auswahl aufheben',
onPressed: _clearSelection,
),
],
],
),
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) {
return Center(
child: Text(
'Fehler beim Laden: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
),
);
}
if (snapshot.connectionState == ConnectionState.waiting &&
widget.collectionName != 'News') {
return const Center(child: CircularProgressIndicator());
}
final firestoreItems = snapshot.data ?? [];
List<PostItem> allItems;
if (widget.collectionName == 'News') {
allItems = [...firestoreItems, ..._aussendungen];
allItems.sort((a, b) => b.createdDate.compareTo(a.createdDate));
if (_newsFilter == 'aussendungen') {
allItems = allItems.where((item) => item.isAussendung).toList();
} else if (_newsFilter == 'news') {
allItems = allItems.where((item) => !item.isAussendung).toList();
}
} else {
allItems = firestoreItems;
}
final filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
final matchesTitle = item.title.toLowerCase().contains(query);
final matchesDesc = item.description.toLowerCase().contains(query);
final matchesStreet =
item.street?.toLowerCase().contains(query) ?? false;
final matchesCategory =
item.category?.toLowerCase().contains(query) ?? false;
return matchesTitle || matchesDesc || matchesStreet || matchesCategory;
}).toList();
if (filteredItems.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
_searchQuery.isNotEmpty
? 'Keine Treffer für "$_searchQuery"'
: 'Noch keine Einträge vorhanden.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
),
],
),
);
}
return ListView.separated(
itemCount: filteredItems.length,
separatorBuilder: (ctx, idx) => const Divider(
height: 1,
indent: 16,
endIndent: 16,
color: Colors.black12,
),
itemBuilder: (context, index) {
final item = filteredItems[index];
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? leadingWidget;
if (item.imageUrl.isNotEmpty) {
leadingWidget = Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.imageUrl,
width: 70,
height: 70,
fit: BoxFit.cover,
placeholder: (ctx, url) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (ctx, url, err) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
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 (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,
children: [
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
formattedDate,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 4),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
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,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade800,
),
),
],
),
);
if (canDelete) {
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
},
onDismissed: (direction) => _deletePost(item),
child: itemTile,
);
}
return itemTile;
},
);
},
);
}
}
+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: ');
}
}
}
+350
View File
@@ -0,0 +1,350 @@
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 {
final FirebaseAuth _auth = FirebaseAuth.instance;
User? _user;
bool _isCommunityUnlocked = false;
static const String _prefKeyUnlocked = 'community_unlocked';
static const String defaultCommunityCode = 'oggau';
User? get user => _user;
bool get isAuthenticated => _user != null;
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) async {
_user = user;
await _checkAdminClaim(user);
if (user != null && !user.isAnonymous) {
await _syncUserProfile(user);
}
notifyListeners();
});
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();
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();
await prefs.setBool(_prefKeyUnlocked, false);
await signOut();
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();
} catch (e) {
if (kDebugMode) {
debugPrint('Sign in anonymously error: $e');
}
}
}
/// 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) {
debugPrint('Sign out error: $e');
}
}
}
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).';
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
import '../models/post_item.dart';
class FirestoreService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
FirestoreService() {
_initSettings();
}
void _initSettings() {
try {
_firestore.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
} catch (e) {
if (kDebugMode) {
debugPrint('Firestore settings initialization error: $e');
}
}
}
/// Stream of posts ordered by creation date descending
Stream<List<PostItem>> getPostsStream(String collectionName) {
return _firestore
.collection(collectionName)
.orderBy('createdDate', descending: true)
.snapshots(includeMetadataChanges: true)
.map((snapshot) =>
snapshot.docs.map((doc) => PostItem.fromFirestore(doc)).toList());
}
/// Add a new post / issue / idea
Future<DocumentReference> addPost(
String collectionName, PostItem item) async {
return await _firestore.collection(collectionName).add(item.toFirestore());
}
/// Delete a post by ID
Future<void> deletePost(String collectionName, String documentId) async {
await _firestore.collection(collectionName).doc(documentId).delete();
}
/// Send a contact form submission to Firestore
Future<void> submitContactMessage({
required String firstName,
required String lastName,
required String email,
String? phone,
required String message,
String? authorUid,
}) async {
await _firestore.collection('ContactMessages').add({
'firstName': firstName,
'lastName': lastName,
'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));
}
}
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../models/post_item.dart';
class PdfService {
static Future<pw.ImageProvider?> _fetchImage(String url) async {
if (url.isEmpty) return null;
try {
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
final Uint8List bytes = response.bodyBytes;
// Optionally resize image to save PDF memory
final decoded = img.decodeImage(bytes);
if (decoded != null) {
final resized = img.copyResize(decoded, width: 300);
final encoded = Uint8List.fromList(img.encodeJpg(resized, quality: 80));
return pw.MemoryImage(encoded);
}
return pw.MemoryImage(bytes);
}
} catch (e) {
if (kDebugMode) {
debugPrint('Failed to load image for PDF: $e');
}
}
return null;
}
/// Print single post item as PDF
static Future<void> printSinglePost(PostItem item) async {
final pdf = pw.Document();
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (context) => [
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Marktgemeinde Oggau',
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue800,
),
),
pw.Text(
formattedDate,
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
),
],
),
),
pw.SizedBox(height: 12),
pw.Text(
item.title,
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold),
),
if (item.street != null && item.street!.isNotEmpty) ...[
pw.SizedBox(height: 6),
pw.Text(
'Örtlichkeit / Straße: ${item.street}',
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold),
),
],
if (item.category != null && item.category!.isNotEmpty) ...[
pw.SizedBox(height: 4),
pw.Text(
'Kategorie: ${item.category}',
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey800),
),
],
pw.Divider(thickness: 1, color: PdfColors.grey400),
pw.SizedBox(height: 10),
if (imageProvider != null) ...[
pw.Center(
child: pw.Container(
height: 220,
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
),
),
pw.SizedBox(height: 14),
],
pw.Text(
'Beschreibung:',
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 6),
pw.Paragraph(
text: item.description,
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
),
],
),
);
final cleanTitle = item.title.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
await Printing.sharePdf(
bytes: await pdf.save(),
filename: '$cleanTitle.pdf',
);
}
/// Print a list of selected items in table format for Bauhof / Gemeinde
static Future<void> printSelectedItemsTable({
required List<PostItem> items,
required String title,
}) async {
final pdf = pw.Document();
// Preload all images in parallel
final Map<String, pw.ImageProvider?> imageMap = {};
await Future.wait(items.map((item) async {
if (item.imageUrl.isNotEmpty) {
imageMap[item.id] = await _fetchImage(item.imageUrl);
}
}));
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(24),
header: (context) => pw.Container(
alignment: pw.Alignment.centerLeft,
margin: const pw.EdgeInsets.only(bottom: 12),
padding: const pw.EdgeInsets.only(bottom: 6),
decoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Oggauer Gemeindetrommler - $title',
style: pw.TextStyle(
fontSize: 16,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue800,
),
),
pw.Text(
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
),
],
),
),
build: (context) => [
pw.TableHelper.fromTextArray(
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellHeight: 65,
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerLeft,
2: pw.Alignment.centerLeft,
3: pw.Alignment.centerLeft,
4: pw.Alignment.center,
},
headers: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
data: items.map((item) {
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate);
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
final descSnippet = item.description.length > 120
? '${item.description.substring(0, 120)}...'
: item.description;
return [
formattedDate,
locInfo,
'${item.title}\n$descSnippet',
item.status ?? 'Gemeldet',
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
];
}).toList(),
),
],
),
);
final filename = '${title.toLowerCase().replaceAll(' ', '_')}_tabelle.pdf';
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:io';
import 'dart:math';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
class StorageService {
final FirebaseStorage _storage = FirebaseStorage.instance;
/// Compresses and resizes an image to maximum 800x600 landscape bounds
Future<File> optimizeImage(File originalFile) async {
try {
final bytes = await originalFile.readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null) return originalFile;
// Ensure landscape orientation or maximum 800x600 resolution
int targetWidth = decoded.width;
int targetHeight = decoded.height;
const int maxW = 800;
const int maxH = 600;
if (targetWidth > maxW || targetHeight > maxH) {
double ratioW = maxW / targetWidth;
double ratioH = maxH / targetHeight;
double scale = min(ratioW, ratioH);
targetWidth = (targetWidth * scale).round();
targetHeight = (targetHeight * scale).round();
}
final resized = img.copyResize(
decoded,
width: targetWidth,
height: targetHeight,
interpolation: img.Interpolation.linear,
);
final tempDir = originalFile.parent.path;
final optimizedFile = File(
'$tempDir/optimized_${DateTime.now().millisecondsSinceEpoch}.jpg');
await optimizedFile.writeAsBytes(img.encodeJpg(resized, quality: 85));
return optimizedFile;
} catch (e) {
if (kDebugMode) {
debugPrint('Image optimization fallback: $e');
}
return originalFile;
}
}
/// Uploads a file to Firebase Storage under $collectionName/$userId/$timestamp.jpg
Future<String> uploadFile({
required File file,
required String collectionName,
bool optimize = true,
}) async {
final user = FirebaseAuth.instance.currentUser;
final userId = user?.uid ?? 'anonymous';
File fileToUpload = file;
if (optimize) {
fileToUpload = await optimizeImage(file);
}
final filename = '${DateTime.now().millisecondsSinceEpoch}.jpg';
final ref = _storage.ref().child('$collectionName/$userId/$filename');
final uploadTask = ref.putFile(fileToUpload);
final snapshot = await uploadTask;
return await snapshot.ref.getDownloadURL();
}
/// Deletes a file by download URL
Future<void> deleteFileByUrl(String url) async {
if (url.isEmpty) return;
try {
final ref = _storage.refFromURL(url);
await ref.delete();
} catch (e) {
if (kDebugMode) {
debugPrint('Error deleting storage file: $e');
}
}
}
}
+137 -1
View File
@@ -329,7 +329,7 @@ packages:
source: sdk
version: "0.0.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
@@ -528,6 +528,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.6"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
@@ -656,6 +664,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.12.0"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
qr:
dependency: transitive
description:
@@ -672,6 +688,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
url: "https://pub.dev"
source: hosted
version: "2.2.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -757,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:
+6 -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
@@ -52,6 +52,9 @@ dependencies:
image_cropper: ^8.0.1
logging: ^1.1.1
provider: ^6.0.5
http: ^1.2.0
shared_preferences: ^2.2.2
url_launcher: ^6.3.1
dev_dependencies:
@@ -76,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)))
);
}
}
}
+162 -22
View File
@@ -1,30 +1,170 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:oggauergemeindetrommler/main.dart';
import 'package:oggauergemeindetrommler/models/post_item.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
group('PostItem Model Tests', () {
test('creates PostItem with default values', () {
final now = DateTime.now();
final item = PostItem(
id: 'test-123',
title: 'Defekte Straßenlaterne',
description: 'Laterne vor Haus Nr. 12 leuchtet nicht mehr.',
imageUrl: 'https://example.com/test.jpg',
createdDate: now,
street: 'Hauptstraße',
category: 'Straßenbeleuchtung',
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
expect(item.id, 'test-123');
expect(item.title, 'Defekte Straßenlaterne');
expect(item.street, 'Hauptstraße');
expect(item.category, 'Straßenbeleuchtung');
expect(item.imageUrls.length, 1);
expect(item.status, 'Gemeldet');
});
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
test('serializes to Firestore map accurately', () {
final now = DateTime.now();
final item = PostItem(
id: 'test-456',
title: 'Müllablagerung am See',
description: 'Illegale Ablagerung am Parkplatz',
createdDate: now,
street: 'Hafen / Seebad',
category: 'Müll / Verunreinigung',
status: 'In Bearbeitung',
);
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
final map = item.toFirestore();
expect(map['title'], 'Müllablagerung am See');
expect(map['street'], 'Hafen / Seebad');
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);
});
});
}