Compare commits
62
Commits
2ee134e283
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b34d4a16f | ||
|
|
c1cdd54964 | ||
|
|
fc95c6e6f7 | ||
|
|
75f1610cde | ||
|
|
9a823cbcdb | ||
|
|
e512c081e3 | ||
|
|
cd3b71736d | ||
|
|
3df2e4c5c8 | ||
|
|
877862aaf2 | ||
|
|
c3513e85e5 | ||
|
|
e64b7a085c | ||
|
|
2e9abac25f | ||
|
|
010904a48a | ||
|
|
a4755f6d53 | ||
|
|
80ff6cd95d | ||
|
|
1420d0a113 | ||
|
|
adc65e056d | ||
|
|
ab9b22fc4a | ||
|
|
b6baaf67e8 | ||
|
|
2917f93323 | ||
|
|
7f4218b0a2 | ||
|
|
8be95162d2 | ||
|
|
565a7d1347 | ||
|
|
e44fcf1223 | ||
|
|
f446fd044b | ||
|
|
9226ce4b33 | ||
|
|
1acbeddc79 | ||
|
|
46dc3368c6 | ||
|
|
b443213224 | ||
|
|
ca59dff4db | ||
|
|
f6f3a5ff06 | ||
|
|
ac395dad07 | ||
|
|
3c8ab00991 | ||
|
|
5969d23a98 | ||
|
|
6b9703d79c | ||
|
|
91c2e9659b | ||
|
|
8b4916246b | ||
|
|
0aa5fda335 | ||
|
|
3b5531a9d6 | ||
|
|
9a8ee35fc2 | ||
|
|
50a6025937 | ||
|
|
588309649c | ||
|
|
e9b91e5452 | ||
|
|
d84b0002ac | ||
|
|
e6ce9c5012 | ||
|
|
239f399af1 | ||
|
|
c0ac104840 | ||
|
|
4c548b030c | ||
|
|
fe4f1d52ae | ||
|
|
3f43a016db | ||
|
|
9d41a5f5f1 | ||
|
|
c7fe414bc6 | ||
|
|
cf836ada32 | ||
|
|
4020dc4d90 | ||
|
|
32670219d3 | ||
|
|
dbec2c6244 | ||
|
|
7062aef03b | ||
|
|
3a50d7f1bc | ||
|
|
893d095f75 | ||
|
|
5687670e36 | ||
|
|
5d1c30ce9f | ||
|
|
423aae59b1 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
|
||||
+18
-20
@@ -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,24 +23,22 @@ 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_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -50,7 +50,7 @@ android {
|
||||
applicationId "com.oggauergemeindetrommler.app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 19
|
||||
minSdkVersion 23
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -70,6 +70,4 @@ flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
}
|
||||
dependencies {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+15
-16
@@ -1,18 +1,3 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
// START: FlutterFire Configuration
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
// END: FlutterFire Configuration
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
@@ -25,10 +10,24 @@ 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 ->
|
||||
if (project.hasProperty('android')) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx1536M -Djdk.lang.Process.launchMechanism=vfork
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
|
||||
+160
@@ -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 "$@"
|
||||
Vendored
+90
@@ -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
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -1,285 +0,0 @@
|
||||
import 'dart:io';
|
||||
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';
|
||||
|
||||
|
||||
class AddScreen extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
|
||||
const AddScreen({super.key, required this.initialIndex});
|
||||
@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<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) {
|
||||
setState(() {
|
||||
_imageFile = tempFile;
|
||||
});
|
||||
} 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);
|
||||
try
|
||||
{
|
||||
await FirebaseFirestore.instance.collection(collectionName).add({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"imageUrl": imageUrl,
|
||||
"createdDate": DateTime.now(),
|
||||
});
|
||||
onComplete(true);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
onComplete(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Future<String> uploadImageToFirebaseStorage(File imageFile, String collectionName) async {
|
||||
Reference ref = FirebaseStorage.instance.ref().child("$collectionName/${DateTime.now().toString()}");
|
||||
UploadTask uploadTask = ref.putFile(imageFile);
|
||||
return await (await uploadTask).ref.getDownloadURL();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String appBarTitle = '';
|
||||
|
||||
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) {
|
||||
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
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.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);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:flutter/material.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
|
||||
},
|
||||
child: const Text('Registrieren'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'DetailPage.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class UserDefinedItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String datum;
|
||||
final String description;
|
||||
final String imageUrl;
|
||||
|
||||
const UserDefinedItem({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.datum,
|
||||
required this.imageUrl,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Open a new screen when the user taps on the list item
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DetailPage(
|
||||
title: title,
|
||||
datum: datum,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
),
|
||||
),
|
||||
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;
|
||||
const GenericList({Key? key, required this.collectionName}) : super(key: key);
|
||||
|
||||
@override
|
||||
_GenericListState createState() => _GenericListState();
|
||||
}
|
||||
|
||||
class _GenericListState extends State<GenericList> {
|
||||
final FirebaseFirestore firestore = FirebaseFirestore.instance;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Enable persistence
|
||||
firestore.enablePersistence();
|
||||
// Enable network
|
||||
firestore.settings =
|
||||
const Settings(cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED);
|
||||
}
|
||||
|
||||
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 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);
|
||||
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'] ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:oggauergemeindetrommler/LoginPage.dart';
|
||||
import 'AddPage.dart';
|
||||
import 'UserDefinedList.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'),
|
||||
const GenericList(collectionName: 'News'),
|
||||
const GenericList(collectionName: 'Issues'),
|
||||
LoginPage()
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Oggauer Gemeinde Trommler'),
|
||||
),
|
||||
body: Center(
|
||||
child: widgetOptions.elementAt(_selectedIndex),
|
||||
),
|
||||
floatingActionButton: _selectedIndex == 3
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AddScreen(initialIndex: _selectedIndex),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Add',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
-7
@@ -1,28 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'home.dart';
|
||||
import 'package:provider/provider.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();
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
runApp(const MyApp());
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('–', '–')
|
||||
.replaceAll('—', '—')
|
||||
.replaceAll('‘', '‘')
|
||||
.replaceAll('’', '’')
|
||||
.replaceAll('“', '“')
|
||||
.replaceAll('”', '”')
|
||||
.replaceAll('…', '…');
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+459
-163
File diff suppressed because it is too large
Load Diff
+18
-12
@@ -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
|
||||
@@ -32,21 +32,29 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: ^0.17.0
|
||||
intl: ^0.20.2
|
||||
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
image_picker: ^0.8.6+3
|
||||
firebase_core: ^2.11.0
|
||||
firebase_auth: ^4.5.0
|
||||
firebase_storage: ^11.1.0
|
||||
cloud_firestore: ^4.6.0
|
||||
image_picker: ^1.1.2
|
||||
firebase_core: ^3.4.0
|
||||
firebase_auth: ^5.2.0
|
||||
firebase_storage: ^12.2.0
|
||||
cloud_firestore: ^5.4.0
|
||||
change_app_package_name: ^1.1.0
|
||||
cached_network_image: ^3.2.3
|
||||
image: ^4.0.16
|
||||
pdf: ^3.10.1
|
||||
printing: ^5.10.4
|
||||
image_cropper: ^8.0.1
|
||||
logging: ^1.1.1
|
||||
provider: ^6.0.5
|
||||
http: ^1.2.0
|
||||
shared_preferences: ^2.2.2
|
||||
url_launcher: ^6.3.1
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
@@ -58,7 +66,7 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -71,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
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 – Konzept & 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user