Compare commits
64
Commits
239f399af1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
974223f7e7 | ||
|
|
f10d5ad5ed | ||
|
|
a228392dc6 | ||
|
|
b7b6c4a2ab | ||
|
|
c655ba713b | ||
|
|
0b3cacf4b3 | ||
|
|
1eb089f6b5 | ||
|
|
744def72fd | ||
|
|
1c66c31f6d | ||
|
|
df34d66d38 | ||
|
|
85556dabb4 | ||
|
|
230430c91c | ||
|
|
285ba029f4 | ||
|
|
2b32b3712f | ||
|
|
7affff6e15 | ||
|
|
098c0105f5 | ||
|
|
a21d370b91 | ||
|
|
a2da5cdc89 | ||
|
|
362a7da74f | ||
|
|
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 |
@@ -0,0 +1,235 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- 'refactor/**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Only one build per ref at a time: two QEMU-emulated Gradle builds in parallel
|
||||||
|
# take the ARM64 runner down (both runs then die at the same instant).
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Keep in sync with .github/workflows/build-ios.yaml and pubspec.yaml (environment).
|
||||||
|
FLUTTER_VERSION: '3.47.4'
|
||||||
|
# Static qemu-x86_64 for the ARM64 runner (Debian trixie `qemu-user`, see below).
|
||||||
|
QEMU_DEB_VERSION: '10.0.13+ds-0+deb13u1'
|
||||||
|
QEMU_DEB_SHA1: '02b553a4532192bc999d15408d1e56d7b254906f'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
name: Build Release APKs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
container:
|
||||||
|
# Cirrus Labs stopped publishing Flutter images in May 2026 (last tag: 3.44.0,
|
||||||
|
# multi-arch amd64/arm64, Ubuntu 24.04, OpenJDK 21, Android SDK 36). The image is
|
||||||
|
# only used for its JDK + Android SDK; the bundled Flutter SDK is switched to
|
||||||
|
# FLUTTER_VERSION in the first step.
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.44.0
|
||||||
|
options: --privileged
|
||||||
|
steps:
|
||||||
|
- name: Switch Flutter SDK to pinned version
|
||||||
|
# act_runner defaults to `sh -e` (dash); bash is needed for `pipefail`.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
FLUTTER_ROOT="${FLUTTER_ROOT:-/sdks/flutter}"
|
||||||
|
git config --global --add safe.directory "$FLUTTER_ROOT"
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
CURRENT="$(git -C "$FLUTTER_ROOT" describe --tags --exact-match 2>/dev/null || true)"
|
||||||
|
if [ "$CURRENT" != "$FLUTTER_VERSION" ]; then
|
||||||
|
echo "Switching Flutter SDK from '${CURRENT:-unknown}' to '$FLUTTER_VERSION'"
|
||||||
|
git -C "$FLUTTER_ROOT" fetch --depth 1 origin "refs/tags/${FLUTTER_VERSION}:refs/tags/${FLUTTER_VERSION}"
|
||||||
|
git -C "$FLUTTER_ROOT" checkout -q "$FLUTTER_VERSION"
|
||||||
|
# Drop the cache of the previous version; the Dart SDK and the engine
|
||||||
|
# artifacts for the new version are downloaded on first use.
|
||||||
|
rm -rf "$FLUTTER_ROOT/bin/cache"
|
||||||
|
fi
|
||||||
|
flutter --version
|
||||||
|
|
||||||
|
- name: Setup Node.js 20 & ARM64 Flutter Engine
|
||||||
|
run: |
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; else NODE_ARCH="x64"; fi
|
||||||
|
curl -fsSL "https://nodejs.org/dist/v20.18.0/node-v20.18.0-linux-${NODE_ARCH}.tar.gz" | tar -xzf - -C /usr/local --strip-components=1
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..."
|
||||||
|
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc llvm
|
||||||
|
|
||||||
|
# Setup x86_64 dynamic libraries & prefix for QEMU emulation
|
||||||
|
mkdir -p /etc/qemu-binfmt
|
||||||
|
ln -sf /usr/x86_64-linux-gnu /etc/qemu-binfmt/x86_64
|
||||||
|
mkdir -p /lib64 /usr/lib64
|
||||||
|
ln -sf /usr/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
|
||||||
|
ln -sf /usr/x86_64-linux-gnu/lib/* /lib64/ 2>/dev/null || true
|
||||||
|
ln -sf /usr/x86_64-linux-gnu/lib/* /usr/lib64/ 2>/dev/null || true
|
||||||
|
|
||||||
|
export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu
|
||||||
|
echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# Ubuntu 24.04 ships QEMU 8.2.2, whose x86_64 user-mode emulation dies with
|
||||||
|
# "QEMU internal SIGSEGV {code=MAPERR, addr=0x20}" as soon as the guest calls
|
||||||
|
# pthread_getattr_np() - which Dart's gen_snapshot does since Flutter 3.4x
|
||||||
|
# (https://gitlab.com/qemu-project/qemu/-/issues/2170, fixed in 8.2.3/9.0).
|
||||||
|
# Replace it with the static binary from Debian trixie's qemu-user package.
|
||||||
|
QEMU_DEB="qemu-user_${QEMU_DEB_VERSION}_arm64.deb"
|
||||||
|
curl -fsSL -o /tmp/qemu-user.deb "https://deb.debian.org/debian/pool/main/q/qemu/${QEMU_DEB}" ||
|
||||||
|
curl -fsSL -o /tmp/qemu-user.deb "https://snapshot.debian.org/file/${QEMU_DEB_SHA1}"
|
||||||
|
echo "${QEMU_DEB_SHA1} /tmp/qemu-user.deb" | sha1sum -c -
|
||||||
|
dpkg-deb -x /tmp/qemu-user.deb /tmp/qemu-user
|
||||||
|
install -m 0755 /tmp/qemu-user/usr/bin/qemu-x86_64 /usr/bin/qemu-x86_64-static
|
||||||
|
rm -rf /tmp/qemu-user /tmp/qemu-user.deb
|
||||||
|
/usr/bin/qemu-x86_64-static --version | head -n 1
|
||||||
|
|
||||||
|
# Must match the path hard-coded in scripts/ci/qemu_intercept.c.
|
||||||
|
QEMU_BIN=/usr/bin/qemu-x86_64-static
|
||||||
|
|
||||||
|
# Dummy fallbacks for FEX binaries/sockets
|
||||||
|
for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > "$fex"
|
||||||
|
chmod +x "$fex" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
flutter precache --android
|
||||||
|
# Flutter (still) only ships an x86_64 gen_snapshot for Linux hosts; wrap it
|
||||||
|
# with QEMU under the linux-arm64 path the tool expects.
|
||||||
|
for dir in "${FLUTTER_ROOT:-/sdks/flutter}"/bin/cache/artifacts/engine/android-*; do
|
||||||
|
if [ -d "$dir/linux-x64" ] && [ ! -x "$dir/linux-arm64/gen_snapshot" ]; then
|
||||||
|
mkdir -p "$dir/linux-arm64"
|
||||||
|
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec env -u LD_PRELOAD %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
|
||||||
|
chmod +x "$dir/linux-arm64/gen_snapshot"
|
||||||
|
echo "Configured gen_snapshot wrapper for $(basename "$dir")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# AGP strips libflutter.so/libapp.so with the NDK's llvm-strip; unstripped they
|
||||||
|
# are ~15x larger (165 MB instead of 12 MB per ABI). The NDK's x86_64 llvm-strip
|
||||||
|
# cannot run under QEMU (needs libz.so.1 etc. in the cross sysroot), so install
|
||||||
|
# the NDK Flutter expects up front and swap in Ubuntu's native arm64 llvm-strip.
|
||||||
|
NDK_VERSION="$(sed -nE 's/.*ndkVersion: String = "([^"]+)".*/\1/p' "${FLUTTER_ROOT:-/sdks/flutter}/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt")"
|
||||||
|
[ -n "$NDK_VERSION" ] || { echo "::error::Could not determine Flutter's NDK version"; exit 1; }
|
||||||
|
echo "Installing NDK $NDK_VERSION ..."
|
||||||
|
yes | sdkmanager --install "ndk;${NDK_VERSION}" > /dev/null
|
||||||
|
NDK_BIN="${ANDROID_HOME:-/opt/android-sdk-linux}/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
|
for tool in llvm-strip llvm-objcopy; do
|
||||||
|
mv "$NDK_BIN/$tool" "$NDK_BIN/$tool.x86_64"
|
||||||
|
ln -s "$(command -v "$tool")" "$NDK_BIN/$tool"
|
||||||
|
done
|
||||||
|
"$NDK_BIN/llvm-strip" --version | head -n 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup QEMU Interceptor
|
||||||
|
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 Release APKs (Split-per-ABI + Universal)
|
||||||
|
# android/app/build.gradle re-enables the universal APK for split builds, so
|
||||||
|
# this one Gradle run yields all four APKs in build/app/outputs/flutter-apk/.
|
||||||
|
run: flutter build apk --release --split-per-abi
|
||||||
|
|
||||||
|
- name: Organize Artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-apks
|
||||||
|
APK_DIR=build/app/outputs/flutter-apk
|
||||||
|
echo "=== Flutter APK outputs ==="
|
||||||
|
ls -lah "$APK_DIR"
|
||||||
|
cp "$APK_DIR/app-release.apk" release-apks/oggauer-gemeindetrommler-universal.apk
|
||||||
|
cp "$APK_DIR/app-arm64-v8a-release.apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||||
|
cp "$APK_DIR/app-armeabi-v7a-release.apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||||
|
cp "$APK_DIR/app-x86_64-release.apk" release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||||
|
echo "=== Generated 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
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload ARM64 APK
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-arm64-v8a
|
||||||
|
path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload ARMv7 APK
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-armeabi-v7a
|
||||||
|
path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload x86_64 APK
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-x86_64
|
||||||
|
path: release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish Direct APKs to Gitea Releases
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
RELEASE_TAG: latest
|
||||||
|
run: |
|
||||||
|
node scripts/ci/publish_release.js
|
||||||
|
|
||||||
|
- name: Clean Workspace Disk Space
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -rf build android/.gradle release-apks 2>/dev/null || true
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Build iOS IPA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
- 'latest'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Only the newest push per branch/tag is built.
|
||||||
|
concurrency:
|
||||||
|
group: build-ios-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Keep in sync with .gitea/workflows/build-apk.yaml and pubspec.yaml (environment).
|
||||||
|
FLUTTER_VERSION: '3.47.4'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
name: Build iOS IPA
|
||||||
|
# macOS 26 (arm64) ships Xcode 26 by default; Flutter 3.47 needs Xcode >= 16.
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 60
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Configure Flutter
|
||||||
|
run: |
|
||||||
|
# All plugins of this project are integrated via CocoaPods. Turning
|
||||||
|
# off the (default-on) Swift Package Manager integration keeps the
|
||||||
|
# build on that single, deterministic path.
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
|
||||||
|
- name: Inspect Environment
|
||||||
|
run: |
|
||||||
|
xcodebuild -version
|
||||||
|
pod --version
|
||||||
|
flutter --version
|
||||||
|
flutter doctor -v
|
||||||
|
|
||||||
|
- name: Fetch Flutter Dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build iOS (Unsigned)
|
||||||
|
run: flutter build ios --release --no-codesign
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd build/ios/iphoneos
|
||||||
|
rm -rf Payload oggauer-gemeindetrommler.ipa
|
||||||
|
mkdir Payload
|
||||||
|
cp -R Runner.app Payload/
|
||||||
|
zip -qry oggauer-gemeindetrommler.ipa Payload
|
||||||
|
ls -lh oggauer-gemeindetrommler.ipa
|
||||||
|
|
||||||
|
- name: Upload IPA Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-ios
|
||||||
|
path: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish IPA to GitHub Release
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'latest' }}
|
||||||
|
name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'Oggauer Gemeindetrommler - LATEST' }}
|
||||||
|
files: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
|
||||||
|
make_latest: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload Xcode Logs (on failure)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-build-logs
|
||||||
|
path: |
|
||||||
|
~/Library/Logs/DiagnosticReports/*.ips
|
||||||
|
build/ios/**/*.log
|
||||||
|
ios/Podfile.lock
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
@@ -42,3 +42,9 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/app/build/
|
||||||
|
.gradle/
|
||||||
|
release-apks/
|
||||||
|
gitea-runner/data/
|
||||||
|
gitea-runner/.env
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -1,92 +1,202 @@
|
|||||||
# 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
|
### 3. 💡 BürgerForum & Ideen (`CitizensForum`)
|
||||||
- [ ] [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:
|
- 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.
|
||||||
|
|
||||||
```
|
### 4. 📬 Kontakt & Admin-Posteingang (`Contact`)
|
||||||
cd existing_repo
|
- **Bürger-Kontaktformular:**
|
||||||
git remote add origin https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1.git
|
- Einfache und direkte Kontaktaufnahme mit dem Bürgerforum.
|
||||||
git branch -M main
|
- Pflichtangaben: Name, E-Mail-Adresse und Anliegen.
|
||||||
git push -uf origin main
|
- 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) `3.47.x` (Dart SDK `>=3.11`), Android `minSdk 23`, iOS `>= 15.0`
|
||||||
|
- **Backend & Cloud Services (Google Firebase):**
|
||||||
|
- `firebase_core` (Initialisierung)
|
||||||
|
- `firebase_auth` (E-Mail & Passwort Authentifizierung, Gast-Modus)
|
||||||
|
- `cloud_firestore` (NoSQL Echtzeit-Datenbank)
|
||||||
|
- `firebase_storage` (Cloud-Bilderspeicher)
|
||||||
|
- **Externe APIs & Web-Services:**
|
||||||
|
- WordPress REST-API (`http`-Paket für Bürgerforum-Aussendungen)
|
||||||
|
- `shared_preferences` (Offline-Zwischenspeicherung)
|
||||||
|
- **Dokumentenverarbeitung & PDF:**
|
||||||
|
- `pdf`, `printing` (PDF-Generierung, In-App Vorschau & Druck)
|
||||||
|
- `url_launcher` (System-Browser, Telefon- und E-Mail-Intents)
|
||||||
|
- **Bildverarbeitung:**
|
||||||
|
- `image_picker`, `cached_network_image`, `image`
|
||||||
|
- **State Management:**
|
||||||
|
- `provider` (`MultiProvider` Architektur)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
## Collaborate with your team
|
Die Flutter-Version ist zentral gepinnt (`FLUTTER_VERSION` in beiden Workflows, `environment` in `pubspec.yaml`) und muss bei einem Upgrade an allen drei Stellen gemeinsam angehoben werden.
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
### Android (Gitea Actions, `.gitea/workflows/build-apk.yaml`)
|
||||||
- [ ] [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)
|
|
||||||
|
|
||||||
## Test and Deploy
|
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.44.0` (letztes von Cirrus Labs veröffentlichtes Image, liefert JDK 21 + Android SDK 36). Das darin enthaltene Flutter-SDK wird im ersten Schritt per `git checkout` auf `FLUTTER_VERSION` umgeschaltet.
|
||||||
|
- **Runner:** Oracle Cloud ARM64 – x86_64-Werkzeuge (AAPT2, `gen_snapshot`) laufen über QEMU (`scripts/ci/qemu_intercept.c`).
|
||||||
|
- **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus.
|
||||||
|
- **Erzeugte APK-Pakete:**
|
||||||
|
1. `oggauer-gemeindetrommler-universal-release.apk` (Universelle Version für alle Android-Smartphones).
|
||||||
|
2. `oggauer-gemeindetrommler-arm64-v8a.apk` (Optimiert für moderne 64-Bit Geräte).
|
||||||
|
3. `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Geräte).
|
||||||
|
4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren).
|
||||||
|
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als Artefakte im Gitea-Reiter **Aktionen** sowie im Release `latest` zum Download bereit.
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
### iOS (GitHub Actions, `.github/workflows/build-ios.yaml`)
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
- **Runner:** `macos-26` (Xcode 26), Flutter über `subosito/flutter-action`.
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
- **Plugins:** ausschließlich über CocoaPods (Swift Package Manager ist im Workflow bewusst deaktiviert); `ios/Podfile.lock` wird nicht versioniert, `pod install` läuft bei jedem Build frisch.
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
- **Ergebnis:** unsigniertes IPA (`flutter build ios --release --no-codesign`) als Artefakt `oggauer-gemeindetrommler-ios` – für die Verteilung muss es nachträglich signiert werden.
|
||||||
- [ ] [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)
|
|
||||||
|
|
||||||
***
|
---
|
||||||
|
|
||||||
# Editing this README
|
## 🛠️ Einrichtung & Lokale Ausführung
|
||||||
|
|
||||||
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.
|
### Voraussetzungen:
|
||||||
|
- **Flutter SDK:** `3.47.x` (stable) – identisch zur in den Workflows gepinnten Version
|
||||||
|
- **Dart SDK:** Im Flutter SDK enthalten
|
||||||
|
- **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten
|
||||||
|
|
||||||
## Suggestions for a good README
|
### Befehle:
|
||||||
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.
|
```bash
|
||||||
|
# 1. Abhängigkeiten laden
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
## Name
|
# 2. Statische Code-Analyse ausführen
|
||||||
Choose a self-explaining name for your project.
|
flutter analyze
|
||||||
|
|
||||||
## Description
|
# 3. Unit-Tests ausführen
|
||||||
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.
|
flutter test
|
||||||
|
|
||||||
## Badges
|
# 4. App im Entwicklungsmodus starten
|
||||||
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.
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
## 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
|
## 📄 Lizenz & Urheberschaft
|
||||||
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.
|
Entwickelt für das **Bürgerforum Oggau**. Private & gemeinnützige Verwendung.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
gradle-wrapper.jar
|
|
||||||
/.gradle
|
/.gradle
|
||||||
/captures/
|
/captures/
|
||||||
/gradlew
|
|
||||||
/gradlew.bat
|
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
|
|
||||||
|
|||||||
+39
-47
@@ -1,36 +1,14 @@
|
|||||||
def localProperties = new Properties()
|
plugins {
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
id "com.android.application"
|
||||||
if (localPropertiesFile.exists()) {
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
// (With android.builtInKotlin=false the Flutter plugin applies kotlin-android itself.)
|
||||||
localProperties.load(reader)
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
}
|
id "com.google.gms.google-services"
|
||||||
}
|
}
|
||||||
|
|
||||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
|
||||||
if (flutterRoot == null) {
|
|
||||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
if (flutterVersionCode == null) {
|
|
||||||
flutterVersionCode = '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
if (flutterVersionName == null) {
|
|
||||||
flutterVersionName = '1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
// START: FlutterFire Configuration
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
// END: FlutterFire Configuration
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "com.oggauergemeindetrommler.app"
|
namespace "com.oggauergemeindetrommler.app"
|
||||||
compileSdkVersion flutter.compileSdkVersion
|
compileSdk flutter.compileSdkVersion
|
||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -38,24 +16,19 @@ android {
|
|||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
buildFeatures {
|
||||||
jvmTarget = '17'
|
buildConfig = true
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId "com.oggauergemeindetrommler.app"
|
applicationId "com.oggauergemeindetrommler.app"
|
||||||
// You can update the following values to match your application needs.
|
// Flutter 3.47 defaults to 24; 23 (Android 6.0) is still supported and
|
||||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
// is also the minimum required by the current Firebase Android SDKs.
|
||||||
minSdkVersion 23
|
minSdkVersion flutter.minSdkVersion
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdk flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
// Read from pubspec.yaml (via local.properties); defaults to 1 / "1.0".
|
||||||
versionName flutterVersionName
|
versionCode flutter.versionCode
|
||||||
multiDexEnabled true
|
versionName flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -65,12 +38,31 @@ android {
|
|||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `flutter build apk --split-per-abi` makes the Flutter Gradle plugin enable ABI
|
||||||
|
// splits for the requested target platforms but switch the universal APK off.
|
||||||
|
// Turn it back on so a single Gradle run yields the per-ABI APKs *and* the
|
||||||
|
// universal one (the plugin copies all of them to build/app/outputs/flutter-apk/,
|
||||||
|
// the universal one as app-release.apk). Only when splitting: for plain builds
|
||||||
|
// the plugin sets ndk.abiFilters instead, which AGP does not allow together
|
||||||
|
// with splits. Do not use AGP's `packageReleaseUniversalApk` task for this -
|
||||||
|
// it derives the universal APK from an App Bundle, and bundles cannot be
|
||||||
|
// built while ABI splits are enabled.
|
||||||
|
if (project.findProperty("split-per-abi")?.toString()?.toBoolean()) {
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
universalApk true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.oggauergemeindetrommler.app">
|
|
||||||
<application
|
<application
|
||||||
android:label="Oggauer Gemeindetrommler"
|
android:label="Oggauer Gemeindetrommler"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -35,5 +34,24 @@
|
|||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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>
|
</manifest>
|
||||||
|
|
||||||
|
|||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package com.oggauergemeindetrommler.oggauer_gemeindetrommler
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
|
||||||
}
|
|
||||||
+7
-30
@@ -1,19 +1,3 @@
|
|||||||
buildscript {
|
|
||||||
ext.kotlin_version = '1.9.22'
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
classpath 'com.android.tools.build:gradle:8.5.2'
|
|
||||||
// START: FlutterFire Configuration
|
|
||||||
classpath 'com.google.gms:google-services:4.4.1'
|
|
||||||
// END: FlutterFire Configuration
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -21,25 +5,18 @@ allprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.buildDir = '../build'
|
// Keep all Gradle outputs under <repo>/build so the Flutter CLI finds the APKs.
|
||||||
|
// (Project.buildDir was removed in Gradle 9 -> use layout.buildDirectory.)
|
||||||
|
def newBuildDir = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
project.layout.buildDirectory.value(newBuildDir.dir(project.name))
|
||||||
|
|
||||||
// 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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.layout.buildDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M -Dandroid.prefs.root=C:/Users/nicol/.android
|
# vfork keeps process spawning cheap under QEMU user-mode emulation (ARM64 CI runner).
|
||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Djdk.lang.Process.launchMechanism=vfork
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
# Stay on the legacy AGP DSL / external Kotlin plugin until all Flutter plugins
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
# support the AGP 9 built-in Kotlin (same as the Flutter 3.47 project template).
|
||||||
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
|
android.newDsl=false
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# Kotlin incremental compilation stores project-relative paths and breaks on Windows
|
||||||
|
# when the pub cache (C:) and the project live on different drives
|
||||||
|
# ("this and base files have different roots"). CI builds are clean anyway.
|
||||||
|
kotlin.incremental=false
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-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
|
||||||
+30
-8
@@ -1,11 +1,33 @@
|
|||||||
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")
|
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
||||||
def properties = new Properties()
|
|
||||||
|
|
||||||
assert localPropertiesFile.exists()
|
repositories {
|
||||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
// Versions follow the Flutter 3.47 project template (see
|
||||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
// packages/flutter_tools/lib/src/android/gradle_utils.dart in the Flutter SDK).
|
||||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
// Flutter refuses to build with AGP < 8.11.1, Kotlin < 2.2.20 or Gradle < 8.14.
|
||||||
|
plugins {
|
||||||
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
|
id "com.android.application" version "9.1.0" apply false
|
||||||
|
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
|
||||||
|
id "com.google.gms.google-services" version "4.5.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include ":app"
|
||||||
|
|||||||
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
|
||||||
@@ -32,3 +32,8 @@ Runner/GeneratedPluginRegistrant.*
|
|||||||
!default.mode2v3
|
!default.mode2v3
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
!default.perspectivev3
|
!default.perspectivev3
|
||||||
|
|
||||||
|
# Podfile.lock is intentionally not tracked: development happens on Windows (no
|
||||||
|
# CocoaPods), and the iOS CI always runs a fresh `pod install`. A stale lock file
|
||||||
|
# only causes "sandbox is not in sync" / incompatible pod version errors.
|
||||||
|
Podfile.lock
|
||||||
|
|||||||
@@ -20,7 +20,5 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>11.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
CODE_SIGNING_ALLOWED=NO
|
||||||
|
CODE_SIGNING_REQUIRED=NO
|
||||||
|
CODE_SIGN_IDENTITY=
|
||||||
|
|||||||
+11
-3
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Flutter 3.47+ requires iOS 15.0 as the minimum deployment target.
|
||||||
# platform :ios, '12.0'
|
platform :ios, '15.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -31,11 +31,19 @@ target 'Runner' do
|
|||||||
use_frameworks!
|
use_frameworks!
|
||||||
use_modular_headers!
|
use_modular_headers!
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
# Keep every pod on the same deployment target as the app so old
|
||||||
|
# podspecs do not drag the minimum back below what Flutter supports.
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||||
|
# Pods never need to be signed; the CI produces an unsigned IPA.
|
||||||
|
config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
|
||||||
|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,842 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- abseil/algorithm (1.20211102.0):
|
|
||||||
- abseil/algorithm/algorithm (= 1.20211102.0)
|
|
||||||
- abseil/algorithm/container (= 1.20211102.0)
|
|
||||||
- abseil/algorithm/algorithm (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/algorithm/container (1.20211102.0):
|
|
||||||
- abseil/algorithm/algorithm
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/base (1.20211102.0):
|
|
||||||
- abseil/base/atomic_hook (= 1.20211102.0)
|
|
||||||
- abseil/base/base (= 1.20211102.0)
|
|
||||||
- abseil/base/base_internal (= 1.20211102.0)
|
|
||||||
- abseil/base/config (= 1.20211102.0)
|
|
||||||
- abseil/base/core_headers (= 1.20211102.0)
|
|
||||||
- abseil/base/dynamic_annotations (= 1.20211102.0)
|
|
||||||
- abseil/base/endian (= 1.20211102.0)
|
|
||||||
- abseil/base/errno_saver (= 1.20211102.0)
|
|
||||||
- abseil/base/fast_type_id (= 1.20211102.0)
|
|
||||||
- abseil/base/log_severity (= 1.20211102.0)
|
|
||||||
- abseil/base/malloc_internal (= 1.20211102.0)
|
|
||||||
- abseil/base/pretty_function (= 1.20211102.0)
|
|
||||||
- abseil/base/raw_logging_internal (= 1.20211102.0)
|
|
||||||
- abseil/base/spinlock_wait (= 1.20211102.0)
|
|
||||||
- abseil/base/strerror (= 1.20211102.0)
|
|
||||||
- abseil/base/throw_delegate (= 1.20211102.0)
|
|
||||||
- abseil/base/atomic_hook (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/base (1.20211102.0):
|
|
||||||
- abseil/base/atomic_hook
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/log_severity
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/base/spinlock_wait
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/base/base_internal (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/base/config (1.20211102.0)
|
|
||||||
- abseil/base/core_headers (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/dynamic_annotations (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/errno_saver (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/fast_type_id (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/log_severity (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/malloc_internal (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/base/pretty_function (1.20211102.0)
|
|
||||||
- abseil/base/raw_logging_internal (1.20211102.0):
|
|
||||||
- abseil/base/atomic_hook
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/log_severity
|
|
||||||
- abseil/base/spinlock_wait (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/errno_saver
|
|
||||||
- abseil/base/strerror (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/errno_saver
|
|
||||||
- abseil/base/throw_delegate (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/container/common (1.20211102.0):
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/container/compressed_tuple (1.20211102.0):
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/container/container_memory (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/container/fixed_array (1.20211102.0):
|
|
||||||
- abseil/algorithm/algorithm
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/container/compressed_tuple
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/container/flat_hash_map (1.20211102.0):
|
|
||||||
- abseil/algorithm/container
|
|
||||||
- abseil/container/container_memory
|
|
||||||
- abseil/container/hash_function_defaults
|
|
||||||
- abseil/container/raw_hash_map
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/container/hash_function_defaults (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/hash/hash
|
|
||||||
- abseil/strings/cord
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/container/hash_policy_traits (1.20211102.0):
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/container/hashtable_debug_hooks (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/container/hashtablez_sampler (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/container/have_sse
|
|
||||||
- abseil/debugging/stacktrace
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/profiling/exponential_biased
|
|
||||||
- abseil/profiling/sample_recorder
|
|
||||||
- abseil/synchronization/synchronization
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/container/have_sse (1.20211102.0)
|
|
||||||
- abseil/container/inlined_vector (1.20211102.0):
|
|
||||||
- abseil/algorithm/algorithm
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/container/inlined_vector_internal
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/container/inlined_vector_internal (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/container/compressed_tuple
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/container/layout (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/container/raw_hash_map (1.20211102.0):
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/container/container_memory
|
|
||||||
- abseil/container/raw_hash_set
|
|
||||||
- abseil/container/raw_hash_set (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/container/common
|
|
||||||
- abseil/container/compressed_tuple
|
|
||||||
- abseil/container/container_memory
|
|
||||||
- abseil/container/hash_policy_traits
|
|
||||||
- abseil/container/hashtable_debug_hooks
|
|
||||||
- abseil/container/hashtablez_sampler
|
|
||||||
- abseil/container/have_sse
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/debugging/debugging_internal (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/errno_saver
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/debugging/demangle_internal (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/debugging/stacktrace (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/debugging/debugging_internal
|
|
||||||
- abseil/debugging/symbolize (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/malloc_internal
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/debugging/debugging_internal
|
|
||||||
- abseil/debugging/demangle_internal
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/functional/bind_front (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/container/compressed_tuple
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/functional/function_ref (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/hash/city (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/hash/hash (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/container/fixed_array
|
|
||||||
- abseil/hash/city
|
|
||||||
- abseil/hash/low_level_hash
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/types/variant
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/hash/low_level_hash (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/memory (1.20211102.0):
|
|
||||||
- abseil/memory/memory (= 1.20211102.0)
|
|
||||||
- abseil/memory/memory (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/meta (1.20211102.0):
|
|
||||||
- abseil/meta/type_traits (= 1.20211102.0)
|
|
||||||
- abseil/meta/type_traits (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/numeric/bits (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/numeric/int128 (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/representation (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/profiling/exponential_biased (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/profiling/sample_recorder (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/synchronization/synchronization
|
|
||||||
- abseil/time/time
|
|
||||||
- abseil/random/distributions (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/random/internal/distribution_caller
|
|
||||||
- abseil/random/internal/fast_uniform_bits
|
|
||||||
- abseil/random/internal/fastmath
|
|
||||||
- abseil/random/internal/generate_real
|
|
||||||
- abseil/random/internal/iostream_state_saver
|
|
||||||
- abseil/random/internal/traits
|
|
||||||
- abseil/random/internal/uniform_helper
|
|
||||||
- abseil/random/internal/wide_multiply
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/random/internal/distribution_caller (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/fast_type_id
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/random/internal/fast_uniform_bits (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/random/internal/fastmath (1.20211102.0):
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/random/internal/generate_real (1.20211102.0):
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/random/internal/fastmath
|
|
||||||
- abseil/random/internal/traits
|
|
||||||
- abseil/random/internal/iostream_state_saver (1.20211102.0):
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/random/internal/nonsecure_base (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/random/internal/pool_urbg
|
|
||||||
- abseil/random/internal/salted_seed_seq
|
|
||||||
- abseil/random/internal/seed_material
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/random/internal/pcg_engine (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/random/internal/fastmath
|
|
||||||
- abseil/random/internal/iostream_state_saver
|
|
||||||
- abseil/random/internal/platform (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/random/internal/pool_urbg (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/random/internal/randen
|
|
||||||
- abseil/random/internal/seed_material
|
|
||||||
- abseil/random/internal/traits
|
|
||||||
- abseil/random/seed_gen_exception
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/random/internal/randen (1.20211102.0):
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/random/internal/platform
|
|
||||||
- abseil/random/internal/randen_hwaes
|
|
||||||
- abseil/random/internal/randen_slow
|
|
||||||
- abseil/random/internal/randen_engine (1.20211102.0):
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/random/internal/iostream_state_saver
|
|
||||||
- abseil/random/internal/randen
|
|
||||||
- abseil/random/internal/randen_hwaes (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/random/internal/platform
|
|
||||||
- abseil/random/internal/randen_hwaes_impl
|
|
||||||
- abseil/random/internal/randen_hwaes_impl (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/random/internal/platform
|
|
||||||
- abseil/random/internal/randen_slow (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/random/internal/platform
|
|
||||||
- abseil/random/internal/salted_seed_seq (1.20211102.0):
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/random/internal/seed_material
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/random/internal/seed_material (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/random/internal/fast_uniform_bits
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/random/internal/traits (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/random/internal/uniform_helper (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/random/internal/traits
|
|
||||||
- abseil/random/internal/wide_multiply (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/random/internal/traits
|
|
||||||
- abseil/random/random (1.20211102.0):
|
|
||||||
- abseil/random/distributions
|
|
||||||
- abseil/random/internal/nonsecure_base
|
|
||||||
- abseil/random/internal/pcg_engine
|
|
||||||
- abseil/random/internal/pool_urbg
|
|
||||||
- abseil/random/internal/randen_engine
|
|
||||||
- abseil/random/seed_sequences
|
|
||||||
- abseil/random/seed_gen_exception (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/random/seed_sequences (1.20211102.0):
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/random/internal/nonsecure_base
|
|
||||||
- abseil/random/internal/pool_urbg
|
|
||||||
- abseil/random/internal/salted_seed_seq
|
|
||||||
- abseil/random/internal/seed_material
|
|
||||||
- abseil/random/seed_gen_exception
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/status/status (1.20211102.0):
|
|
||||||
- abseil/base/atomic_hook
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/debugging/stacktrace
|
|
||||||
- abseil/debugging/symbolize
|
|
||||||
- abseil/functional/function_ref
|
|
||||||
- abseil/strings/cord
|
|
||||||
- abseil/strings/str_format
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/status/statusor (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/status/status
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/variant
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/strings/cord (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/container/fixed_array
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/functional/function_ref
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/strings/cord_internal
|
|
||||||
- abseil/strings/cordz_functions
|
|
||||||
- abseil/strings/cordz_info
|
|
||||||
- abseil/strings/cordz_statistics
|
|
||||||
- abseil/strings/cordz_update_scope
|
|
||||||
- abseil/strings/cordz_update_tracker
|
|
||||||
- abseil/strings/internal
|
|
||||||
- abseil/strings/str_format
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/strings/cord_internal (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/container/compressed_tuple
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/container/layout
|
|
||||||
- abseil/functional/function_ref
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/strings/cordz_functions (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/profiling/exponential_biased
|
|
||||||
- abseil/strings/cordz_handle (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/synchronization/synchronization
|
|
||||||
- abseil/strings/cordz_info (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/container/inlined_vector
|
|
||||||
- abseil/debugging/stacktrace
|
|
||||||
- abseil/strings/cord_internal
|
|
||||||
- abseil/strings/cordz_functions
|
|
||||||
- abseil/strings/cordz_handle
|
|
||||||
- abseil/strings/cordz_statistics
|
|
||||||
- abseil/strings/cordz_update_tracker
|
|
||||||
- abseil/synchronization/synchronization
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/strings/cordz_statistics (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/strings/cordz_update_tracker
|
|
||||||
- abseil/strings/cordz_update_scope (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/strings/cord_internal
|
|
||||||
- abseil/strings/cordz_info
|
|
||||||
- abseil/strings/cordz_update_tracker
|
|
||||||
- abseil/strings/cordz_update_tracker (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/strings/internal (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/strings/str_format (1.20211102.0):
|
|
||||||
- abseil/strings/str_format_internal
|
|
||||||
- abseil/strings/str_format_internal (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/functional/function_ref
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/numeric/representation
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/types/optional
|
|
||||||
- abseil/types/span
|
|
||||||
- abseil/strings/strings (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/endian
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/numeric/bits
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/strings/internal
|
|
||||||
- abseil/synchronization/graphcycles_internal (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/malloc_internal
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/synchronization/kernel_timeout_internal (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/time/time
|
|
||||||
- abseil/synchronization/synchronization (1.20211102.0):
|
|
||||||
- abseil/base/atomic_hook
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/dynamic_annotations
|
|
||||||
- abseil/base/malloc_internal
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/debugging/stacktrace
|
|
||||||
- abseil/debugging/symbolize
|
|
||||||
- abseil/synchronization/graphcycles_internal
|
|
||||||
- abseil/synchronization/kernel_timeout_internal
|
|
||||||
- abseil/time/time
|
|
||||||
- abseil/time (1.20211102.0):
|
|
||||||
- abseil/time/internal (= 1.20211102.0)
|
|
||||||
- abseil/time/time (= 1.20211102.0)
|
|
||||||
- abseil/time/internal (1.20211102.0):
|
|
||||||
- abseil/time/internal/cctz (= 1.20211102.0)
|
|
||||||
- abseil/time/internal/cctz (1.20211102.0):
|
|
||||||
- abseil/time/internal/cctz/civil_time (= 1.20211102.0)
|
|
||||||
- abseil/time/internal/cctz/time_zone (= 1.20211102.0)
|
|
||||||
- abseil/time/internal/cctz/civil_time (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/time/internal/cctz/time_zone (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/time/internal/cctz/civil_time
|
|
||||||
- abseil/time/time (1.20211102.0):
|
|
||||||
- abseil/base/base
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/numeric/int128
|
|
||||||
- abseil/strings/strings
|
|
||||||
- abseil/time/internal/cctz/civil_time
|
|
||||||
- abseil/time/internal/cctz/time_zone
|
|
||||||
- abseil/types (1.20211102.0):
|
|
||||||
- abseil/types/any (= 1.20211102.0)
|
|
||||||
- abseil/types/bad_any_cast (= 1.20211102.0)
|
|
||||||
- abseil/types/bad_any_cast_impl (= 1.20211102.0)
|
|
||||||
- abseil/types/bad_optional_access (= 1.20211102.0)
|
|
||||||
- abseil/types/bad_variant_access (= 1.20211102.0)
|
|
||||||
- abseil/types/compare (= 1.20211102.0)
|
|
||||||
- abseil/types/optional (= 1.20211102.0)
|
|
||||||
- abseil/types/span (= 1.20211102.0)
|
|
||||||
- abseil/types/variant (= 1.20211102.0)
|
|
||||||
- abseil/types/any (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/fast_type_id
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/bad_any_cast
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/types/bad_any_cast (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/types/bad_any_cast_impl
|
|
||||||
- abseil/types/bad_any_cast_impl (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/types/bad_optional_access (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/types/bad_variant_access (1.20211102.0):
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/raw_logging_internal
|
|
||||||
- abseil/types/compare (1.20211102.0):
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/optional (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/memory/memory
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/bad_optional_access
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/types/span (1.20211102.0):
|
|
||||||
- abseil/algorithm/algorithm
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/base/throw_delegate
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/variant (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/base/core_headers
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- abseil/types/bad_variant_access
|
|
||||||
- abseil/utility/utility
|
|
||||||
- abseil/utility/utility (1.20211102.0):
|
|
||||||
- abseil/base/base_internal
|
|
||||||
- abseil/base/config
|
|
||||||
- abseil/meta/type_traits
|
|
||||||
- BoringSSL-GRPC (0.0.24):
|
|
||||||
- BoringSSL-GRPC/Implementation (= 0.0.24)
|
|
||||||
- BoringSSL-GRPC/Interface (= 0.0.24)
|
|
||||||
- BoringSSL-GRPC/Implementation (0.0.24):
|
|
||||||
- BoringSSL-GRPC/Interface (= 0.0.24)
|
|
||||||
- BoringSSL-GRPC/Interface (0.0.24)
|
|
||||||
- cloud_firestore (4.6.0):
|
|
||||||
- Firebase/Firestore (= 10.7.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
|
||||||
- Firebase/Auth (10.7.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseAuth (~> 10.7.0)
|
|
||||||
- Firebase/CoreOnly (10.7.0):
|
|
||||||
- FirebaseCore (= 10.7.0)
|
|
||||||
- Firebase/Firestore (10.7.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseFirestore (~> 10.7.0)
|
|
||||||
- Firebase/Storage (10.7.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseStorage (~> 10.7.0)
|
|
||||||
- firebase_auth (4.5.0):
|
|
||||||
- Firebase/Auth (= 10.7.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- firebase_core (2.11.0):
|
|
||||||
- Firebase/CoreOnly (= 10.7.0)
|
|
||||||
- Flutter
|
|
||||||
- firebase_storage (11.1.2):
|
|
||||||
- Firebase/Storage (= 10.7.0)
|
|
||||||
- firebase_core
|
|
||||||
- Flutter
|
|
||||||
- FirebaseAppCheckInterop (10.9.0)
|
|
||||||
- FirebaseAuth (10.7.0):
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
|
|
||||||
- GoogleUtilities/Environment (~> 7.8)
|
|
||||||
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
|
|
||||||
- FirebaseAuthInterop (10.9.0)
|
|
||||||
- FirebaseCore (10.7.0):
|
|
||||||
- FirebaseCoreInternal (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 7.8)
|
|
||||||
- GoogleUtilities/Logger (~> 7.8)
|
|
||||||
- FirebaseCoreExtension (10.9.0):
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- FirebaseCoreInternal (10.9.0):
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
|
||||||
- FirebaseFirestore (10.7.0):
|
|
||||||
- abseil/algorithm (~> 1.20211102.0)
|
|
||||||
- abseil/base (~> 1.20211102.0)
|
|
||||||
- abseil/container/flat_hash_map (~> 1.20211102.0)
|
|
||||||
- abseil/memory (~> 1.20211102.0)
|
|
||||||
- abseil/meta (~> 1.20211102.0)
|
|
||||||
- abseil/strings/strings (~> 1.20211102.0)
|
|
||||||
- abseil/time (~> 1.20211102.0)
|
|
||||||
- abseil/types (~> 1.20211102.0)
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- "gRPC-C++ (~> 1.44.0)"
|
|
||||||
- leveldb-library (~> 1.22)
|
|
||||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
|
||||||
- FirebaseStorage (10.7.0):
|
|
||||||
- FirebaseAppCheckInterop (~> 10.0)
|
|
||||||
- FirebaseAuthInterop (~> 10.0)
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- FirebaseCoreExtension (~> 10.0)
|
|
||||||
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
|
|
||||||
- Flutter (1.0.0)
|
|
||||||
- FMDB (2.7.5):
|
|
||||||
- FMDB/standard (= 2.7.5)
|
|
||||||
- FMDB/standard (2.7.5)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Network
|
|
||||||
- GoogleUtilities/Environment (7.11.1):
|
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
|
||||||
- GoogleUtilities/Logger (7.11.1):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Network (7.11.1):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- "GoogleUtilities/NSData+zlib"
|
|
||||||
- GoogleUtilities/Reachability
|
|
||||||
- "GoogleUtilities/NSData+zlib (7.11.1)"
|
|
||||||
- GoogleUtilities/Reachability (7.11.1):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- "gRPC-C++ (1.44.0)":
|
|
||||||
- "gRPC-C++/Implementation (= 1.44.0)"
|
|
||||||
- "gRPC-C++/Interface (= 1.44.0)"
|
|
||||||
- "gRPC-C++/Implementation (1.44.0)":
|
|
||||||
- abseil/base/base (= 1.20211102.0)
|
|
||||||
- abseil/base/core_headers (= 1.20211102.0)
|
|
||||||
- abseil/container/flat_hash_map (= 1.20211102.0)
|
|
||||||
- abseil/container/inlined_vector (= 1.20211102.0)
|
|
||||||
- abseil/functional/bind_front (= 1.20211102.0)
|
|
||||||
- abseil/hash/hash (= 1.20211102.0)
|
|
||||||
- abseil/memory/memory (= 1.20211102.0)
|
|
||||||
- abseil/random/random (= 1.20211102.0)
|
|
||||||
- abseil/status/status (= 1.20211102.0)
|
|
||||||
- abseil/status/statusor (= 1.20211102.0)
|
|
||||||
- abseil/strings/cord (= 1.20211102.0)
|
|
||||||
- abseil/strings/str_format (= 1.20211102.0)
|
|
||||||
- abseil/strings/strings (= 1.20211102.0)
|
|
||||||
- abseil/synchronization/synchronization (= 1.20211102.0)
|
|
||||||
- abseil/time/time (= 1.20211102.0)
|
|
||||||
- abseil/types/optional (= 1.20211102.0)
|
|
||||||
- abseil/types/variant (= 1.20211102.0)
|
|
||||||
- abseil/utility/utility (= 1.20211102.0)
|
|
||||||
- "gRPC-C++/Interface (= 1.44.0)"
|
|
||||||
- gRPC-Core (= 1.44.0)
|
|
||||||
- "gRPC-C++/Interface (1.44.0)"
|
|
||||||
- gRPC-Core (1.44.0):
|
|
||||||
- gRPC-Core/Implementation (= 1.44.0)
|
|
||||||
- gRPC-Core/Interface (= 1.44.0)
|
|
||||||
- gRPC-Core/Implementation (1.44.0):
|
|
||||||
- abseil/base/base (= 1.20211102.0)
|
|
||||||
- abseil/base/core_headers (= 1.20211102.0)
|
|
||||||
- abseil/container/flat_hash_map (= 1.20211102.0)
|
|
||||||
- abseil/container/inlined_vector (= 1.20211102.0)
|
|
||||||
- abseil/functional/bind_front (= 1.20211102.0)
|
|
||||||
- abseil/hash/hash (= 1.20211102.0)
|
|
||||||
- abseil/memory/memory (= 1.20211102.0)
|
|
||||||
- abseil/random/random (= 1.20211102.0)
|
|
||||||
- abseil/status/status (= 1.20211102.0)
|
|
||||||
- abseil/status/statusor (= 1.20211102.0)
|
|
||||||
- abseil/strings/cord (= 1.20211102.0)
|
|
||||||
- abseil/strings/str_format (= 1.20211102.0)
|
|
||||||
- abseil/strings/strings (= 1.20211102.0)
|
|
||||||
- abseil/synchronization/synchronization (= 1.20211102.0)
|
|
||||||
- abseil/time/time (= 1.20211102.0)
|
|
||||||
- abseil/types/optional (= 1.20211102.0)
|
|
||||||
- abseil/types/variant (= 1.20211102.0)
|
|
||||||
- abseil/utility/utility (= 1.20211102.0)
|
|
||||||
- BoringSSL-GRPC (= 0.0.24)
|
|
||||||
- gRPC-Core/Interface (= 1.44.0)
|
|
||||||
- Libuv-gRPC (= 0.0.10)
|
|
||||||
- gRPC-Core/Interface (1.44.0)
|
|
||||||
- GTMSessionFetcher/Core (3.1.1)
|
|
||||||
- image_picker_ios (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- leveldb-library (1.22.2)
|
|
||||||
- Libuv-gRPC (0.0.10):
|
|
||||||
- Libuv-gRPC/Implementation (= 0.0.10)
|
|
||||||
- Libuv-gRPC/Interface (= 0.0.10)
|
|
||||||
- Libuv-gRPC/Implementation (0.0.10):
|
|
||||||
- Libuv-gRPC/Interface (= 0.0.10)
|
|
||||||
- Libuv-gRPC/Interface (0.0.10)
|
|
||||||
- nanopb (2.30909.0):
|
|
||||||
- nanopb/decode (= 2.30909.0)
|
|
||||||
- nanopb/encode (= 2.30909.0)
|
|
||||||
- nanopb/decode (2.30909.0)
|
|
||||||
- nanopb/encode (2.30909.0)
|
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- PromisesObjC (2.2.0)
|
|
||||||
- sqflite (0.0.2):
|
|
||||||
- Flutter
|
|
||||||
- FMDB (>= 2.7.5)
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`)
|
|
||||||
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
|
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
|
||||||
- firebase_storage (from `.symlinks/plugins/firebase_storage/ios`)
|
|
||||||
- Flutter (from `Flutter`)
|
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
|
|
||||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- abseil
|
|
||||||
- BoringSSL-GRPC
|
|
||||||
- Firebase
|
|
||||||
- FirebaseAppCheckInterop
|
|
||||||
- FirebaseAuth
|
|
||||||
- FirebaseAuthInterop
|
|
||||||
- FirebaseCore
|
|
||||||
- FirebaseCoreExtension
|
|
||||||
- FirebaseCoreInternal
|
|
||||||
- FirebaseFirestore
|
|
||||||
- FirebaseStorage
|
|
||||||
- FMDB
|
|
||||||
- GoogleUtilities
|
|
||||||
- "gRPC-C++"
|
|
||||||
- gRPC-Core
|
|
||||||
- GTMSessionFetcher
|
|
||||||
- leveldb-library
|
|
||||||
- Libuv-gRPC
|
|
||||||
- nanopb
|
|
||||||
- PromisesObjC
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
|
||||||
cloud_firestore:
|
|
||||||
:path: ".symlinks/plugins/cloud_firestore/ios"
|
|
||||||
firebase_auth:
|
|
||||||
:path: ".symlinks/plugins/firebase_auth/ios"
|
|
||||||
firebase_core:
|
|
||||||
:path: ".symlinks/plugins/firebase_core/ios"
|
|
||||||
firebase_storage:
|
|
||||||
:path: ".symlinks/plugins/firebase_storage/ios"
|
|
||||||
Flutter:
|
|
||||||
:path: Flutter
|
|
||||||
image_picker_ios:
|
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
|
||||||
path_provider_foundation:
|
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/ios"
|
|
||||||
sqflite:
|
|
||||||
:path: ".symlinks/plugins/sqflite/ios"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
abseil: ebe5b5529fb05d93a8bdb7951607be08b7fa71bc
|
|
||||||
BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33
|
|
||||||
cloud_firestore: 3809dfc121ab99b81bba51eb84ac6ca3f9033325
|
|
||||||
Firebase: 0219acf760880eeec8ce479895bd7767466d9f81
|
|
||||||
firebase_auth: 1eb69de8f57040c5c78bb3267412e9cb395fd08c
|
|
||||||
firebase_core: dee76ded6c693fdb38b8ea39aef7129e32e587a3
|
|
||||||
firebase_storage: f6fb3e54e886182b5ab5cf514da1d9ceb196fa2f
|
|
||||||
FirebaseAppCheckInterop: e69dde5cd51b88ee1b4339d6766b691272256f9b
|
|
||||||
FirebaseAuth: dd64c01631df724b09f33e584625775c52f7d71f
|
|
||||||
FirebaseAuthInterop: e53c08e60a02de17d1ab77c5032db8ae22d3a799
|
|
||||||
FirebaseCore: e317665b9d744727a97e623edbbed009320afdd7
|
|
||||||
FirebaseCoreExtension: d3e9bba2930a8033042112397cd9f006a1bb203d
|
|
||||||
FirebaseCoreInternal: d2b4acb827908e72eca47a9fd896767c3053921e
|
|
||||||
FirebaseFirestore: 3963a6edd1c84b4748dab3e2c62624a29d03eca1
|
|
||||||
FirebaseStorage: 4841efa304543e1f9e4ca116c559c7a1ea2a9d0f
|
|
||||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
|
||||||
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
|
|
||||||
"gRPC-C++": 9675f953ace2b3de7c506039d77be1f2e77a8db2
|
|
||||||
gRPC-Core: 943e491cb0d45598b0b0eb9e910c88080369290b
|
|
||||||
GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
|
|
||||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
|
||||||
leveldb-library: f03246171cce0484482ec291f88b6d563699ee06
|
|
||||||
Libuv-gRPC: 55e51798e14ef436ad9bc45d12d43b77b49df378
|
|
||||||
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
|
||||||
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
|
|
||||||
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
|
|
||||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 7d6b9db9243dbc65e9914f56de22b83205a8e90d
|
|
||||||
|
|
||||||
COCOAPODS: 1.12.1
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1300;
|
LastUpgradeCheck = 1510;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -363,13 +363,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -486,7 +486,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -503,13 +503,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -535,13 +535,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import UIKit
|
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@UIApplicationMain
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
|||||||
|
# Launch Screen Assets
|
||||||
|
|
||||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||||
|
|
||||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||||
@@ -26,6 +26,27 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
|
|
||||||
class AddScreen extends StatefulWidget {
|
|
||||||
final int initialIndex;
|
|
||||||
|
|
||||||
const AddScreen({Key? key, required this.initialIndex}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_AddScreenState createState() => _AddScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddScreenState extends State<AddScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _titleController = TextEditingController();
|
|
||||||
final _descriptionController = TextEditingController();
|
|
||||||
File? _imageFile;
|
|
||||||
bool _uploading = false;
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_selectedIndex = widget.initialIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> isFileSizeValid(File file, int maxSizeInMB) async {
|
|
||||||
final fileSize = await file.length();
|
|
||||||
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
|
||||||
return fileSize <= maxSizeInBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File> _cropImage(File imageFile) async {
|
|
||||||
final originalImage = img.decodeImage(await imageFile.readAsBytes());
|
|
||||||
|
|
||||||
if (originalImage != null) {
|
|
||||||
int originSize = min(originalImage.width, originalImage.height);
|
|
||||||
final croppedImage = img.copyCrop(originalImage,
|
|
||||||
x: (originalImage.width - originSize) ~/ 2,
|
|
||||||
y: (originalImage.height - originSize) ~/ 2,
|
|
||||||
width: originSize,
|
|
||||||
height: originSize);
|
|
||||||
final newPath =
|
|
||||||
imageFile.path.substring(0, imageFile.path.lastIndexOf('/'));
|
|
||||||
final newFile = File('$newPath/cropped.jpg');
|
|
||||||
await newFile.writeAsBytes(img.encodePng(croppedImage));
|
|
||||||
return newFile;
|
|
||||||
} else {
|
|
||||||
throw Exception('Unable to decode image file.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _getImage(ImageSource source) async {
|
|
||||||
final pickedFile = await ImagePicker().pickImage(source: source);
|
|
||||||
|
|
||||||
if (pickedFile != null) {
|
|
||||||
final tempFile = File(pickedFile.path);
|
|
||||||
final bool isValid = await isFileSizeValid(tempFile, 10);
|
|
||||||
if (isValid) {
|
|
||||||
// Bildinformationen lesen
|
|
||||||
final originalImage = img.decodeImage(await tempFile.readAsBytes());
|
|
||||||
|
|
||||||
if (originalImage != null) {
|
|
||||||
if (originalImage.width == originalImage.height) {
|
|
||||||
// Wenn das Bild bereits im 1:1-Format ist, überspringen Sie das Zuschneiden
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_imageFile = tempFile;
|
|
||||||
print("1:1");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Wenn das Bild nicht im 1:1-Format ist, schneiden Sie es zu
|
|
||||||
File croppedFile = await _cropImage(tempFile);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_imageFile = croppedFile;
|
|
||||||
});
|
|
||||||
print("decode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception('Unable to decode image file.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Die Dateigröße darf maximal 10 MB betragen.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addNewElementToFirestore(
|
|
||||||
String title, String description, File imageFile,
|
|
||||||
{required Function(bool) onComplete}) async {
|
|
||||||
String collectionName;
|
|
||||||
if (_selectedIndex == 0) {
|
|
||||||
collectionName = "CitizensForum";
|
|
||||||
} else if (_selectedIndex == 1) {
|
|
||||||
collectionName = "News";
|
|
||||||
} else {
|
|
||||||
collectionName = "Issues";
|
|
||||||
}
|
|
||||||
|
|
||||||
String imageUrl =
|
|
||||||
await uploadImageToFirebaseStorage(imageFile, collectionName);
|
|
||||||
String? userId = FirebaseAuth.instance.currentUser?.uid; // get the user ID
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FirebaseFirestore.instance.collection(collectionName).add({
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"imageUrl": imageUrl,
|
|
||||||
"createdDate": DateTime.now(),
|
|
||||||
"author_uid": userId, // add the user ID to the document
|
|
||||||
});
|
|
||||||
onComplete(true);
|
|
||||||
} catch (e) {
|
|
||||||
onComplete(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> uploadImageToFirebaseStorage(
|
|
||||||
File imageFile, String collectionName) async {
|
|
||||||
String? userId = FirebaseAuth.instance.currentUser?.uid;
|
|
||||||
if (userId != null) {
|
|
||||||
Reference ref = FirebaseStorage.instance
|
|
||||||
.ref()
|
|
||||||
.child("$collectionName/$userId/${DateTime.now().toString()}");
|
|
||||||
UploadTask uploadTask = ref.putFile(imageFile);
|
|
||||||
return await (await uploadTask).ref.getDownloadURL();
|
|
||||||
} else {
|
|
||||||
throw Exception('Cannot upload without user ID');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
String appBarTitle = '';
|
|
||||||
|
|
||||||
double keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
||||||
|
|
||||||
if (_selectedIndex == 0) {
|
|
||||||
appBarTitle = 'Neue BürgerForum Information';
|
|
||||||
} else if (_selectedIndex == 1) {
|
|
||||||
appBarTitle = 'Neue Neuigkeit';
|
|
||||||
} else if (_selectedIndex == 2) {
|
|
||||||
appBarTitle = 'Neuer Mängel';
|
|
||||||
}
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(appBarTitle),
|
|
||||||
actions: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: () {
|
|
||||||
_titleController.clear();
|
|
||||||
_descriptionController.clear();
|
|
||||||
setState(() {
|
|
||||||
_imageFile = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
onPressed: () async {
|
|
||||||
if (_formKey.currentState!.validate() &&
|
|
||||||
_imageFile != null &&
|
|
||||||
!_uploading) {
|
|
||||||
setState(() {
|
|
||||||
_uploading = true;
|
|
||||||
});
|
|
||||||
Navigator.pop(
|
|
||||||
context); // Fenster direkt schließen, nachdem der Upload-Button gedrückt wurde.
|
|
||||||
await addNewElementToFirestore(
|
|
||||||
_titleController.text,
|
|
||||||
_descriptionController.text,
|
|
||||||
_imageFile!, onComplete: (bool success) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_uploading = false;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(success
|
|
||||||
? 'Upload erfolgreich.'
|
|
||||||
: 'Upload fehlgeschlagen.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Titel',
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[200],
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
controller: _titleController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie einen Titel ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Beschreibung',
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[200],
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
controller: _descriptionController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine Beschreibung ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
_imageFile != null
|
|
||||||
? Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
double imageHeight =
|
|
||||||
max(0, constraints.maxHeight - keyboardHeight);
|
|
||||||
return Container(
|
|
||||||
height: imageHeight,
|
|
||||||
child: Image.file(_imageFile!),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
height: 200.0,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[200],
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 60,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Bild hinzufügen',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.photo_library,
|
|
||||||
size: 40),
|
|
||||||
title: const Text(
|
|
||||||
'Galerie auswählen',
|
|
||||||
style: TextStyle(fontSize: 20),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_getImage(ImageSource.gallery);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.camera_alt,
|
|
||||||
size: 40),
|
|
||||||
title: const Text(
|
|
||||||
'Kamera verwenden',
|
|
||||||
style: TextStyle(fontSize: 20),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_getImage(ImageSource.camera);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Bild hinzufügen',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:pdf/widgets.dart' as pdfWidgets;
|
|
||||||
import 'package:printing/printing.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
|
|
||||||
class DetailPage extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String datum;
|
|
||||||
final String description;
|
|
||||||
final String imageUrl;
|
|
||||||
|
|
||||||
const DetailPage({
|
|
||||||
Key? key,
|
|
||||||
required this.title,
|
|
||||||
required this.datum,
|
|
||||||
required this.description,
|
|
||||||
required this.imageUrl,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _printPdf() async {
|
|
||||||
final pdf = pdfWidgets.Document();
|
|
||||||
final image = await _loadNetworkImage(imageUrl);
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pdfWidgets.MultiPage(
|
|
||||||
build: (context) => [
|
|
||||||
pdfWidgets.Center(
|
|
||||||
child: pdfWidgets.SizedBox(
|
|
||||||
width: double.infinity, // Die Breite auf die maximal mögliche Breite setzen
|
|
||||||
child: pdfWidgets.AspectRatio(
|
|
||||||
aspectRatio: 1, // Sie können das Seitenverhältnis entsprechend Ihrem Bild anpassen
|
|
||||||
child: pdfWidgets.Image(image),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(
|
|
||||||
title,
|
|
||||||
style: pdfWidgets.TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: pdfWidgets.FontWeight.bold // Titel fett
|
|
||||||
)
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(datum, style: const pdfWidgets.TextStyle(fontSize: 14, )),
|
|
||||||
pdfWidgets.Paragraph(text: description, style: const pdfWidgets.TextStyle(fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await Printing.sharePdf(bytes: await pdf.save(), filename: '$title.pdf');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
|
|
||||||
final response = await http.get(Uri.parse(url));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to load network image.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final Uint8List bytes = response.bodyBytes;
|
|
||||||
final imageProvider = pdfWidgets.MemoryImage(bytes);
|
|
||||||
|
|
||||||
return imageProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(title),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
onPressed: _printPdf,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
placeholder: (context, url) => const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0, bottom: 8.0),
|
|
||||||
child: Text(
|
|
||||||
datum,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
description,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:oggauergemeindetrommler/register.dart';
|
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
|
||||||
LoginPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_LoginPageState createState() => _LoginPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isValidEmail(String email) {
|
|
||||||
// Hier kannst du deine Validierungslogik implementieren
|
|
||||||
// Rückgabe true, wenn die E-Mail gültig ist, andernfalls false
|
|
||||||
// Beispiel:
|
|
||||||
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'E-Mail',
|
|
||||||
),
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
|
|
||||||
} else if (!isValidEmail(value)) {
|
|
||||||
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
String email = _emailController.text;
|
|
||||||
if (isValidEmail(email)) {
|
|
||||||
// E-Mail ist gültig, führe die entsprechende Aktion aus
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Ungültige E-Mail-Adresse'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Anmelden'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text('Noch kein Konto?'),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
// Navigiere zur Registrierungsseite
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const RegisterPage()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('Registrieren'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:printing/printing.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'AddPage.dart';
|
|
||||||
import 'DetailPage.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:pdf/widgets.dart' as pdfWidgets;
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
|
|
||||||
import 'anonym_aut.dart';
|
|
||||||
|
|
||||||
class UserDefinedItem extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String datum;
|
|
||||||
final String description;
|
|
||||||
final String imageUrl;
|
|
||||||
final Function onLongPress;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const UserDefinedItem({
|
|
||||||
Key? key,
|
|
||||||
required this.title,
|
|
||||||
required this.description,
|
|
||||||
required this.datum,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.onLongPress,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return InkWell(
|
|
||||||
onTap:onTap,
|
|
||||||
onLongPress: onLongPress as void Function()?,
|
|
||||||
child: Card(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
placeholder: (context, url) => const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isSelected) // Wenn das Element ausgewählt ist, zeigen Sie die Checkbox an
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
child: Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (bool? value) {
|
|
||||||
onLongPress(); // Wenn die Checkbox angeklickt wird, rufen Sie die onLongPress-Funktion auf
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
flex: 1,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
datum,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
description,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GenericList extends StatefulWidget {
|
|
||||||
final String collectionName;
|
|
||||||
final int selectedIndex;
|
|
||||||
|
|
||||||
const GenericList({required this.collectionName, required this.selectedIndex});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_GenericListState createState() => _GenericListState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GenericListState extends State<GenericList> {
|
|
||||||
final FirebaseFirestore firestore = FirebaseFirestore.instance;
|
|
||||||
Map<String, Map<String, dynamic>> selectedItems = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// Enable persistence
|
|
||||||
firestore.enablePersistence();
|
|
||||||
// Enable network
|
|
||||||
firestore.settings =
|
|
||||||
const Settings(cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED);
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(GenericList oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (widget.selectedIndex != oldWidget.selectedIndex) {
|
|
||||||
selectedItems.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void clearSelectedItems() {
|
|
||||||
setState(() {
|
|
||||||
selectedItems.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<QuerySnapshot> getDocumentStream() {
|
|
||||||
return firestore
|
|
||||||
.collection(widget.collectionName)
|
|
||||||
.orderBy('createdDate', descending: true)
|
|
||||||
.snapshots(includeMetadataChanges: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteDocument(String documentId, String? imageUrl) async {
|
|
||||||
try {
|
|
||||||
if (imageUrl != null) {
|
|
||||||
// Löschen Sie das Bild vom Firebase Storage
|
|
||||||
await FirebaseStorage.instance.refFromURL(imageUrl).delete();
|
|
||||||
}
|
|
||||||
// Löschen Sie das Dokument von der Firestore-Kollektion
|
|
||||||
await firestore.collection(widget.collectionName)
|
|
||||||
.doc(documentId)
|
|
||||||
.delete();
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
print('Error deleting document: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
StreamBuilder<QuerySnapshot>(
|
|
||||||
stream: getDocumentStream(),
|
|
||||||
builder: (BuildContext context,
|
|
||||||
AsyncSnapshot<QuerySnapshot> snapshot) {
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Text('Error: ${snapshot.error}');
|
|
||||||
}
|
|
||||||
switch (snapshot.connectionState) {
|
|
||||||
case ConnectionState.waiting:
|
|
||||||
return const Text('Loading...');
|
|
||||||
default:
|
|
||||||
final List<QueryDocumentSnapshot> documents = snapshot.data!
|
|
||||||
.docs;
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: documents.length,
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
final Map<String, dynamic> data =
|
|
||||||
documents[index].data()! as Map<String, dynamic>;
|
|
||||||
final String documentId = documents[index].id;
|
|
||||||
final DateTime createdDate = data['createdDate'].toDate();
|
|
||||||
final formattedDate =
|
|
||||||
DateFormat('dd.MM.yyyy').format(createdDate);
|
|
||||||
final currentUser = FirebaseAuth.instance.currentUser;
|
|
||||||
|
|
||||||
if (currentUser != null && data['author_uid'] == currentUser.uid) {
|
|
||||||
return Dismissible(
|
|
||||||
key: UniqueKey(),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
onDismissed: (direction) {
|
|
||||||
_deleteDocument(documentId, data['imageUrl']);
|
|
||||||
},
|
|
||||||
background: Container(
|
|
||||||
color: Colors.red,
|
|
||||||
child: const Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(right: 16),
|
|
||||||
child: Icon(Icons.delete, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: UserDefinedItem(
|
|
||||||
title: data['title'] ?? '',
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'] ?? '',
|
|
||||||
imageUrl: data['imageUrl'] ?? '',
|
|
||||||
isSelected: selectedItems.containsKey(documentId),
|
|
||||||
onTap: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
// Open a new screen when the user taps on the list item
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => DetailPage(
|
|
||||||
title: data['title'],
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'],
|
|
||||||
imageUrl: data['imageUrl'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
setState(() {
|
|
||||||
if (selectedItems.containsKey(documentId)) {
|
|
||||||
selectedItems.remove(documentId);
|
|
||||||
} else {
|
|
||||||
selectedItems[documentId] = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return UserDefinedItem(
|
|
||||||
title: data['title'] ?? '',
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'] ?? '',
|
|
||||||
imageUrl: data['imageUrl'] ?? '',
|
|
||||||
isSelected: selectedItems.containsKey(documentId),
|
|
||||||
onTap: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
// Open a new screen when the user taps on the list item
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => DetailPage(
|
|
||||||
title: data['title'],
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'],
|
|
||||||
imageUrl: data['imageUrl'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
setState(() {
|
|
||||||
if (selectedItems.containsKey(documentId)) {
|
|
||||||
selectedItems.remove(documentId);
|
|
||||||
} else {
|
|
||||||
selectedItems[documentId] = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (selectedItems.isNotEmpty)
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
onPressed: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
},
|
|
||||||
tooltip: "Auswahl zurücksetzen",
|
|
||||||
child: const Icon(Icons.refresh),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.bottomLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
onPressed: () => _printSelectedItemsAsPdf(widget.selectedIndex),
|
|
||||||
tooltip: "Ausgewählte Elemente als pdf speichern",
|
|
||||||
child: const Icon(Icons.picture_as_pdf),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
),
|
|
||||||
floatingActionButton: Consumer<AuthState>(
|
|
||||||
builder: (context, authState, child) {
|
|
||||||
if (authState.user != null) {
|
|
||||||
return FloatingActionButton(
|
|
||||||
onPressed: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => AddScreen(initialIndex: widget.selectedIndex)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
tooltip: "Neues Element hinzufügen",
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Container(); // Return an empty container when the user is not signed in
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
|
|
||||||
final response = await http.get(Uri.parse(url));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to load network image.');
|
|
||||||
}
|
|
||||||
final Uint8List bytes = response.bodyBytes;
|
|
||||||
|
|
||||||
final img.Image? image = img.decodeImage(bytes);
|
|
||||||
final img.Image resizedImage = img.copyResize(image!, width: 290); // Beispielwert für die Breite
|
|
||||||
final Uint8List resizedBytes = Uint8List.fromList(img.encodeJpg(resizedImage));
|
|
||||||
|
|
||||||
final imageProvider = pdfWidgets.MemoryImage(resizedBytes);
|
|
||||||
return imageProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _printSelectedItemsAsPdf(int selectedIndex) async {
|
|
||||||
final pdfWidgets.Document pdf = pdfWidgets.Document();
|
|
||||||
|
|
||||||
final List<pdfWidgets.Widget> items = [];
|
|
||||||
for (final entry in selectedItems.entries) {
|
|
||||||
final String title = entry.value['title'] ?? '';
|
|
||||||
final String description = (entry.value['description'] ?? '').length > 165 ? (entry.value['description'] ?? '').substring(0, 165) + '...' : (entry.value['description'] ?? '');
|
|
||||||
final String imageUrl = entry.value['imageUrl'] ?? '';
|
|
||||||
final DateTime date = entry.value['createdDate'].toDate();
|
|
||||||
final String formattedDate = DateFormat('dd.MM.yyyy').format(date);
|
|
||||||
final pdfWidgets.ImageProvider imageProvider = await _loadNetworkImage(imageUrl);
|
|
||||||
|
|
||||||
items.add(
|
|
||||||
pdfWidgets.Container(
|
|
||||||
height: 145, // Begrenzt die Höhe des Containers
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Row(
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Container(
|
|
||||||
width: 145, // Reduziert die Breite des Bildes
|
|
||||||
height: 145, // Reduziert die Höhe des Bildes
|
|
||||||
child: pdfWidgets.Image(imageProvider),
|
|
||||||
),
|
|
||||||
pdfWidgets.Flexible(
|
|
||||||
child: pdfWidgets.Padding(
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Column(
|
|
||||||
crossAxisAlignment: pdfWidgets.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Row(
|
|
||||||
mainAxisAlignment: pdfWidgets.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Flexible(
|
|
||||||
child: pdfWidgets.Text(
|
|
||||||
title,
|
|
||||||
style: pdfWidgets.TextStyle(
|
|
||||||
fontSize: 20, fontWeight: pdfWidgets.FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(
|
|
||||||
formattedDate,
|
|
||||||
style: const pdfWidgets.TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pdfWidgets.Padding(
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Text(
|
|
||||||
description,
|
|
||||||
style: const pdfWidgets.TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pdfWidgets.MultiPage(
|
|
||||||
build: (pdfWidgets.Context context) => items,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
String filename;
|
|
||||||
switch(selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
filename = 'Bürgerforum_items.pdf';
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
filename = 'Neuigkeiten_items.pdf';
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
filename = 'Mängel_items.pdf';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
filename = 'selected_items.pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
|
|
||||||
clearSelectedItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class AuthState extends ChangeNotifier {
|
|
||||||
FirebaseAuth auth = FirebaseAuth.instance;
|
|
||||||
User? _user;
|
|
||||||
|
|
||||||
User? get user => _user;
|
|
||||||
|
|
||||||
AuthState() {
|
|
||||||
auth.authStateChanges().listen((User? user) {
|
|
||||||
if (user == null) {
|
|
||||||
print('User is currently signed out!');
|
|
||||||
_user = null;
|
|
||||||
} else {
|
|
||||||
print('User is signed in!');
|
|
||||||
_user = user;
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> signInAnonymously() async {
|
|
||||||
try {
|
|
||||||
await auth.signInAnonymously();
|
|
||||||
} catch (e) {
|
|
||||||
print(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> signOut() async {
|
|
||||||
await auth.signOut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AnonymAuthScreen extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Consumer<AuthState>(
|
|
||||||
builder: (context, authState, child) {
|
|
||||||
return ElevatedButton(
|
|
||||||
onPressed: authState.user == null
|
|
||||||
? authState.signInAnonymously
|
|
||||||
: authState.signOut,
|
|
||||||
child: Text(authState.user == null ? 'Anonym anmelden' : 'Abmelden'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:oggauergemeindetrommler/LoginPage.dart';
|
|
||||||
import 'UserDefinedList.dart';
|
|
||||||
import 'anonym_aut.dart';
|
|
||||||
|
|
||||||
class MyHomePage extends StatefulWidget {
|
|
||||||
const MyHomePage({Key? key, required this.title});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
@override
|
|
||||||
State<MyHomePage> createState() => _MyHomePageState();
|
|
||||||
}
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
void _onItemTapped(int index) {
|
|
||||||
setState(() {
|
|
||||||
_selectedIndex = index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final List<Widget> widgetOptions = <Widget>[
|
|
||||||
const GenericList(collectionName: 'CitizensForum', selectedIndex: 0,),
|
|
||||||
const GenericList(collectionName: 'News', selectedIndex: 1,),
|
|
||||||
const GenericList(collectionName: 'Issues', selectedIndex: 2,),
|
|
||||||
AnonymAuthScreen()
|
|
||||||
];
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Oggauer Gemeinde Trommler'),
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: widgetOptions.elementAt(_selectedIndex),
|
|
||||||
),
|
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
|
||||||
items: const <BottomNavigationBarItem>[
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
label: 'BürgerForum',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.newspaper),
|
|
||||||
label: 'Neuigkeiten',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.report),
|
|
||||||
label: 'Mängel',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.account_circle),
|
|
||||||
label: 'Profil',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
currentIndex: _selectedIndex,
|
|
||||||
selectedItemColor: Colors.blue,
|
|
||||||
unselectedItemColor: Colors.black26,
|
|
||||||
onTap: _onItemTapped,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
-8
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'anonym_aut.dart';
|
|
||||||
import 'home.dart';
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
import 'screens/home_screen.dart';
|
||||||
|
import 'services/auth_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -12,24 +12,40 @@ void main() async {
|
|||||||
);
|
);
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => AuthState(),
|
create: (context) => AuthService(),
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
// This widget is the root of your application.
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Oggauer Gemeindetrommler',
|
title: 'Oggauer Gemeindetrommler',
|
||||||
home: MyHomePage(title: 'Oggauer Gemeindetrommler'),
|
|
||||||
debugShowCheckedModeBanner: false,
|
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: CardThemeData(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
home: const HomeScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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,
|
||||||
|
String imageUrl = '',
|
||||||
|
List<String>? imageUrls,
|
||||||
|
required this.createdDate,
|
||||||
|
this.authorUid,
|
||||||
|
this.street,
|
||||||
|
this.category,
|
||||||
|
this.status,
|
||||||
|
this.pdfUrl,
|
||||||
|
this.webUrl,
|
||||||
|
this.isAussendung = false,
|
||||||
|
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []),
|
||||||
|
imageUrl = imageUrl.isNotEmpty
|
||||||
|
? imageUrl
|
||||||
|
: (imageUrls != null && imageUrls.isNotEmpty ? imageUrls.first : '');
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasIssueFields = data['street'] != null || data['category'] != null;
|
||||||
|
final status = data['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
|
||||||
|
|
||||||
|
return PostItem(
|
||||||
|
id: doc.id,
|
||||||
|
title: data['title'] as String? ?? '',
|
||||||
|
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: status,
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasIssueFields = map['street'] != null || map['category'] != null;
|
||||||
|
final status =
|
||||||
|
map['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
|
||||||
|
|
||||||
|
return PostItem(
|
||||||
|
id: map['id'] as String? ?? '',
|
||||||
|
title: map['title'] as String? ?? '',
|
||||||
|
description: map['description'] as String? ?? '',
|
||||||
|
imageUrl: legacyImage.isNotEmpty
|
||||||
|
? legacyImage
|
||||||
|
: (images.isNotEmpty ? images.first : ''),
|
||||||
|
imageUrls: images,
|
||||||
|
createdDate: date,
|
||||||
|
authorUid: map['authorUid'] as String?,
|
||||||
|
street: map['street'] as String?,
|
||||||
|
category: map['category'] as String?,
|
||||||
|
status: status,
|
||||||
|
pdfUrl: map['pdfUrl'] as String?,
|
||||||
|
webUrl: map['webUrl'] as String?,
|
||||||
|
isAussendung: map['isAussendung'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode HTML entities commonly present in WordPress feeds
|
||||||
|
static String unescapeHtml(String text) {
|
||||||
|
var result = text
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of this PostItem with updated fields
|
||||||
|
PostItem copyWith({
|
||||||
|
String? id,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
String? imageUrl,
|
||||||
|
List<String>? imageUrls,
|
||||||
|
DateTime? createdDate,
|
||||||
|
String? authorUid,
|
||||||
|
String? street,
|
||||||
|
String? category,
|
||||||
|
String? status,
|
||||||
|
String? pdfUrl,
|
||||||
|
String? webUrl,
|
||||||
|
bool? isAussendung,
|
||||||
|
}) {
|
||||||
|
return PostItem(
|
||||||
|
id: id ?? this.id,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
|
imageUrls: imageUrls ?? this.imageUrls,
|
||||||
|
createdDate: createdDate ?? this.createdDate,
|
||||||
|
authorUid: authorUid ?? this.authorUid,
|
||||||
|
street: street ?? this.street,
|
||||||
|
category: category ?? this.category,
|
||||||
|
status: status ?? this.status,
|
||||||
|
pdfUrl: pdfUrl ?? this.pdfUrl,
|
||||||
|
webUrl: webUrl ?? this.webUrl,
|
||||||
|
isAussendung: isAussendung ?? this.isAussendung,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class RegisterPage extends StatefulWidget {
|
|
||||||
const RegisterPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_RegisterPageState createState() => _RegisterPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RegisterPageState extends State<RegisterPage> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isValidEmail(String email) {
|
|
||||||
// Die gleiche Validierung wie in Ihrer LoginPage
|
|
||||||
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool arePasswordsMatching(String password, String confirmPassword) {
|
|
||||||
return password == confirmPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'E-Mail',
|
|
||||||
),
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
|
|
||||||
} else if (!isValidEmail(value)) {
|
|
||||||
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort bestätigen',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
String email = _emailController.text;
|
|
||||||
String password = _passwordController.text;
|
|
||||||
String confirmPassword = _confirmPasswordController.text;
|
|
||||||
if (isValidEmail(email) && arePasswordsMatching(password, confirmPassword)) {
|
|
||||||
// E-Mail und Passwörter sind gültig, führe die entsprechende Aktion aus
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Ungültige Eingaben'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Registrieren'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
|
||||||
|
class AddIssueScreen extends StatefulWidget {
|
||||||
|
final PostItem? issueToEdit;
|
||||||
|
|
||||||
|
const AddIssueScreen({super.key, this.issueToEdit});
|
||||||
|
|
||||||
|
@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 = [];
|
||||||
|
List<String> _existingImageUrls = [];
|
||||||
|
String _selectedStatus = 'Gemeldet';
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
bool get isEditing => widget.issueToEdit != null;
|
||||||
|
|
||||||
|
final List<String> _statuses = [
|
||||||
|
'Gemeldet',
|
||||||
|
'In Bearbeitung',
|
||||||
|
'Erledigt',
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<String> _streets = [
|
||||||
|
'Hauptstraße',
|
||||||
|
'Seestraße',
|
||||||
|
'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();
|
||||||
|
if (widget.issueToEdit != null) {
|
||||||
|
final issue = widget.issueToEdit!;
|
||||||
|
_titleController.text = issue.title;
|
||||||
|
_descriptionController.text = issue.description;
|
||||||
|
if (issue.street != null && _streets.contains(issue.street)) {
|
||||||
|
_selectedStreet = issue.street;
|
||||||
|
} else {
|
||||||
|
_selectedStreet = _streets.first;
|
||||||
|
}
|
||||||
|
if (issue.category != null && _categories.contains(issue.category)) {
|
||||||
|
_selectedCategory = issue.category;
|
||||||
|
} else {
|
||||||
|
_selectedCategory = _categories.first;
|
||||||
|
}
|
||||||
|
_existingImageUrls = List<String>.from(issue.imageUrls);
|
||||||
|
if (_existingImageUrls.isEmpty && issue.imageUrl.isNotEmpty) {
|
||||||
|
_existingImageUrls.add(issue.imageUrl);
|
||||||
|
}
|
||||||
|
if (issue.status != null && _statuses.contains(issue.status)) {
|
||||||
|
_selectedStatus = issue.status!;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedStreet = _streets.first;
|
||||||
|
_selectedCategory = _categories.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
if (_existingImageUrls.length + _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,
|
||||||
|
useSafeArea: true,
|
||||||
|
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 {
|
||||||
|
if (isEditing) {
|
||||||
|
// Delete removed old images from Storage
|
||||||
|
for (final oldUrl in widget.issueToEdit!.imageUrls) {
|
||||||
|
if (!_existingImageUrls.contains(oldUrl)) {
|
||||||
|
try {
|
||||||
|
await _storageService.deleteFileByUrl(oldUrl);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload newly selected images to Storage
|
||||||
|
final List<String> newlyUploadedUrls = [];
|
||||||
|
for (final file in _selectedImages) {
|
||||||
|
final url = await _storageService.uploadFile(
|
||||||
|
file: file,
|
||||||
|
collectionName: 'Issues',
|
||||||
|
optimize: true,
|
||||||
|
);
|
||||||
|
newlyUploadedUrls.add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allImages = [..._existingImageUrls, ...newlyUploadedUrls];
|
||||||
|
|
||||||
|
final updatedIssue = widget.issueToEdit!.copyWith(
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
imageUrl: allImages.isNotEmpty ? allImages.first : '',
|
||||||
|
imageUrls: allImages,
|
||||||
|
street: _selectedStreet,
|
||||||
|
category: _selectedCategory,
|
||||||
|
status: _selectedStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _firestoreService.updatePost('Issues', updatedIssue);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Mangel erfolgreich aktualisiert!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context, updatedIssue);
|
||||||
|
} else {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
|
||||||
|
// Upload selected images to Firebase Storage
|
||||||
|
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, newIssue);
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final isAdmin = auth.isAdmin;
|
||||||
|
final totalPhotos = _existingImageUrls.length + _selectedImages.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEditing ? 'Mangel bearbeiten' : 'Mangel melden'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
top: 16.0,
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 24.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>(
|
||||||
|
initialValue: _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>(
|
||||||
|
initialValue: _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),
|
||||||
|
),
|
||||||
|
if (isAdmin && isEditing) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedStatus,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bearbeitungsstatus (Admin) *',
|
||||||
|
prefixIcon: Icon(Icons.assignment_turned_in_outlined),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _statuses
|
||||||
|
.map((status) => DropdownMenuItem(
|
||||||
|
value: status,
|
||||||
|
child: Text(status),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => _selectedStatus = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
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),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Existing images from Firebase Storage
|
||||||
|
..._existingImageUrls.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final url = entry.value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
width: 110,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (ctx, u) => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
errorWidget: (ctx, u, e) => Container(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.broken_image, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -4,
|
||||||
|
right: 4,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.close,
|
||||||
|
color: Colors.white, size: 16),
|
||||||
|
tooltip: 'Foto entfernen',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_existingImageUrls.removeAt(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// Newly picked local image files
|
||||||
|
..._selectedImages.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final file = entry.value;
|
||||||
|
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: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.close,
|
||||||
|
color: Colors.white, size: 16),
|
||||||
|
tooltip: 'Foto entfernen',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedImages.removeAt(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (totalPhotos < 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),
|
||||||
|
)
|
||||||
|
: Icon(isEditing ? Icons.save : Icons.check_circle_outline),
|
||||||
|
label: Text(
|
||||||
|
_isSaving
|
||||||
|
? 'Wird gespeichert...'
|
||||||
|
: (isEditing
|
||||||
|
? 'Änderungen speichern'
|
||||||
|
: 'Mangel fertigstellen'),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
|
||||||
|
class AddPostScreen extends StatefulWidget {
|
||||||
|
final String collectionName;
|
||||||
|
final String screenTitle;
|
||||||
|
final PostItem? postToEdit;
|
||||||
|
|
||||||
|
const AddPostScreen({
|
||||||
|
super.key,
|
||||||
|
required this.collectionName,
|
||||||
|
required this.screenTitle,
|
||||||
|
this.postToEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
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;
|
||||||
|
String? _existingImageUrl;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
bool get isEditing => widget.postToEdit != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.postToEdit != null) {
|
||||||
|
_titleController.text = widget.postToEdit!.title;
|
||||||
|
_descriptionController.text = widget.postToEdit!.description;
|
||||||
|
if (widget.postToEdit!.imageUrls.isNotEmpty) {
|
||||||
|
_existingImageUrl = widget.postToEdit!.imageUrls.first;
|
||||||
|
} else if (widget.postToEdit!.imageUrl.isNotEmpty) {
|
||||||
|
_existingImageUrl = widget.postToEdit!.imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_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,
|
||||||
|
useSafeArea: true,
|
||||||
|
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 {
|
||||||
|
if (isEditing) {
|
||||||
|
String imageUrl = _existingImageUrl ?? '';
|
||||||
|
|
||||||
|
if (_selectedImage != null) {
|
||||||
|
// If a new image was chosen, remove the old one from storage if it existed
|
||||||
|
if (widget.postToEdit!.imageUrls.isNotEmpty) {
|
||||||
|
for (final oldUrl in widget.postToEdit!.imageUrls) {
|
||||||
|
try {
|
||||||
|
await _storageService.deleteFileByUrl(oldUrl);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageUrl = await _storageService.uploadFile(
|
||||||
|
file: _selectedImage!,
|
||||||
|
collectionName: widget.collectionName,
|
||||||
|
optimize: true,
|
||||||
|
);
|
||||||
|
} else if (_existingImageUrl == null &&
|
||||||
|
widget.postToEdit!.imageUrls.isNotEmpty) {
|
||||||
|
// Existing image was deleted by the user
|
||||||
|
for (final oldUrl in widget.postToEdit!.imageUrls) {
|
||||||
|
try {
|
||||||
|
await _storageService.deleteFileByUrl(oldUrl);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedPost = widget.postToEdit!.copyWith(
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
await _firestoreService.updatePost(widget.collectionName, updatedPost);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Eintrag erfolgreich aktualisiert!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context, updatedPost);
|
||||||
|
} else {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
String imageUrl = '';
|
||||||
|
|
||||||
|
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, newPost);
|
||||||
|
}
|
||||||
|
} 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(isEditing ? 'Beitrag bearbeiten' : widget.screenTitle),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
top: 16.0,
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 24.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 if (_existingImageUrl != null &&
|
||||||
|
_existingImageUrl!.isNotEmpty)
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: _existingImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (ctx, url) => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
errorWidget: (ctx, url, err) => Container(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.broken_image, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
tooltip: 'Bild entfernen',
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _existingImageUrl = null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
),
|
||||||
|
onPressed: _showImageSourceModal,
|
||||||
|
icon: const Icon(Icons.camera_alt, size: 16),
|
||||||
|
label: const Text('Ersetzen',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
InkWell(
|
||||||
|
onTap: _showImageSourceModal,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
: Icon(isEditing ? Icons.save : Icons.send),
|
||||||
|
label: Text(
|
||||||
|
_isSaving
|
||||||
|
? 'Wird gespeichert...'
|
||||||
|
: (isEditing
|
||||||
|
? 'Änderungen speichern'
|
||||||
|
: 'Veröffentlichen'),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
|
||||||
|
class AdminMessagesScreen extends StatefulWidget {
|
||||||
|
const AdminMessagesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminMessagesScreen> createState() => _AdminMessagesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
String _selectedFilter = 'all'; // 'all', 'Neu', 'In Bearbeitung', 'Erledigt'
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isSearchActive = false;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMail(String email) async {
|
||||||
|
final uri = Uri(scheme: 'mailto', path: email);
|
||||||
|
try {
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('E-Mail-App konnte nicht geöffnet werden: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _callPhone(String phone) async {
|
||||||
|
final cleanPhone = phone.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
final uri = Uri(scheme: 'tel', path: cleanPhone);
|
||||||
|
try {
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Telefon-App konnte nicht geöffnet werden: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessageDetail(
|
||||||
|
BuildContext context,
|
||||||
|
String docId,
|
||||||
|
Map<String, dynamic> data,
|
||||||
|
) {
|
||||||
|
final firstName = data['firstName'] as String? ?? '';
|
||||||
|
final lastName = data['lastName'] as String? ?? '';
|
||||||
|
final rawName = '$firstName $lastName'.trim();
|
||||||
|
final fullName = rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
|
||||||
|
final email = data['email'] as String? ?? '';
|
||||||
|
final phone = data['phone'] as String? ?? '';
|
||||||
|
final message = data['message'] as String? ?? '';
|
||||||
|
final currentStatus = data['status'] as String? ?? 'Neu';
|
||||||
|
|
||||||
|
DateTime createdDate;
|
||||||
|
if (data['createdDate'] is Timestamp) {
|
||||||
|
createdDate = (data['createdDate'] as Timestamp).toDate();
|
||||||
|
} else {
|
||||||
|
createdDate = DateTime.now();
|
||||||
|
}
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setModalState) {
|
||||||
|
final bottomInset = MediaQuery.viewPaddingOf(ctx).bottom > 0
|
||||||
|
? MediaQuery.viewPaddingOf(ctx).bottom
|
||||||
|
: MediaQuery.paddingOf(ctx).bottom;
|
||||||
|
final keyboardInset = MediaQuery.viewInsetsOf(ctx).bottom;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: bottomInset + keyboardInset + 20,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
fullName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildStatusBadge(currentStatus),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Eingegangen: $formattedDate Uhr',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
if (email.isNotEmpty) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.email_outlined,
|
||||||
|
size: 18, color: Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
email,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _sendMail(email),
|
||||||
|
icon: const Icon(Icons.send, size: 16),
|
||||||
|
label: const Text('Antworten'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (phone.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.phone_outlined,
|
||||||
|
size: 18, color: Colors.green),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
phone,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _callPhone(phone),
|
||||||
|
icon: const Icon(Icons.call, size: 16),
|
||||||
|
label: const Text('Anrufen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text(
|
||||||
|
'Nachricht / Anliegen:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Status ändern:',
|
||||||
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildStatusButton(docId, 'Neu', currentStatus, Colors.orange),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildStatusButton(
|
||||||
|
docId, 'In Bearbeitung', currentStatus, Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildStatusButton(
|
||||||
|
docId, 'Erledigt', currentStatus, Colors.green),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Nachricht löschen'),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dCtx) => AlertDialog(
|
||||||
|
title: const Text('Nachricht löschen?'),
|
||||||
|
content: const Text(
|
||||||
|
'Möchten Sie diese Bürgernachricht unwiderruflich löschen?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dCtx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(dCtx, true),
|
||||||
|
child: const Text('Löschen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await _firestoreService.deleteContactMessage(docId);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Nachricht gelöscht.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Schließen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusButton(
|
||||||
|
String docId,
|
||||||
|
String targetStatus,
|
||||||
|
String currentStatus,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
final isCurrent = currentStatus == targetStatus;
|
||||||
|
return Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
backgroundColor: isCurrent ? color.withValues(alpha: 0.15) : null,
|
||||||
|
side: BorderSide(
|
||||||
|
color: isCurrent ? color : Colors.grey.shade300,
|
||||||
|
width: isCurrent ? 2 : 1,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
await _firestoreService.updateContactMessageStatus(
|
||||||
|
docId, targetStatus);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
targetStatus,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isCurrent ? color : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge(String status) {
|
||||||
|
Color bg;
|
||||||
|
Color fg;
|
||||||
|
switch (status) {
|
||||||
|
case 'In Bearbeitung':
|
||||||
|
bg = Colors.blue.shade50;
|
||||||
|
fg = Colors.blue.shade800;
|
||||||
|
break;
|
||||||
|
case 'Erledigt':
|
||||||
|
bg = Colors.green.shade50;
|
||||||
|
fg = Colors.green.shade800;
|
||||||
|
break;
|
||||||
|
case 'Neu':
|
||||||
|
default:
|
||||||
|
bg = Colors.orange.shade50;
|
||||||
|
fg = Colors.orange.shade900;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: fg.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: fg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: _isSearchActive
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Nachrichten durchsuchen...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintStyle: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||||
|
)
|
||||||
|
: const Text('Posteingang (Bürgernachrichten)'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
|
||||||
|
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_isSearchActive) {
|
||||||
|
_isSearchActive = false;
|
||||||
|
_searchQuery = '';
|
||||||
|
_searchController.clear();
|
||||||
|
} else {
|
||||||
|
_isSearchActive = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Filter Chips
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Alle'),
|
||||||
|
selected: _selectedFilter == 'all',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'all'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
avatar: const Icon(Icons.fiber_new,
|
||||||
|
size: 16, color: Colors.orange),
|
||||||
|
label: const Text('Neu'),
|
||||||
|
selected: _selectedFilter == 'Neu',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'Neu'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
avatar: const Icon(Icons.hourglass_top,
|
||||||
|
size: 16, color: Colors.blue),
|
||||||
|
label: const Text('In Bearbeitung'),
|
||||||
|
selected: _selectedFilter == 'In Bearbeitung',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'In Bearbeitung'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
avatar: const Icon(Icons.check_circle_outline,
|
||||||
|
size: 16, color: Colors.green),
|
||||||
|
label: const Text('Erledigt'),
|
||||||
|
selected: _selectedFilter == 'Erledigt',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'Erledigt'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
|
||||||
|
stream: _firestoreService.getContactMessagesStream(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
'Fehler beim Abrufen der Nachrichten: ${snapshot.error}',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final docs = snapshot.data?.docs ?? [];
|
||||||
|
final filteredDocs = docs.where((doc) {
|
||||||
|
final data = doc.data();
|
||||||
|
final status = data['status'] as String? ?? 'Neu';
|
||||||
|
if (_selectedFilter != 'all' && status != _selectedFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final q = _searchQuery.toLowerCase();
|
||||||
|
final fn = (data['firstName'] as String? ?? '').toLowerCase();
|
||||||
|
final ln = (data['lastName'] as String? ?? '').toLowerCase();
|
||||||
|
final em = (data['email'] as String? ?? '').toLowerCase();
|
||||||
|
final msg = (data['message'] as String? ?? '').toLowerCase();
|
||||||
|
return fn.contains(q) ||
|
||||||
|
ln.contains(q) ||
|
||||||
|
em.contains(q) ||
|
||||||
|
msg.contains(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filteredDocs.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mark_email_read_outlined,
|
||||||
|
size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Keine Treffer für "'
|
||||||
|
: 'Keine Nachrichten vorhanden.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 20,
|
||||||
|
),
|
||||||
|
itemCount: filteredDocs.length,
|
||||||
|
separatorBuilder: (ctx, i) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final doc = filteredDocs[index];
|
||||||
|
final data = doc.data();
|
||||||
|
final firstName = data['firstName'] as String? ?? '';
|
||||||
|
final lastName = data['lastName'] as String? ?? '';
|
||||||
|
final rawName = '$firstName $lastName'.trim();
|
||||||
|
final fullName =
|
||||||
|
rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
|
||||||
|
final email = data['email'] as String? ?? '';
|
||||||
|
final message = data['message'] as String? ?? '';
|
||||||
|
final status = data['status'] as String? ?? 'Neu';
|
||||||
|
|
||||||
|
DateTime createdDate;
|
||||||
|
if (data['createdDate'] is Timestamp) {
|
||||||
|
createdDate = (data['createdDate'] as Timestamp).toDate();
|
||||||
|
} else {
|
||||||
|
createdDate = DateTime.now();
|
||||||
|
}
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: status == 'Neu'
|
||||||
|
? Colors.orange.shade100
|
||||||
|
: status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade100
|
||||||
|
: Colors.green.shade100,
|
||||||
|
child: Text(
|
||||||
|
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: status == 'Neu'
|
||||||
|
? Colors.orange.shade900
|
||||||
|
: status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade900
|
||||||
|
: Colors.green.shade900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
fullName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: status == 'Neu'
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.w600,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
formattedDate,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (email.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
email,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: _buildStatusBadge(status),
|
||||||
|
onTap: () => _showMessageDetail(context, doc.id, data),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,420 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/pdf_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
import 'add_issue_screen.dart';
|
||||||
|
import 'add_post_screen.dart';
|
||||||
|
import 'pdf_viewer_screen.dart';
|
||||||
|
|
||||||
|
class DetailScreen extends StatefulWidget {
|
||||||
|
final PostItem item;
|
||||||
|
final String? collectionName;
|
||||||
|
|
||||||
|
const DetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
this.collectionName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DetailScreen> createState() => _DetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailScreenState extends State<DetailScreen> {
|
||||||
|
late PostItem _item;
|
||||||
|
int _currentImageIndex = 0;
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_item = widget.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editItem() async {
|
||||||
|
final collection = widget.collectionName ??
|
||||||
|
(_item.street != null ? 'Issues' : 'CitizensForum');
|
||||||
|
PostItem? updated;
|
||||||
|
if (collection == 'Issues' || _item.street != null) {
|
||||||
|
updated = await Navigator.push<PostItem>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => AddIssueScreen(issueToEdit: _item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updated = await Navigator.push<PostItem>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => AddPostScreen(
|
||||||
|
collectionName: collection,
|
||||||
|
screenTitle: 'Beitrag bearbeiten',
|
||||||
|
postToEdit: _item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_item = updated!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteItem() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => AlertDialog(
|
||||||
|
title: const Text('Eintrag löschen?'),
|
||||||
|
content: const Text(
|
||||||
|
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||||
|
child: const Text('Löschen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final collection = widget.collectionName ??
|
||||||
|
(_item.street != null ? 'Issues' : 'News');
|
||||||
|
try {
|
||||||
|
final storage = StorageService();
|
||||||
|
final firestore = FirestoreService();
|
||||||
|
for (final url in _item.imageUrls) {
|
||||||
|
try {
|
||||||
|
await storage.deleteFileByUrl(url);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await firestore.deletePost(collection, _item.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Eintrag gelöscht.')),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Fehler beim Löschen: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthService>(context);
|
||||||
|
final currentUser = auth.user;
|
||||||
|
final isAuthor = currentUser != null && _item.authorUid == currentUser.uid;
|
||||||
|
final canEdit = !_item.isAussendung && (isAuthor || auth.isAdmin);
|
||||||
|
final canDelete = !_item.isAussendung && (isAuthor || auth.isAdmin);
|
||||||
|
final isIssue = widget.collectionName == 'Issues' || _item.street != null;
|
||||||
|
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy HH:mm').format(_item.createdDate);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_item.title, overflow: TextOverflow.ellipsis),
|
||||||
|
actions: [
|
||||||
|
if (canEdit)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Beitrag bearbeiten',
|
||||||
|
onPressed: _editItem,
|
||||||
|
),
|
||||||
|
if (canDelete)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Beitrag löschen',
|
||||||
|
onPressed: _deleteItem,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
|
tooltip: 'Als PDF drucken / teilen',
|
||||||
|
onPressed: () => PdfService.printSinglePost(_item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
top: 16.0,
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_item.imageUrls.isNotEmpty) ...[
|
||||||
|
if (_item.imageUrls.length == 1)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: _item.imageUrls.first,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 240,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
height: 200,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.image_not_supported, size: 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 260,
|
||||||
|
width: double.infinity,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: _item.imageUrls.length,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentImageIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: _item.imageUrls[index],
|
||||||
|
width: double.infinity,
|
||||||
|
height: 260,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
height: 260,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.image_not_supported,
|
||||||
|
size: 48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Page indicator dots
|
||||||
|
Positioned(
|
||||||
|
bottom: 10,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(
|
||||||
|
_item.imageUrls.length,
|
||||||
|
(i) => Container(
|
||||||
|
margin:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: _currentImageIndex == i ? 16 : 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _currentImageIndex == i
|
||||||
|
? Colors.white
|
||||||
|
: Colors.white54,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Photo counter badge (e.g. 1 / 2)
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.photo_library,
|
||||||
|
color: Colors.white, size: 14),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
_item.title,
|
||||||
|
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 (isIssue && _item.status != null) ...[
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade50
|
||||||
|
: (_item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade50
|
||||||
|
: Colors.orange.shade50),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade200
|
||||||
|
: (_item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade200
|
||||||
|
: Colors.orange.shade200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_item.status!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade800
|
||||||
|
: (_item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade800
|
||||||
|
: Colors.orange.shade900),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,265 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class PdfViewerScreen extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String pdfUrl;
|
||||||
|
|
||||||
|
const PdfViewerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.pdfUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PdfViewerScreen> createState() => _PdfViewerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PdfViewerScreenState extends State<PdfViewerScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final TransformationController _transformationController =
|
||||||
|
TransformationController();
|
||||||
|
late AnimationController _animationController;
|
||||||
|
Animation<Matrix4>? _zoomAnimation;
|
||||||
|
TapDownDetails? _doubleTapDetails;
|
||||||
|
Uint8List? _cachedBytes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
)..addListener(() {
|
||||||
|
if (_zoomAnimation != null) {
|
||||||
|
_transformationController.value = _zoomAnimation!.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
_transformationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDoubleTap() {
|
||||||
|
if (_animationController.isAnimating) return;
|
||||||
|
|
||||||
|
final currentMatrix = _transformationController.value;
|
||||||
|
final currentScale = currentMatrix.getMaxScaleOnAxis();
|
||||||
|
|
||||||
|
Matrix4 targetMatrix;
|
||||||
|
if (currentScale > 1.05) {
|
||||||
|
// Zoomed in -> smoothly animate back to standard size (scale 1.0)
|
||||||
|
targetMatrix = Matrix4.identity();
|
||||||
|
} else {
|
||||||
|
// At standard size -> zoom in to tapped point (2.5x)
|
||||||
|
final position = _doubleTapDetails?.localPosition ?? Offset.zero;
|
||||||
|
final scenePoint = _transformationController.toScene(position);
|
||||||
|
final x = position.dx - scenePoint.dx * 2.5;
|
||||||
|
final y = position.dy - scenePoint.dy * 2.5;
|
||||||
|
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
|
||||||
|
..setTranslationRaw(x, y, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_zoomAnimation = Matrix4Tween(
|
||||||
|
begin: currentMatrix,
|
||||||
|
end: targetMatrix,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
));
|
||||||
|
_animationController.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openExternal() async {
|
||||||
|
final uri = Uri.parse(widget.pdfUrl);
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
uri,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Browser konnte nicht geöffnet werden: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sharePdf() async {
|
||||||
|
if (_cachedBytes != null) {
|
||||||
|
final safeTitle =
|
||||||
|
widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
|
||||||
|
await Printing.sharePdf(
|
||||||
|
bytes: _cachedBytes!,
|
||||||
|
filename: '$safeTitle.pdf',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('PDF wird noch geladen...')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final safeTitle =
|
||||||
|
widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.title, overflow: TextOverflow.ellipsis),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
tooltip: 'PDF teilen / drucken',
|
||||||
|
onPressed: _sharePdf,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.open_in_browser),
|
||||||
|
tooltip: 'Im Browser öffnen',
|
||||||
|
onPressed: _openExternal,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: PdfPreview.builder(
|
||||||
|
build: (PdfPageFormat format) async {
|
||||||
|
if (_cachedBytes != null) return _cachedBytes!;
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(widget.pdfUrl))
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
_cachedBytes = response.bodyBytes;
|
||||||
|
return response.bodyBytes;
|
||||||
|
}
|
||||||
|
throw Exception(
|
||||||
|
'Fehler beim Abrufen der Datei (${response.statusCode})');
|
||||||
|
},
|
||||||
|
canChangeOrientation: false,
|
||||||
|
canChangePageFormat: false,
|
||||||
|
canDebug: false,
|
||||||
|
allowPrinting: false,
|
||||||
|
allowSharing: false,
|
||||||
|
useActions: false,
|
||||||
|
pdfFileName: '$safeTitle.pdf',
|
||||||
|
loadingWidget: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Aussendung wird geladen...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onError: (context, error) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Fehler beim Laden des Dokuments: $error',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _openExternal,
|
||||||
|
icon: const Icon(Icons.open_in_browser),
|
||||||
|
label: const Text('Direkt im Browser öffnen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pagesBuilder: (context, pages) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final viewportWidth = constraints.maxWidth;
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onDoubleTapDown: (details) => _doubleTapDetails = details,
|
||||||
|
onDoubleTap: _handleDoubleTap,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transformationController,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 5.0,
|
||||||
|
panEnabled: true,
|
||||||
|
scaleEnabled: true,
|
||||||
|
constrained: false,
|
||||||
|
boundaryMargin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 48, vertical: 80),
|
||||||
|
child: SizedBox(
|
||||||
|
width: viewportWidth,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8.0, vertical: 16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < pages.length; i++) ...[
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Image(
|
||||||
|
image: pages[i].image,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (pages.length > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
|
child: Text(
|
||||||
|
'Seite ${i + 1} von ${pages.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editPost(PostItem item) {
|
||||||
|
if (widget.collectionName == 'Issues') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => AddIssueScreen(issueToEdit: item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => AddPostScreen(
|
||||||
|
collectionName: widget.collectionName,
|
||||||
|
screenTitle: widget.collectionName == 'News'
|
||||||
|
? 'Nachricht bearbeiten'
|
||||||
|
: 'Idee bearbeiten',
|
||||||
|
postToEdit: item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAddNewPressed() async {
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
|
||||||
|
// News are official announcements and can only be created by Admins
|
||||||
|
if (widget.collectionName == 'News' && !auth.isAdmin) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Nur Administratoren können Neuigkeiten veröffentlichen.'),
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||||
|
await AuthDialog.show(context);
|
||||||
|
if (!mounted) return;
|
||||||
|
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 canEdit = !item.isAussendung && (isAuthor || auth.isAdmin);
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||||
|
final isIssue =
|
||||||
|
widget.collectionName == 'Issues' || item.street != null;
|
||||||
|
|
||||||
|
Widget? leadingWidget;
|
||||||
|
if (item.imageUrl.isNotEmpty) {
|
||||||
|
leadingWidget = Stack(
|
||||||
|
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,
|
||||||
|
collectionName: widget.collectionName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggleSelection(item),
|
||||||
|
leading: leadingWidget,
|
||||||
|
trailing: item.isAussendung
|
||||||
|
? Icon(Icons.arrow_forward_ios,
|
||||||
|
size: 14, color: Colors.grey.shade400)
|
||||||
|
: (_selectedItems.isNotEmpty
|
||||||
|
? null
|
||||||
|
: ((canEdit || canDelete)
|
||||||
|
? PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
size: 20, color: Colors.black54),
|
||||||
|
tooltip: 'Optionen',
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'edit') {
|
||||||
|
_editPost(item);
|
||||||
|
} else if (value == 'delete') {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => AlertDialog(
|
||||||
|
title: const Text('Eintrag löschen?'),
|
||||||
|
content: const Text(
|
||||||
|
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(dialogCtx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(dialogCtx, true),
|
||||||
|
child: const Text('Löschen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
_deletePost(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (ctx) => [
|
||||||
|
if (canEdit)
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'edit',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.edit_outlined, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Bearbeiten'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canDelete)
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_outline,
|
||||||
|
size: 20, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Löschen',
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null)),
|
||||||
|
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 (isIssue && item.status != null && item.status!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade50
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade50
|
||||||
|
: Colors.orange.shade50),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade300
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade300
|
||||||
|
: Colors.orange.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.status!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? Colors.green.shade800
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? Colors.blue.shade800
|
||||||
|
: Colors.orange.shade900),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.street != null && item.street!.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on,
|
||||||
|
size: 12, color: Colors.redAccent),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
item.street!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else if (item.street != null && item.street!.isNotEmpty) ...[
|
||||||
|
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,555 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
|
||||||
|
class UserManagementScreen extends StatefulWidget {
|
||||||
|
const UserManagementScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserManagementScreenState extends State<UserManagementScreen> {
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isSearchActive = false;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _selectedFilter = 'all'; // 'all', 'admins', 'citizens'
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleAdminRole({
|
||||||
|
required String uid,
|
||||||
|
required String email,
|
||||||
|
required String displayName,
|
||||||
|
required bool currentIsAdmin,
|
||||||
|
required String currentAdminUid,
|
||||||
|
}) async {
|
||||||
|
if (currentIsAdmin && uid == currentAdminUid) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Sie können sich nicht selbst die Administrator-Rechte entziehen.'),
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetAction = currentIsAdmin ? 'entziehen' : 'erteilen';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dCtx) => AlertDialog(
|
||||||
|
title: Text(currentIsAdmin
|
||||||
|
? 'Admin-Rechte entziehen?'
|
||||||
|
: 'Zum Administrator ernennen?'),
|
||||||
|
content: Text(currentIsAdmin
|
||||||
|
? 'Möchten Sie $displayName die Administrator-Rechte wirklich entziehen?'
|
||||||
|
: 'Möchten Sie $displayName wirklich zum Administrator ernennen? Dieser Nutzer erhält vollen Zugriff auf News, alle Beiträge, Bürgernachrichten und die Benutzerverwaltung.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dCtx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
currentIsAdmin ? Colors.red : Colors.indigo.shade700,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dCtx, true),
|
||||||
|
child: Text(currentIsAdmin ? 'Rechte entziehen' : 'Ernennen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
try {
|
||||||
|
await _firestoreService.setAdminRole(
|
||||||
|
uid: uid,
|
||||||
|
email: email,
|
||||||
|
makeAdmin: !currentIsAdmin,
|
||||||
|
grantedBy: currentAdminUid,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Administrator-Rechte wurden erfolgreich $targetAction.'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Ändern der Rechte: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddAdminDialog(String currentAdminUid) async {
|
||||||
|
final emailController = TextEditingController();
|
||||||
|
final uidController = TextEditingController();
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dCtx) => AlertDialog(
|
||||||
|
title: const Text('Admin manuell hinzufügen'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Geben Sie die E-Mail-Adresse oder Firebase-UID des neuen Administrators ein:',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'E-Mail-Adresse *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: uidController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'UID (optional)',
|
||||||
|
hintText: 'z. B. aus Firebase Auth',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dCtx),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final email = emailController.text.trim().toLowerCase();
|
||||||
|
final uid = uidController.text.trim();
|
||||||
|
if (email.isEmpty && uid.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final identifier = uid.isNotEmpty ? uid : email;
|
||||||
|
try {
|
||||||
|
await _firestoreService.addAdminDirectly(
|
||||||
|
identifier: identifier,
|
||||||
|
email: email,
|
||||||
|
grantedBy: currentAdminUid,
|
||||||
|
);
|
||||||
|
if (dCtx.mounted) {
|
||||||
|
Navigator.pop(dCtx);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Administrator erfolgreich hinzugefügt.'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Hinzufügen: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Hinzufügen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthService>(context);
|
||||||
|
final currentAdminUid = auth.user?.uid ?? '';
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: _isSearchActive
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Benutzer nach Name oder E-Mail suchen...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintStyle: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||||
|
)
|
||||||
|
: const Text('Benutzerverwaltung'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
|
||||||
|
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_isSearchActive) {
|
||||||
|
_isSearchActive = false;
|
||||||
|
_searchQuery = '';
|
||||||
|
_searchController.clear();
|
||||||
|
} else {
|
||||||
|
_isSearchActive = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
|
tooltip: 'Admin manuell hinzufügen',
|
||||||
|
onPressed: () => _showAddAdminDialog(currentAdminUid),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Filter Chips
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Alle'),
|
||||||
|
selected: _selectedFilter == 'all',
|
||||||
|
onSelected: (_) => setState(() => _selectedFilter = 'all'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
avatar: const Icon(Icons.admin_panel_settings,
|
||||||
|
size: 16, color: Colors.indigo),
|
||||||
|
label: const Text('Administratoren'),
|
||||||
|
selected: _selectedFilter == 'admins',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'admins'),
|
||||||
|
selectedColor: Colors.indigo.shade100,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
avatar: const Icon(Icons.person_outline,
|
||||||
|
size: 16, color: Colors.teal),
|
||||||
|
label: const Text('Bürger'),
|
||||||
|
selected: _selectedFilter == 'citizens',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _selectedFilter = 'citizens'),
|
||||||
|
selectedColor: Colors.teal.shade100,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Users and Admins Stream
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
|
||||||
|
stream: _firestoreService.getAdminsStream(),
|
||||||
|
builder: (context, adminSnapshot) {
|
||||||
|
final Set<String> adminUids = {};
|
||||||
|
final Set<String> adminEmails = {};
|
||||||
|
|
||||||
|
if (adminSnapshot.hasData) {
|
||||||
|
for (final doc in adminSnapshot.data!.docs) {
|
||||||
|
adminUids.add(doc.id.toLowerCase());
|
||||||
|
final data = doc.data();
|
||||||
|
final email =
|
||||||
|
(data['email'] as String? ?? '').toLowerCase();
|
||||||
|
if (email.isNotEmpty) adminEmails.add(email);
|
||||||
|
final uid =
|
||||||
|
(data['uid'] as String? ?? '').toLowerCase();
|
||||||
|
if (uid.isNotEmpty) adminUids.add(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
|
||||||
|
stream: _firestoreService.getUsersStream(),
|
||||||
|
builder: (context, userSnapshot) {
|
||||||
|
if (userSnapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
'Fehler beim Laden der Benutzer: ${userSnapshot.error}',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userSnapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final userDocs = userSnapshot.data?.docs ?? [];
|
||||||
|
final filteredUsers = userDocs.where((doc) {
|
||||||
|
final data = doc.data();
|
||||||
|
final uid = doc.id;
|
||||||
|
final email =
|
||||||
|
(data['email'] as String? ?? '').toLowerCase();
|
||||||
|
final isUserAdmin = adminUids.contains(uid.toLowerCase()) ||
|
||||||
|
adminEmails.contains(email) ||
|
||||||
|
data['isAdmin'] == true;
|
||||||
|
|
||||||
|
if (_selectedFilter == 'admins' && !isUserAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_selectedFilter == 'citizens' && isUserAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final q = _searchQuery.toLowerCase();
|
||||||
|
final name =
|
||||||
|
(data['displayName'] as String? ?? '').toLowerCase();
|
||||||
|
final fn =
|
||||||
|
(data['firstName'] as String? ?? '').toLowerCase();
|
||||||
|
final ln =
|
||||||
|
(data['lastName'] as String? ?? '').toLowerCase();
|
||||||
|
return name.contains(q) ||
|
||||||
|
email.contains(q) ||
|
||||||
|
fn.contains(q) ||
|
||||||
|
ln.contains(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filteredUsers.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.people_outline,
|
||||||
|
size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Keine Treffer für "$_searchQuery"'
|
||||||
|
: 'Noch keine registrierten Benutzer vorhanden.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 20,
|
||||||
|
),
|
||||||
|
itemCount: filteredUsers.length,
|
||||||
|
separatorBuilder: (ctx, i) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final doc = filteredUsers[index];
|
||||||
|
final data = doc.data();
|
||||||
|
final uid = doc.id;
|
||||||
|
final email = data['email'] as String? ?? '';
|
||||||
|
final displayName = data['displayName'] as String? ??
|
||||||
|
'${data['firstName'] ?? ''} ${data['lastName'] ?? ''}'.trim();
|
||||||
|
final name = displayName.isNotEmpty
|
||||||
|
? displayName
|
||||||
|
: (email.isNotEmpty ? email : 'Bürger');
|
||||||
|
final isCurrentUser = uid == currentAdminUid;
|
||||||
|
final isUserAdmin =
|
||||||
|
adminUids.contains(uid.toLowerCase()) ||
|
||||||
|
adminEmails.contains(email.toLowerCase()) ||
|
||||||
|
data['isAdmin'] == true;
|
||||||
|
|
||||||
|
DateTime? createdAt;
|
||||||
|
if (data['createdAt'] is Timestamp) {
|
||||||
|
createdAt = (data['createdAt'] as Timestamp).toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: isUserAdmin
|
||||||
|
? Colors.indigo.shade100
|
||||||
|
: Colors.blue.shade50,
|
||||||
|
child: Icon(
|
||||||
|
isUserAdmin
|
||||||
|
? Icons.admin_panel_settings
|
||||||
|
: Icons.person,
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.indigo.shade800
|
||||||
|
: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isCurrentUser) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Du',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (email.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
email,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.indigo.shade50
|
||||||
|
: Colors.teal.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.indigo.shade200
|
||||||
|
: Colors.teal.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isUserAdmin ? 'Administrator' : 'Bürger',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.indigo.shade800
|
||||||
|
: Colors.teal.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (createdAt != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Seit ${DateFormat('dd.MM.yyyy').format(createdAt)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
tooltip: 'Optionen',
|
||||||
|
onSelected: (action) {
|
||||||
|
if (action == 'toggleAdmin') {
|
||||||
|
_toggleAdminRole(
|
||||||
|
uid: uid,
|
||||||
|
email: email,
|
||||||
|
displayName: name,
|
||||||
|
currentIsAdmin: isUserAdmin,
|
||||||
|
currentAdminUid: currentAdminUid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (ctx) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'toggleAdmin',
|
||||||
|
enabled: !(isUserAdmin && isCurrentUser),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isUserAdmin
|
||||||
|
? Icons.remove_moderator
|
||||||
|
: Icons.add_moderator,
|
||||||
|
size: 20,
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.red
|
||||||
|
: Colors.indigo.shade700,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
isUserAdmin
|
||||||
|
? 'Admin-Rechte entziehen'
|
||||||
|
: 'Zum Admin ernennen',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isUserAdmin
|
||||||
|
? Colors.red
|
||||||
|
: Colors.indigo.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,208 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing post by ID
|
||||||
|
Future<void> updatePost(String collectionName, PostItem item) async {
|
||||||
|
await _firestore
|
||||||
|
.collection(collectionName)
|
||||||
|
.doc(item.id)
|
||||||
|
.update(item.toFirestore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a contact form submission to Firestore
|
||||||
|
Future<void> submitContactMessage({
|
||||||
|
required String firstName,
|
||||||
|
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,
|
||||||
|
'emailVerified': ?emailVerified,
|
||||||
|
'isAdmin': ?isAdmin,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!doc.exists) {
|
||||||
|
data['createdAt'] = FieldValue.serverTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
await docRef.set(data, SetOptions(merge: true));
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Error syncing user profile to Firestore: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote or demote a user to Admin in Firestore
|
||||||
|
Future<void> setAdminRole({
|
||||||
|
required String uid,
|
||||||
|
required String email,
|
||||||
|
required bool makeAdmin,
|
||||||
|
String? grantedBy,
|
||||||
|
}) async {
|
||||||
|
final cleanEmail = email.trim().toLowerCase();
|
||||||
|
final adminRef = _firestore.collection('Admins').doc(uid);
|
||||||
|
final userRef = _firestore.collection('Users').doc(uid);
|
||||||
|
|
||||||
|
if (makeAdmin) {
|
||||||
|
// 1. Add UID to Admins collection
|
||||||
|
await adminRef.set({
|
||||||
|
'uid': uid,
|
||||||
|
'email': cleanEmail,
|
||||||
|
'grantedAt': FieldValue.serverTimestamp(),
|
||||||
|
'grantedBy': grantedBy ?? '',
|
||||||
|
}, SetOptions(merge: true));
|
||||||
|
|
||||||
|
// 2. Also ensure email doc exists if email is known
|
||||||
|
if (cleanEmail.isNotEmpty) {
|
||||||
|
await _firestore.collection('Admins').doc(cleanEmail).set({
|
||||||
|
'uid': uid,
|
||||||
|
'email': cleanEmail,
|
||||||
|
'grantedAt': FieldValue.serverTimestamp(),
|
||||||
|
'grantedBy': grantedBy ?? '',
|
||||||
|
}, SetOptions(merge: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mark user document as admin
|
||||||
|
await userRef.set({'isAdmin': true}, SetOptions(merge: true));
|
||||||
|
} else {
|
||||||
|
// 1. Remove UID from Admins collection
|
||||||
|
await adminRef.delete();
|
||||||
|
|
||||||
|
// 2. Remove email from Admins collection if present
|
||||||
|
if (cleanEmail.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await _firestore.collection('Admins').doc(cleanEmail).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update user document
|
||||||
|
await userRef.set({'isAdmin': false}, SetOptions(merge: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add admin directly by UID or email (e.g. manual entry)
|
||||||
|
Future<void> addAdminDirectly({
|
||||||
|
required String identifier,
|
||||||
|
required String email,
|
||||||
|
String? grantedBy,
|
||||||
|
}) async {
|
||||||
|
final cleanId = identifier.trim();
|
||||||
|
final cleanEmail = email.trim().toLowerCase();
|
||||||
|
await _firestore.collection('Admins').doc(cleanId).set({
|
||||||
|
'uid': cleanId,
|
||||||
|
'email': cleanEmail,
|
||||||
|
'grantedAt': FieldValue.serverTimestamp(),
|
||||||
|
'grantedBy': grantedBy ?? '',
|
||||||
|
}, SetOptions(merge: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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 List<String> urlsToFetch = item.imageUrls.isNotEmpty
|
||||||
|
? item.imageUrls
|
||||||
|
: (item.imageUrl.isNotEmpty ? [item.imageUrl] : []);
|
||||||
|
final results =
|
||||||
|
await Future.wait(urlsToFetch.map((url) => _fetchImage(url)));
|
||||||
|
final imageProviders = results.whereType<pw.ImageProvider>().toList();
|
||||||
|
|
||||||
|
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.status != null && item.status!.isNotEmpty) ...[
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Status: ',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: PdfColors.grey800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Container(
|
||||||
|
padding:
|
||||||
|
const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? const PdfColor.fromInt(0xFFE8F5E9)
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? const PdfColor.fromInt(0xFFE3F2FD)
|
||||||
|
: const PdfColor.fromInt(0xFFFFF3E0)),
|
||||||
|
borderRadius:
|
||||||
|
const pw.BorderRadius.all(pw.Radius.circular(4)),
|
||||||
|
border: pw.Border.all(
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? const PdfColor.fromInt(0xFF81C784)
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? const PdfColor.fromInt(0xFF64B5F6)
|
||||||
|
: const PdfColor.fromInt(0xFFFFB74D)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: pw.Text(
|
||||||
|
item.status!,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: item.status == 'Erledigt'
|
||||||
|
? const PdfColor.fromInt(0xFF2E7D32)
|
||||||
|
: (item.status == 'In Bearbeitung'
|
||||||
|
? const PdfColor.fromInt(0xFF1565C0)
|
||||||
|
: const PdfColor.fromInt(0xFFE65100)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Text(
|
||||||
|
'Ö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 (imageProviders.isNotEmpty) ...[
|
||||||
|
if (imageProviders.length == 1)
|
||||||
|
pw.Center(
|
||||||
|
child: pw.Container(
|
||||||
|
height: 220,
|
||||||
|
child: pw.Image(imageProviders.first, fit: pw.BoxFit.contain),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
pw.Row(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < imageProviders.length; i++) ...[
|
||||||
|
if (i > 0) pw.SizedBox(width: 12),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Container(
|
||||||
|
height: 180,
|
||||||
|
child: pw.Image(imageProviders[i],
|
||||||
|
fit: pw.BoxFit.contain),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 14),
|
||||||
|
],
|
||||||
|
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 {
|
||||||
|
final url =
|
||||||
|
item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl;
|
||||||
|
if (url.isNotEmpty) {
|
||||||
|
imageMap[item.id] = await _fetchImage(url);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
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;
|
||||||
|
final imageCount = item.imageUrls.length;
|
||||||
|
final imageLabel = imageMap[item.id] != null
|
||||||
|
? (imageCount > 1 ? '$imageCount Fotos' : 'Foto vorhanden')
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return [
|
||||||
|
formattedDate,
|
||||||
|
locInfo,
|
||||||
|
'${item.title}\n$descSnippet',
|
||||||
|
item.status ?? 'Gemeldet',
|
||||||
|
imageLabel,
|
||||||
|
];
|
||||||
|
}).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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+373
-205
File diff suppressed because it is too large
Load Diff
+37
-84
@@ -1,108 +1,61 @@
|
|||||||
name: oggauergemeindetrommler
|
name: oggauergemeindetrommler
|
||||||
description: Gemeinde Trommler
|
description: Gemeinde Trommler
|
||||||
|
|
||||||
|
# Private app – never publish to pub.dev.
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
# The following line prevents the package from being accidentally published to
|
# Version + build number (build-name+build-number). Can be overridden with
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# `flutter build ... --build-name --build-number`.
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
||||||
|
|
||||||
# The following defines the version and build number for your application.
|
|
||||||
# A version number is three numbers separated by dots, like 1.2.43
|
|
||||||
# followed by an optional build number separated by a +.
|
|
||||||
# Both the version and the builder number may be overridden in flutter
|
|
||||||
# build by specifying --build-name and --build-number, respectively.
|
|
||||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
|
||||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
|
||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
|
||||||
# Read more about iOS versioning at
|
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.18.2 <3.0.0'
|
# Flutter 3.47.x ships Dart 3.13; keep the constraint in sync with the
|
||||||
|
# Flutter version pinned in the CI workflows (FLUTTER_VERSION).
|
||||||
|
sdk: ^3.11.0
|
||||||
|
flutter: '>=3.47.0'
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
|
||||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
|
||||||
# dependencies can be manually updated by changing the version numbers below to
|
|
||||||
# the latest version available on pub.dev. To see which dependencies have newer
|
|
||||||
# versions available, run `flutter pub outdated`.
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
# Firebase (Auth, Firestore, Storage)
|
||||||
|
firebase_core: ^4.15.0
|
||||||
|
firebase_auth: ^6.7.0
|
||||||
|
cloud_firestore: ^6.10.0
|
||||||
|
firebase_storage: ^13.6.0
|
||||||
|
|
||||||
|
# UI / state
|
||||||
|
provider: ^6.1.5
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|
||||||
|
# Media
|
||||||
|
image_picker: ^1.2.3
|
||||||
|
image: ^4.5.4
|
||||||
|
|
||||||
|
# PDF export & sharing
|
||||||
|
pdf: ^3.11.3
|
||||||
|
printing: ^5.14.3
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# Misc
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
http: ^1.2.2
|
||||||
cupertino_icons: ^1.0.2
|
shared_preferences: ^2.5.3
|
||||||
image_picker: ^1.1.2
|
url_launcher: ^6.3.1
|
||||||
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
|
|
||||||
|
|
||||||
|
# Transitive (via flutter_cache_manager / printing), pinned on purpose:
|
||||||
|
# path_provider_android >= 2.3 is implemented with `jni` and compiles native
|
||||||
|
# code (CMake + NDK). The ARM64 Gitea runner would have to run the x86_64 NDK
|
||||||
|
# toolchain through QEMU, so stay on the pure-Java implementation for now.
|
||||||
|
path_provider_android: '>=2.2.0 <2.3.0'
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
|
||||||
# package. See that file for information about deactivating specific lint
|
|
||||||
# rules and activating additional ones.
|
|
||||||
flutter_lints: ^4.0.0
|
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
|
||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
|
||||||
# included with your application, so that you can use the icons in
|
|
||||||
# the material Icons class.
|
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/images/
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
|
||||||
|
|
||||||
# For details regarding adding assets from package dependencies, see
|
|
||||||
# https://flutter.dev/assets-and-images/#from-packages
|
|
||||||
|
|
||||||
# To add custom fonts to your application, add a fonts section here,
|
|
||||||
# in this "flutter" section. Each entry in this list should have a
|
|
||||||
# "family" key with the font family name, and a "fonts" key with a
|
|
||||||
# list giving the asset and other descriptors for the font. For
|
|
||||||
# example:
|
|
||||||
# fonts:
|
|
||||||
# - family: Schyler
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/Schyler-Regular.ttf
|
|
||||||
# - asset: fonts/Schyler-Italic.ttf
|
|
||||||
# style: italic
|
|
||||||
# - family: Trajan Pro
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/TrajanPro.ttf
|
|
||||||
# - asset: fonts/TrajanPro_Bold.ttf
|
|
||||||
# weight: 700
|
|
||||||
#
|
|
||||||
# For details regarding fonts from package dependencies,
|
|
||||||
# see https://flutter.dev/custom-fonts/#from-packages
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+371
-21
@@ -1,30 +1,380 @@
|
|||||||
// 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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:oggauergemeindetrommler/models/post_item.dart';
|
||||||
import 'package:oggauergemeindetrommler/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
group('PostItem Model Tests', () {
|
||||||
// Build our app and trigger a frame.
|
test('creates PostItem with default values', () {
|
||||||
await tester.pumpWidget(const MyApp());
|
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',
|
||||||
|
status: 'Gemeldet',
|
||||||
|
);
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
expect(item.id, 'test-123');
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(item.title, 'Defekte Straßenlaterne');
|
||||||
expect(find.text('1'), findsNothing);
|
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.
|
test('defaults status to Gemeldet only for issues and null for news/ideas', () {
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
final issueJson = {
|
||||||
await tester.pump();
|
'id': 'issue-1',
|
||||||
|
'title': 'Schlagloch',
|
||||||
|
'description': 'Tiefes Schlagloch',
|
||||||
|
'createdDate': '2026-09-18T10:00:00.000',
|
||||||
|
'street': 'Kirchengasse',
|
||||||
|
'category': 'Straße',
|
||||||
|
};
|
||||||
|
final issueItem = PostItem.fromJson(issueJson);
|
||||||
|
expect(issueItem.status, 'Gemeldet');
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
final newsJson = {
|
||||||
expect(find.text('0'), findsNothing);
|
'id': 'news-1',
|
||||||
expect(find.text('1'), findsOneWidget);
|
'title': 'Dorffest Ankündigung',
|
||||||
|
'description': 'Am Samstag findet das Fest statt.',
|
||||||
|
'createdDate': '2026-09-18T10:00:00.000',
|
||||||
|
};
|
||||||
|
final newsItem = PostItem.fromJson(newsJson);
|
||||||
|
expect(newsItem.status, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles multiple images in PostItem', () {
|
||||||
|
final item = PostItem(
|
||||||
|
id: 'multi-img-1',
|
||||||
|
title: 'Baustelle',
|
||||||
|
description: 'Zwei Fotos vorhanden',
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
imageUrls: [
|
||||||
|
'https://example.com/img1.jpg',
|
||||||
|
'https://example.com/img2.jpg',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(item.imageUrls.length, 2);
|
||||||
|
expect(item.imageUrls[0], 'https://example.com/img1.jpg');
|
||||||
|
expect(item.imageUrls[1], 'https://example.com/img2.jpg');
|
||||||
|
expect(item.imageUrl, 'https://example.com/img1.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializes to Firestore map accurately', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final item = PostItem(
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PDF Viewer & Navigation Overlap Tests', () {
|
||||||
|
test('verifies double-tap zoom matrix calculation and reset', () {
|
||||||
|
// Test at standard scale (1.0) -> zoom to 2.5x
|
||||||
|
final currentMatrix = Matrix4.identity();
|
||||||
|
final currentScale = currentMatrix.getMaxScaleOnAxis();
|
||||||
|
expect(currentScale, 1.0);
|
||||||
|
|
||||||
|
const tapPosition = Offset(100, 200);
|
||||||
|
Matrix4 targetMatrix;
|
||||||
|
if (currentScale > 1.05) {
|
||||||
|
targetMatrix = Matrix4.identity();
|
||||||
|
} else {
|
||||||
|
final x = -tapPosition.dx * (2.5 - 1.0);
|
||||||
|
final y = -tapPosition.dy * (2.5 - 1.0);
|
||||||
|
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
|
||||||
|
..setTranslationRaw(x, y, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(targetMatrix.getMaxScaleOnAxis(), 2.5);
|
||||||
|
expect(targetMatrix.getTranslation().x, -150.0);
|
||||||
|
expect(targetMatrix.getTranslation().y, -300.0);
|
||||||
|
|
||||||
|
// Test when zoomed in -> reset to standard size (scale 1.0)
|
||||||
|
final zoomedScale = targetMatrix.getMaxScaleOnAxis();
|
||||||
|
expect(zoomedScale > 1.05, isTrue);
|
||||||
|
|
||||||
|
Matrix4 resetMatrix;
|
||||||
|
if (zoomedScale > 1.05) {
|
||||||
|
resetMatrix = Matrix4.identity();
|
||||||
|
} else {
|
||||||
|
resetMatrix = targetMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resetMatrix.getMaxScaleOnAxis(), 1.0);
|
||||||
|
expect(resetMatrix.getTranslation().x, 0.0);
|
||||||
|
expect(resetMatrix.getTranslation().y, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calculates safe bottom padding above 3-button navigation bar', () {
|
||||||
|
// 3-button navigation typical height is ~48dp
|
||||||
|
const systemNavBarHeight = 48.0;
|
||||||
|
const keyboardHeight = 0.0;
|
||||||
|
const extraPadding = 20.0;
|
||||||
|
|
||||||
|
final totalBottomPadding = systemNavBarHeight + keyboardHeight + extraPadding;
|
||||||
|
expect(totalBottomPadding, 68.0);
|
||||||
|
// Ensures interactive action buttons sit strictly above the navigation bar
|
||||||
|
expect(totalBottomPadding > systemNavBarHeight, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Post Edit & Permissions Tests', () {
|
||||||
|
test('PostItem.copyWith updates fields while preserving existing values', () {
|
||||||
|
final original = PostItem(
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Original Title',
|
||||||
|
description: 'Original Desc',
|
||||||
|
createdDate: DateTime(2026, 1, 1),
|
||||||
|
authorUid: 'user-123',
|
||||||
|
imageUrl: 'https://example.com/img1.jpg',
|
||||||
|
street: 'Hauptstraße',
|
||||||
|
category: 'Sonstiges',
|
||||||
|
status: 'Gemeldet',
|
||||||
|
);
|
||||||
|
|
||||||
|
final edited = original.copyWith(
|
||||||
|
title: 'Updated Title',
|
||||||
|
description: 'Updated Desc',
|
||||||
|
imageUrl: 'https://example.com/img2.jpg',
|
||||||
|
imageUrls: ['https://example.com/img2.jpg'],
|
||||||
|
status: 'In Bearbeitung',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(edited.id, 'post-1');
|
||||||
|
expect(edited.authorUid, 'user-123');
|
||||||
|
expect(edited.createdDate, DateTime(2026, 1, 1));
|
||||||
|
expect(edited.street, 'Hauptstraße');
|
||||||
|
expect(edited.category, 'Sonstiges');
|
||||||
|
expect(edited.title, 'Updated Title');
|
||||||
|
expect(edited.description, 'Updated Desc');
|
||||||
|
expect(edited.imageUrl, 'https://example.com/img2.jpg');
|
||||||
|
expect(edited.imageUrls, ['https://example.com/img2.jpg']);
|
||||||
|
expect(edited.status, 'In Bearbeitung');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifies post edit permissions: admins can edit all, users can edit only their own, Aussendungen cannot be edited', () {
|
||||||
|
bool canEditPost({
|
||||||
|
required PostItem post,
|
||||||
|
required String? currentUserId,
|
||||||
|
required bool isAdmin,
|
||||||
|
}) {
|
||||||
|
if (post.isAussendung) return false;
|
||||||
|
final isAuthor = currentUserId != null && post.authorUid == currentUserId;
|
||||||
|
return isAuthor || isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
final userPost = PostItem(
|
||||||
|
id: 'user_post_1',
|
||||||
|
title: 'Schlagloch',
|
||||||
|
description: 'Großes Schlagloch',
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
authorUid: 'author_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final aussendung = PostItem(
|
||||||
|
id: 'wp_123',
|
||||||
|
title: 'Aussendung',
|
||||||
|
description: 'PDF',
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
isAussendung: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Author user can edit their own post
|
||||||
|
expect(
|
||||||
|
canEditPost(post: userPost, currentUserId: 'author_1', isAdmin: false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Different user cannot edit other user's post
|
||||||
|
expect(
|
||||||
|
canEditPost(post: userPost, currentUserId: 'other_user', isAdmin: false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Admin can edit any post (even if not author)
|
||||||
|
expect(
|
||||||
|
canEditPost(post: userPost, currentUserId: 'other_user', isAdmin: true),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Admin cannot edit external WordPress Aussendungen
|
||||||
|
expect(
|
||||||
|
canEditPost(post: aussendung, currentUserId: 'author_1', isAdmin: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Unauthenticated user cannot edit
|
||||||
|
expect(
|
||||||
|
canEditPost(post: userPost, currentUserId: null, isAdmin: false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates issue status editing for admins', () {
|
||||||
|
const allowedStatuses = ['Gemeldet', 'In Bearbeitung', 'Erledigt'];
|
||||||
|
|
||||||
|
final issue = PostItem(
|
||||||
|
id: 'issue_1',
|
||||||
|
title: 'Straßenlaterne',
|
||||||
|
description: 'Laterne dunkel',
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
authorUid: 'user_1',
|
||||||
|
status: 'Gemeldet',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issue.status, 'Gemeldet');
|
||||||
|
|
||||||
|
// Admin transitions status
|
||||||
|
final inProgress = issue.copyWith(status: 'In Bearbeitung');
|
||||||
|
expect(inProgress.status, 'In Bearbeitung');
|
||||||
|
expect(allowedStatuses.contains(inProgress.status), isTrue);
|
||||||
|
|
||||||
|
final done = inProgress.copyWith(status: 'Erledigt');
|
||||||
|
expect(done.status, 'Erledigt');
|
||||||
|
expect(allowedStatuses.contains(done.status), isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user