Compare commits
60
Commits
e2fe4ce6c4
..
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 |
+184
-28
@@ -14,28 +14,152 @@ on:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
# Only one build per ref at a time: two QEMU-emulated Gradle builds in parallel
|
||||
# take the ARM64 runner down (both runs then die at the same instant).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Keep in sync with .github/workflows/build-ios.yaml and pubspec.yaml (environment).
|
||||
FLUTTER_VERSION: '3.47.4'
|
||||
# Static qemu-x86_64 for the ARM64 runner (Debian trixie `qemu-user`, see below).
|
||||
QEMU_DEB_VERSION: '10.0.13+ds-0+deb13u1'
|
||||
QEMU_DEB_SHA1: '02b553a4532192bc999d15408d1e56d7b254906f'
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Release APKs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:3.19.3
|
||||
# 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: Configure Git Safe Directory
|
||||
- name: Setup QEMU Interceptor
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git config --global --add safe.directory '*'
|
||||
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: chmod +x android/gradlew
|
||||
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
|
||||
@@ -45,35 +169,67 @@ jobs:
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
- name: Build Universal Release APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build Split-per-ABI Release APKs
|
||||
- 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
|
||||
# Copy universal APK
|
||||
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
|
||||
fi
|
||||
# Copy split APKs
|
||||
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||
fi
|
||||
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||
fi
|
||||
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||
fi
|
||||
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 APKs to Gitea Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload Universal APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: oggauer-gemeindetrommler-apks
|
||||
path: release-apks/*.apk
|
||||
retention-days: 30
|
||||
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
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'refactor/**'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Release APKs
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:3.19.3
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure Git Safe Directory
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Make Gradlew Executable
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Inspect Build Environment
|
||||
run: |
|
||||
flutter --version
|
||||
java -version
|
||||
|
||||
- name: Fetch Flutter Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Run Code Analysis & Tests
|
||||
run: |
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
- name: Build Universal Release APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build Split-per-ABI Release APKs
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Organize Artifacts
|
||||
run: |
|
||||
mkdir -p release-apks
|
||||
# Copy universal APK
|
||||
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
|
||||
fi
|
||||
# Copy split APKs
|
||||
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||
fi
|
||||
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||
fi
|
||||
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||
fi
|
||||
echo "=== Generated APK Artifacts ==="
|
||||
ls -lah release-apks/
|
||||
|
||||
- name: Upload APKs to Gitea Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oggauer-gemeindetrommler-apks
|
||||
path: release-apks/*.apk
|
||||
retention-days: 30
|
||||
@@ -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
|
||||
@@ -44,3 +44,7 @@ app.*.map.json
|
||||
/android/app/release
|
||||
/android/app/build/
|
||||
.gradle/
|
||||
release-apks/
|
||||
gitea-runner/data/
|
||||
gitea-runner/.env
|
||||
backups/
|
||||
|
||||
@@ -1,132 +1,198 @@
|
||||
# Oggauer Gemeindetrommler 🥁
|
||||
|
||||
Die **Oggauer Gemeindetrommler App** ist eine mobile Anwendung (Flutter für Android & iOS) für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See. Sie dient als digitale Plattform für Gemeinde-Nachrichten, Bürgeranliegen (Mängelmeldungen mit Foto-Upload) sowie den Austausch im Bürgerforum bzw. die Einbringung von Gemeinde-Ideen inklusive PDF-Exportfunktionen.
|
||||
Die **Oggauer Gemeindetrommler App** ist die offizielle Smartphone-Anwendung (Flutter für Android & iOS) des **Bürgerforums Oggau** für alle Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.
|
||||
|
||||
Die App vereint aktuelle Gemeinde-Nachrichten, offizielle PDF-Aussendungen des Bürgerforums, einen bürgernahen Mängelmelder mit Foto-Upload, eine Ideenplattform sowie einen direkten Kontaktkanal mit integriertem Admin-Posteingang und vollständiger In-App-Benutzerverwaltung.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Aktueller Funktionsumfang (Ist-Zustand)
|
||||
## 📱 Funktionsumfang & Module
|
||||
|
||||
Die App wurde in ihrer Erstversion mit Unterstützung von KI (frühe GPT-Modelle) entwickelt und nutzt Firebase als Backend.
|
||||
### 1. 📰 Nachrichten & Offizielle PDF-Aussendungen (`News`)
|
||||
- **Chronologischer Feed:** Vereint manuell erstellte Gemeinde-Nachrichten und die offiziellen Rundschreiben/Aussendungen des Bürgerforums nach Datum sortiert (neueste zuerst).
|
||||
- **WordPress REST-API Anbindung:** Vollautomatische Synchronisation der Aussendungen von [buergerforumoggau.at](https://buergerforumoggau.at/category/aktuelles/aussendungen/) inklusive HTML-Bereinigung und PDF-Extraktion.
|
||||
- **Offline-First:** Geladene Aussendungen werden lokal via `SharedPreferences` zwischengespeichert und stehen sofort beim App-Start sowie offline zur Verfügung.
|
||||
- **Integrierter PDF-Reader:**
|
||||
- Flüssige In-App-Vorschau ohne externe App zwingend öffnen zu müssen.
|
||||
- **Stufenloser Zoom:** Gesten-Zoom (Pinch-to-Zoom von 0.8x bis 5.0x) und schwebendes Bedienfeld (`+`, `-`, `1:1` Reset auf 100%).
|
||||
- **Drucken & Teilen:** Direktes Teilen von PDFs via `Printing.sharePdf` und System-Druckfunktion.
|
||||
- **Browser-Aufruf:** Öffnen des Original-Dokuments im Standard-Webbrowser oder externen PDF-Viewer.
|
||||
- **Filter-Leiste:** Schnelles Filtern nach `Alle`, `Aussendungen (PDF)` und `Aktuelles`.
|
||||
- **Echtzeitsuche & PDF-Export:** Volltextsuche in Beiträgen sowie Export von Einzelbeiträgen oder Tabellenübersichten als PDF.
|
||||
- **Redaktions-Funktion:** Berechtigte Administratoren können neue Nachrichten direkt über die App mit Titel, Text und Beitragsbild erfassen.
|
||||
|
||||
### 1. Neuigkeiten (`News`)
|
||||
- Liste aktueller Berichte und Neuigkeiten aus der Gemeinde mit Datum, Überschrift, Textvorschau und Beitragsbild.
|
||||
- Detailansicht mit vollständigem Text und großem Beitragsbild.
|
||||
- PDF-Export: Sowohl Einzelbeiträge als auch eine Mehrfachauswahl können direkt als PDF generiert und geteilt werden.
|
||||
### 2. ⚠️ Mängelmelder (`Issues`)
|
||||
- **Mängelübersicht:** Chronologische Liste aller im Gemeindegebiet gemeldeten Mängel mit Bildvorschau, Status und Ortsangabe.
|
||||
- **Strukturierte Erfassung:**
|
||||
- Titel und detaillierte Beschreibung.
|
||||
- Kategorie-Auswahl (z. B. Straßenbeleuchtung, Straßenschäden, Grünflächen, Müll, Spielplätze).
|
||||
- Pflicht-Dropdown aller offiziellen Straßen in Oggau.
|
||||
- Bis zu 2 Fotos (Aufnahme direkt per Kamera oder Auswahl aus der Galerie).
|
||||
- Automatische Bildkomprimierung und Speicheroptimierung (< 10 MB).
|
||||
- **Status-Workflow:** Verwaltung durch Administratoren (`Gemeldet` $\rightarrow$ `In Bearbeitung` $\rightarrow$ `Behoben`).
|
||||
- **Sammel-PDF-Druck:** Mehrfachauswahl gemeldeter Mängel mit tabellarischem PDF-Export (ideal zur Weiterleitung an den Bauhof oder Gemeinderat).
|
||||
- **Löschfunktion:** Verfasser und Administratoren können Meldungen per Wischgeste entfernen.
|
||||
|
||||
### 2. Mängelübersicht & Mängelmeldung (`Issues`)
|
||||
- Übersicht über gemeldete Mängel im Ortsgebiet (chronologisch sortiert).
|
||||
- Erfassung neuer Mängel mit Titel, Beschreibungstext und Foto (Kamera oder Galerie).
|
||||
- Automatischer Zuschnitt (1:1 Quadrat) und Größenvalidierung (< 10 MB).
|
||||
- Eigene Meldungen können per Wischgeste (Swipe-to-Delete) entfernt werden.
|
||||
- Mehrfachauswahl von Mängeln mit Sammel-PDF-Export (z. B. zur Weiterleitung an den Bauhof).
|
||||
### 3. 💡 BürgerForum & Ideen (`CitizensForum`)
|
||||
- Bürger-Beteiligungsplattform für Anregungen, Projektideen und Initiativen zur Dorfentwicklung.
|
||||
- Erfassung mit Titel, Freitextbeschreibung und bis zu 2 Fotos/Anhängen.
|
||||
- PDF-Export und Druckunterstützung zur Vorstellung in Ausschüssen oder Sitzungen.
|
||||
|
||||
### 3. BürgerForum / Ideen (`CitizensForum`)
|
||||
- Plattform zur Einbringung von Anregungen und Vorschlägen für das Gemeindeleben.
|
||||
- Nutzt aktuell den gleichen Erfassungs- und PDF-Export-Workflow wie bei den Mängeln.
|
||||
### 4. 📬 Kontakt & Admin-Posteingang (`Contact`)
|
||||
- **Bürger-Kontaktformular:**
|
||||
- Einfache und direkte Kontaktaufnahme mit dem Bürgerforum.
|
||||
- Pflichtangaben: Name, E-Mail-Adresse und Anliegen.
|
||||
- Optionale Rückruf-Telefonnummer für schnelle Rückfragen.
|
||||
- Rechtlicher Datenschutzhinweis.
|
||||
- **Admin-Posteingang (`AdminMessagesScreen`):**
|
||||
- Administratoren haben direkten Zugriff auf alle über die App eingesendeten Bürgernachrichten.
|
||||
- **Echtzeit-Aktualisierung** via Firestore.
|
||||
- **Filter-Chips:** Schnelles Filtern nach `Alle`, `Neu`, `In Bearbeitung` und `Erledigt`.
|
||||
- **Volltextsuche:** Durchsucht Betreff, Nachrichtentext, Absender-Name und E-Mail-Adresse.
|
||||
- **Detail-Ansicht (Bottom Sheet):**
|
||||
- Vollständiger Nachrichtentext mit Metadaten und Zeitstempel.
|
||||
- Statuswechsel (`Neu`, `In Bearbeitung`, `Erledigt`).
|
||||
- Ein-Klick-Aktionen: Direkte E-Mail-Antwort (`mailto:`) und Anruf (`tel:`).
|
||||
- Sicheres Löschen erledigter Nachrichten.
|
||||
|
||||
### 4. Authentifizierung (`AuthState`)
|
||||
- Anonyme Firebase-Authentifizierung (`FirebaseAuth.signInAnonymously`).
|
||||
- Lesemodus für Gäste; Erstellen und Löschen eigener Beiträge ist an den angemeldeten Nutzerstatus gekoppelt.
|
||||
### 5. ℹ️ Bürgerforum-Info, Leitbild & In-App-Benutzerverwaltung (`Info`)
|
||||
- **Leitbild & Über uns:** Informationen über die Grundsätze, Ziele und Aktivitäten des Bürgerforums Oggau.
|
||||
- **Notrufnummern:** Wichtige Notfallkontakte (Feuerwehr, Polizei, Rettung, Euronotruf, Vergiftungszentrale, etc.) auf einen Blick mit direkter Anruffunktion.
|
||||
- **Benutzerkonto-Status:** Transparente Anzeige des Anmeldestatus (`Gast`, `Bürger`, `Administrator`).
|
||||
- **In-App-Benutzerverwaltung (`UserManagementScreen`):**
|
||||
- Keine Notwendigkeit mehr, für die Rollenvergabe die Firebase Console zu öffnen.
|
||||
- Vollständige Liste aller registrierten Bürger mit Such- und Filterfunktion (`Alle`, `Administratoren`, `Bürger`).
|
||||
- **Admin-Rechte verwalten:** Beförderung zum Administrator oder Entzug von Admin-Rechten mit einem Klick (inklusive Sicherheits-Bestätigungsdialog).
|
||||
- **Selbstentzugs-Schutz:** Der angemeldete Admin kann sich nicht versehentlich selbst die Administrationsrechte entziehen.
|
||||
- **Manuelle Admin-Freischaltung:** Zusätzliche Dialog-Eingabe zur direkten Autorisierung per E-Mail oder UID.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Soll vs. Ist Abgleich (Original-Anforderungen)
|
||||
## 🔒 Sicherheitsarchitektur (Backend & Frontend)
|
||||
|
||||
Basierend auf dem ursprünglichen Lastenheft von Thomas und den UI-Wireframes (`App (1).xlsx`) ergibt sich folgender Umsetzungsstatus:
|
||||
Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sicherheitskonzept gewährleistet:
|
||||
|
||||
| Anforderung / Modul | Ursprüngliche Spezifikation | Aktueller Umsetzungsstatus | Status |
|
||||
|---|---|---|---|
|
||||
| **Startseite / News** | 2-Zeilen-Vorschau, Trennlinie, Volltextsuche (Lupe), Detailansicht, PDF/JPG-Anhänge | Überschrift, Datum, 3 Zeilen Text, Bild, Detailansicht | 🟡 Teilweise (Suche fehlt, nur 1 Bild) |
|
||||
| **Mängelübersicht** | Liste, Volltextsuche, PDF-Tabellendruck einer Auswahl | Liste vorhanden, Checkbox-Auswahl + PDF-Druck integriert | 🟡 Teilweise (Suche & Tabellenlayout im PDF fehlen) |
|
||||
| **Mängelerfassung** | Pflicht-Dropdown für Straße/Ort, Dropdown für Grundlage, max. 2 Fotos im Querformat (max. 800x600), automatischer Verfasser | Titel, Beschreibung, 1 Foto im Quadrat (1:1). Keine Dropdowns | 🔴 Wesentliche Felder fehlen |
|
||||
| **Ideen** | Wie Mängel, bis zu 5 Anhänge (PDF/Bilder), kein Druck erforderlich | Als „BürgerForum“ realisiert, aber identische Maske zu Mängeln (nur 1 Bild) | 🟡 Teilweise (Mehrfach-Upload bis 5 Dateien fehlt) |
|
||||
| **Kontakt** | Freitext, Name, Nachname, E-Mail (Pflicht), Rückrufnummer (optional), E-Mail-Versand, Rechtshinweis | Nicht im Menü eingebunden; nur Registrier-Fragmente vorhanden | 🔴 Fehlt komplett |
|
||||
| **Information / Impressum** | Öffentliche Gemeinde-Infos, Veranstaltungen, Impressum ohne Login frei zugänglich | Nicht vorhanden | 🔴 Fehlt komplett |
|
||||
| **Zugangsschutz / Auth** | Gemeindebürger-Zugang via Passwort/Freigabe; nur Info-Bereich offen | Anonymer Login per Knopfdruck für alle; Fragmente für E-Mail-Login | 🔴 Entspricht nicht dem Passwort-Konzept |
|
||||
| **Design & Header** | Neutraler Hintergrund, optionales Betreiber-Logo im Header | Schlichter Material-AppBar-Header ohne Logo | 🟡 Optimierbar |
|
||||
1. **Firestore Security Rules (`firestore.rules`):**
|
||||
- **`/Users/{userId}`:** Nutzer können ihr eigenes Profil lesen und pflegen. Nur Administratoren (`isAdmin()`) haben Vollzugriff auf alle Benutzerprofile.
|
||||
- **`/Admins/{adminDoc}`:** Schreib- und Löschrechte sind strikt auf bestehende Administratoren beschränkt.
|
||||
- **`/ContactMessages/{doc}`:** Eingereichte Kontaktanfragen können ausschließlich von Administratoren oder dem jeweiligen Verfasser gelesen werden. Statusänderungen und Löschungen sind Administratoren vorbehalten.
|
||||
- **`/News/{newsId}`:** Erstellen und Löschen von News-Beiträgen ist nur für Administratoren gestattet.
|
||||
- **`/Issues` & `/CitizensForum`:** Angemeldete Nutzer dürfen eigene Beiträge erfassen und löschen; Admins dürfen den Bearbeitungsstatus pflegen.
|
||||
2. **Cloud Storage Rules (`storage.rules`):**
|
||||
- Uploads sind auf angemeldete Nutzer, Dateigrößen unter 10 MB und gültige Bildformate (`image/*`) limitiert.
|
||||
- Fotos dürfen ausschließlich vom ursprünglichen Verfasser oder von einem berechtigten Administrator gelöscht werden.
|
||||
3. **Automatische Profil-Synchronisation:**
|
||||
- Bei jeder Anmeldung oder Registrierung gleicht `AuthService` das Benutzerprofil mit Firestore (`Users/{uid}`) ab.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Umsetzungsstatus (Soll vs. Ist)
|
||||
|
||||
| Anforderung / Modul | Spezifikation & Features | Umsetzungsstatus | Status |
|
||||
|---|---|---|:---:|
|
||||
| **News & Aussendungen** | Chronologischer Feed, Volltextsuche, PDF-Aussendungen der Webseite, Zoom-Viewer, PDF-Export | Vollständig integriert (WordPress REST-API, In-App Zoom Reader, Filter-Chips) | 🟢 Erledigt |
|
||||
| **Mängelübersicht** | Chronologische Liste, Straßenauswahl, Volltextsuche, Tabellen-PDF-Export, Status-Tracking | Vollständig integriert (Dropdown aller Oggauer Straßen, Statusanzeige, PDF-Export) | 🟢 Erledigt |
|
||||
| **Mängelerfassung** | Pflicht-Straße, Kategorie, max. 2 Fotos, automatische Komprimierung, Verfasser-Zuordnung | Vollständig integriert (Kamera/Galerie, Dual-Foto-Upload, Validierung) | 🟢 Erledigt |
|
||||
| **Bürgerforum / Ideen** | Bürgerbeteiligung, Beschreibung, bis zu 2 Anhänge, PDF-Export | Vollständig integriert | 🟢 Erledigt |
|
||||
| **Kontakt & Posteingang** | Kontaktformular mit Validierung, Datenschutzhinweis, Admin-Nachrichten-Posteingang | Vollständig integriert (Echtzeit-Inbox, Status-Workflow, Schnellantwort) | 🟢 Erledigt |
|
||||
| **Benutzerverwaltung** | Rollenverwaltung (Admin/Bürger), Kontoverwaltung in der App ohne Firebase Console | Vollständig integriert (In-App User Management, Selbstschutz, Echtzeit) | 🟢 Erledigt |
|
||||
| **Info & Notruf** | Leitbild Bürgerforum, Gemeinde-Informationen, Notfallnummern mit Schnellwahl | Vollständig integriert | 🟢 Erledigt |
|
||||
| **Authentifizierung & Rechte** | E-Mail/Passwort, Bürger-Freischaltung, Rollenkonzept, Gast-Zugang, Security Rules | Vollständig integriert & backend-seitig gehärtet | 🟢 Erledigt |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technische Architektur & Tech-Stack
|
||||
|
||||
### Verwendete Technologien:
|
||||
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=2.18.2 <3.0.0` / Dart 3 kompatibel)
|
||||
- **Backend / Cloud Services (Firebase):**
|
||||
- `firebase_core` (Core-Initialisierung)
|
||||
- `firebase_auth` (Authentifizierung)
|
||||
- `cloud_firestore` (NoSQL Echtzeitdatenbank für News, Mängel, Ideen)
|
||||
- `firebase_storage` (Speicherung hochgeladener Bilder)
|
||||
- **PDF-Erstellung & Druck:** `pdf`, `printing`
|
||||
- **Bildverarbeitung:** `image`, `image_picker`, `image_cropper`, `cached_network_image`
|
||||
- **State Management:** `provider`
|
||||
- **Framework:** [Flutter](https://flutter.dev) `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-Projektkonfiguration (Android & iOS)
|
||||
├── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
|
||||
├── firebase_options.dart # Firebase-Konfiguration für Android & iOS
|
||||
├── main.dart # Einstiegspunkt, Theme & MultiProvider Setup
|
||||
├── models/
|
||||
│ └── post_item.dart # Typisiertes Datenmodell für News, Mängel & Ideen
|
||||
│ └── post_item.dart # Datenmodell für News, Aussendungen, Mängel & Ideen
|
||||
├── screens/
|
||||
│ ├── add_issue_screen.dart # Mängelerfassung mit Straßenauswahl & bis zu 2 Fotos
|
||||
│ ├── add_post_screen.dart # Universelle Erfassung für News & Bürgerideen
|
||||
│ ├── auth_dialog.dart # Anmelde-, Registrierungs- & Code-Freischaltungs-Dialog
|
||||
│ ├── contact_screen.dart # Bürgerforum-Kontaktformular & Anfragen
|
||||
│ ├── detail_screen.dart # Detailansicht mit Metadaten & PDF-Einzelexport
|
||||
│ ├── home_screen.dart # Haupt-Scaffold mit 5 Tabs (News, Mängel, Ideen, Kontakt, Info)
|
||||
│ ├── info_screen.dart # Bürgerforum Leitbild, Notrufnummern & Account-Status
|
||||
│ └── post_list_screen.dart # Universelle Listenansicht, Echtzeitsuche & PDF-Tabelle
|
||||
│ ├── 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/
|
||||
├── auth_service.dart # Hybrid-Auth (E-Mail/Passwort, Bürger-Code, Anonym)
|
||||
├── firestore_service.dart# NoSQL CRUD & Kontaktanfragen
|
||||
├── pdf_service.dart # PDF-Druckerzeugung (Listen & Einzelansichten)
|
||||
└── storage_service.dart # Optimierter Bild-Upload (Querformat max 800x600)
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤖 CI/CD Pipeline (Gitea Actions)
|
||||
## 🤖 CI/CD Pipeline
|
||||
|
||||
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
|
||||
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.
|
||||
|
||||
### Funktionen der Pipeline:
|
||||
- **Automatische Trigger:** Wird bei jedem `push` auf `main`, `master`, `refactor/**`, bei Git-Tags (`v*`) oder manuell im Webinterface (`workflow_dispatch`) ausgelöst.
|
||||
- **Isolierte Build-Umgebung:** Nutzt das offizielle `ghcr.io/cirruslabs/flutter:3.19.3` Docker-Image mit vorinstalliertem Android SDK, Java 17 und Flutter.
|
||||
- **Qualitätssicherung:** Führt automatisch `flutter analyze` und `flutter test` aus, bevor der Build startet.
|
||||
- **Vollständige APK-Generierung:**
|
||||
- `oggauer-gemeindetrommler-universal-release.apk` (Universelle Release-APK für alle Android-Geräte)
|
||||
- `oggauer-gemeindetrommler-arm64-v8a.apk` (Für moderne 64-Bit Smartphones)
|
||||
- `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Smartphones)
|
||||
- `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren & ChromeOS)
|
||||
- **Artifact-Upload:** Speichert die fertigen APKs als herunterladbare ZIP-Datei (`oggauer-gemeindetrommler-apks`) direkt im Gitea Run-Dashboard.
|
||||
### Android (Gitea Actions, `.gitea/workflows/build-apk.yaml`)
|
||||
|
||||
### APKs in Gitea herunterladen:
|
||||
1. Im Gitea-Repository auf den Reiter **Aktionen** (*Actions*) klicken.
|
||||
2. Den gewünschten Pipeline-Durchlauf auswählen.
|
||||
3. Im Abschnitt **Artefakte** (*Artifacts*) das Paket `oggauer-gemeindetrommler-apks` herunterladen.
|
||||
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.44.0` (letztes von Cirrus Labs veröffentlichtes Image, liefert JDK 21 + Android SDK 36). Das darin enthaltene Flutter-SDK wird im ersten Schritt per `git checkout` auf `FLUTTER_VERSION` umgeschaltet.
|
||||
- **Runner:** Oracle Cloud ARM64 – x86_64-Werkzeuge (AAPT2, `gen_snapshot`) laufen über QEMU (`scripts/ci/qemu_intercept.c`).
|
||||
- **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus.
|
||||
- **Erzeugte APK-Pakete:**
|
||||
1. `oggauer-gemeindetrommler-universal-release.apk` (Universelle Version für alle Android-Smartphones).
|
||||
2. `oggauer-gemeindetrommler-arm64-v8a.apk` (Optimiert für moderne 64-Bit Geräte).
|
||||
3. `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Geräte).
|
||||
4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren).
|
||||
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als Artefakte im Gitea-Reiter **Aktionen** sowie im Release `latest` zum Download bereit.
|
||||
|
||||
### iOS (GitHub Actions, `.github/workflows/build-ios.yaml`)
|
||||
|
||||
- **Runner:** `macos-26` (Xcode 26), Flutter über `subosito/flutter-action`.
|
||||
- **Plugins:** ausschließlich über CocoaPods (Swift Package Manager ist im Workflow bewusst deaktiviert); `ios/Podfile.lock` wird nicht versioniert, `pod install` läuft bei jedem Build frisch.
|
||||
- **Ergebnis:** unsigniertes IPA (`flutter build ios --release --no-codesign`) als Artefakt `oggauer-gemeindetrommler-ios` – für die Verteilung muss es nachträglich signiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Einrichtung & Lokaler Start
|
||||
## 🛠️ Einrichtung & Lokale Ausführung
|
||||
|
||||
### Voraussetzungen
|
||||
1. **Flutter SDK** (empfohlen: `>= 3.19.x`)
|
||||
2. **Dart SDK** (enthalten in Flutter)
|
||||
3. **Android Studio** / **Xcode** (für Emulatoren oder physische Geräte)
|
||||
4. Konfiguriertes Firebase-Projekt (`gemeinde-trommler`)
|
||||
### 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
|
||||
|
||||
### Installation & Ausführung
|
||||
### Befehle:
|
||||
```bash
|
||||
# 1. Abhängigkeiten installieren
|
||||
# 1. Abhängigkeiten laden
|
||||
flutter pub get
|
||||
|
||||
# 2. Statische Code-Analyse ausführen
|
||||
flutter analyze
|
||||
|
||||
# 3. Tests ausführen
|
||||
# 3. Unit-Tests ausführen
|
||||
flutter test
|
||||
|
||||
# 4. App lokal starten
|
||||
# 4. App im Entwicklungsmodus starten
|
||||
flutter run
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
|
||||
+38
-50
@@ -1,65 +1,34 @@
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
// (With android.builtInKotlin=false the Flutter plugin applies kotlin-android itself.)
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id "com.google.gms.google-services"
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
// START: FlutterFire Configuration
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
// END: FlutterFire Configuration
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
namespace "com.oggauergemeindetrommler.app"
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
ndkVersion "26.1.10909125"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.oggauergemeindetrommler.app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 23
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
// Flutter 3.47 defaults to 24; 23 (Android 6.0) is still supported and
|
||||
// is also the minimum required by the current Firebase Android SDKs.
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
// Read from pubspec.yaml (via local.properties); defaults to 1 / "1.0".
|
||||
versionCode flutter.versionCode
|
||||
versionName flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -69,12 +38,31 @@ android {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
// `flutter build apk --split-per-abi` makes the Flutter Gradle plugin enable ABI
|
||||
// splits for the requested target platforms but switch the universal APK off.
|
||||
// Turn it back on so a single Gradle run yields the per-ABI APKs *and* the
|
||||
// universal one (the plugin copies all of them to build/app/outputs/flutter-apk/,
|
||||
// the universal one as app-release.apk). Only when splitting: for plain builds
|
||||
// the plugin sets ndk.abiFilters instead, which AGP does not allow together
|
||||
// with splits. Do not use AGP's `packageReleaseUniversalApk` task for this -
|
||||
// it derives the universal APK from an App Bundle, and bundles cannot be
|
||||
// built while ABI splits are enabled.
|
||||
if (project.findProperty("split-per-abi")?.toString()?.toBoolean()) {
|
||||
splits {
|
||||
abi {
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.oggauergemeindetrommler.app">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Oggauer Gemeindetrommler"
|
||||
android:name="${applicationName}"
|
||||
@@ -35,5 +34,24 @@
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<data android:scheme="mailto" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.DIAL" />
|
||||
<data android:scheme="tel" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
|
||||
-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 {
|
||||
repositories {
|
||||
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 {
|
||||
// Note: Do not override project.buildDir across different drives on Windows (H: vs C:)
|
||||
|
||||
// Inject namespace into plugins that don't have it (required for AGP 8+)
|
||||
afterEvaluate { project ->
|
||||
if (project.hasProperty('android')) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
project.layout.buildDirectory.value(newBuildDir.dir(project.name))
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
|
||||
@@ -1,4 +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.enableJetifier=true
|
||||
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
|
||||
# Stay on the legacy AGP DSL / external Kotlin plugin until all Flutter plugins
|
||||
# support the AGP 9 built-in Kotlin (same as the Flutter 3.47 project template).
|
||||
android.newDsl=false
|
||||
android.builtInKotlin=false
|
||||
# Kotlin incremental compilation stores project-relative paths and breaks on Windows
|
||||
# when the pub cache (C:) and the project live on different drives
|
||||
# ("this and base files have different roots"). CI builds are clean anyway.
|
||||
kotlin.incremental=false
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-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")
|
||||
def properties = new Properties()
|
||||
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||
// Versions follow the Flutter 3.47 project template (see
|
||||
// packages/flutter_tools/lib/src/android/gradle_utils.dart in the Flutter SDK).
|
||||
// Flutter refuses to build with AGP < 8.11.1, Kotlin < 2.2.20 or Gradle < 8.14.
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "9.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
|
||||
id "com.google.gms.google-services" version "4.5.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
@@ -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
|
||||
+51
-25
@@ -1,35 +1,61 @@
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// News / Neuigkeiten: Öffentlich lesbar, Schreiben für angemeldete Nutzer
|
||||
match /News/{document=**} {
|
||||
allow read: if true;
|
||||
allow write: if request.auth != null;
|
||||
|
||||
// Hilfsfunktionen
|
||||
function isAuthenticated() {
|
||||
return request.auth != null;
|
||||
}
|
||||
|
||||
// Mängel & Anliegen: Öffentlich lesbar, Erstellen angemeldet, Löschen nur eigener Autor
|
||||
match /Issues/{document} {
|
||||
allow read: if true;
|
||||
allow create: if request.auth != null;
|
||||
allow update: if request.auth != null;
|
||||
allow delete: if request.auth != null &&
|
||||
(resource.data.author_uid == request.auth.uid);
|
||||
|
||||
function 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)))
|
||||
);
|
||||
}
|
||||
|
||||
// Bürgerforum / Ideen: Öffentlich lesbar, Erstellen angemeldet, Löschen eigener Autor
|
||||
match /CitizensForum/{document} {
|
||||
allow read: if true;
|
||||
allow create: if request.auth != null;
|
||||
allow update: if request.auth != null;
|
||||
allow delete: if request.auth != null &&
|
||||
(resource.data.author_uid == request.auth.uid);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
// Kontakt-Nachrichten: Jeder kann einsenden, Lesen nur berechtigte Nutzer
|
||||
|
||||
// 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} {
|
||||
allow create: if true;
|
||||
allow read, update, delete: if request.auth != null;
|
||||
// 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.pbxuser
|
||||
!default.perspectivev3
|
||||
|
||||
# Podfile.lock is intentionally not tracked: development happens on Windows (no
|
||||
# CocoaPods), and the iOS CI always runs a fresh `pod install`. A stale lock file
|
||||
# only causes "sandbox is not in sync" / incompatible pod version errors.
|
||||
Podfile.lock
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>11.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
CODE_SIGNING_REQUIRED=NO
|
||||
CODE_SIGN_IDENTITY=
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '12.0'
|
||||
# Flutter 3.47+ requires iOS 15.0 as the minimum deployment target.
|
||||
platform :ios, '15.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -31,11 +31,19 @@ target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
# Keep every pod on the same deployment target as the app so old
|
||||
# podspecs do not drag the minimum back below what Flutter supports.
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
# Pods never need to be signed; the CI produces an unsigned IPA.
|
||||
config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
|
||||
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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 */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1300;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
@@ -348,7 +348,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -363,13 +363,13 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -436,7 +436,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -486,7 +486,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -503,13 +503,13 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -535,13 +535,13 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 4YF7BFHHRH;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@UIApplicationMain
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class MyApp extends StatelessWidget {
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
|
||||
+215
-4
@@ -11,19 +11,28 @@ class PostItem {
|
||||
final String? street;
|
||||
final String? category;
|
||||
final String? status;
|
||||
final String? pdfUrl;
|
||||
final String? webUrl;
|
||||
final bool isAussendung;
|
||||
|
||||
PostItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.imageUrl = '',
|
||||
String imageUrl = '',
|
||||
List<String>? imageUrls,
|
||||
required this.createdDate,
|
||||
this.authorUid,
|
||||
this.street,
|
||||
this.category,
|
||||
this.status = 'Gemeldet',
|
||||
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
|
||||
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>? ?? {};
|
||||
@@ -43,6 +52,9 @@ class PostItem {
|
||||
images = [legacyImage];
|
||||
}
|
||||
|
||||
final hasIssueFields = data['street'] != null || data['category'] != null;
|
||||
final status = data['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
|
||||
|
||||
return PostItem(
|
||||
id: doc.id,
|
||||
title: data['title'] as String? ?? '',
|
||||
@@ -53,7 +65,10 @@ class PostItem {
|
||||
authorUid: data['author_uid'] as String?,
|
||||
street: data['street'] as String?,
|
||||
category: data['category'] as String?,
|
||||
status: data['status'] as String? ?? 'Gemeldet',
|
||||
status: status,
|
||||
pdfUrl: data['pdfUrl'] as String?,
|
||||
webUrl: data['webUrl'] as String?,
|
||||
isAussendung: data['isAussendung'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +83,202 @@ class PostItem {
|
||||
if (street != null) 'street': street,
|
||||
if (category != null) 'category': category,
|
||||
if (status != null) 'status': status,
|
||||
if (pdfUrl != null) 'pdfUrl': pdfUrl,
|
||||
if (webUrl != null) 'webUrl': webUrl,
|
||||
if (isAussendung) 'isAussendung': isAussendung,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert to standard JSON map for local caching
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'imageUrl': imageUrl,
|
||||
'imageUrls': imageUrls,
|
||||
'createdDate': createdDate.toIso8601String(),
|
||||
'authorUid': authorUid,
|
||||
'street': street,
|
||||
'category': category,
|
||||
'status': status,
|
||||
'pdfUrl': pdfUrl,
|
||||
'webUrl': webUrl,
|
||||
'isAussendung': isAussendung,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from standard JSON map (local cache)
|
||||
factory PostItem.fromJson(Map<String, dynamic> map) {
|
||||
DateTime date;
|
||||
if (map['createdDate'] is String) {
|
||||
date = DateTime.tryParse(map['createdDate'] as String) ?? DateTime.now();
|
||||
} else {
|
||||
date = DateTime.now();
|
||||
}
|
||||
|
||||
final String legacyImage = map['imageUrl'] as String? ?? '';
|
||||
List<String> images = [];
|
||||
if (map['imageUrls'] is List) {
|
||||
images = List<String>.from(map['imageUrls'] as List);
|
||||
} else if (legacyImage.isNotEmpty) {
|
||||
images = [legacyImage];
|
||||
}
|
||||
|
||||
final hasIssueFields = map['street'] != null || map['category'] != null;
|
||||
final status =
|
||||
map['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
|
||||
|
||||
return PostItem(
|
||||
id: map['id'] as String? ?? '',
|
||||
title: map['title'] as String? ?? '',
|
||||
description: map['description'] as String? ?? '',
|
||||
imageUrl: legacyImage.isNotEmpty
|
||||
? legacyImage
|
||||
: (images.isNotEmpty ? images.first : ''),
|
||||
imageUrls: images,
|
||||
createdDate: date,
|
||||
authorUid: map['authorUid'] as String?,
|
||||
street: map['street'] as String?,
|
||||
category: map['category'] as String?,
|
||||
status: status,
|
||||
pdfUrl: map['pdfUrl'] as String?,
|
||||
webUrl: map['webUrl'] as String?,
|
||||
isAussendung: map['isAussendung'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Decode HTML entities commonly present in WordPress feeds
|
||||
static String unescapeHtml(String text) {
|
||||
var result = text
|
||||
.replaceAll('&', '&')
|
||||
.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,13 +1,18 @@
|
||||
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 {
|
||||
const AddIssueScreen({super.key});
|
||||
final PostItem? issueToEdit;
|
||||
|
||||
const AddIssueScreen({super.key, this.issueToEdit});
|
||||
|
||||
@override
|
||||
State<AddIssueScreen> createState() => _AddIssueScreenState();
|
||||
@@ -22,8 +27,18 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
final StorageService _storageService = StorageService();
|
||||
|
||||
final List<File> _selectedImages = [];
|
||||
List<String> _existingImageUrls = [];
|
||||
String _selectedStatus = 'Gemeldet';
|
||||
bool _isSaving = false;
|
||||
|
||||
bool get isEditing => widget.issueToEdit != null;
|
||||
|
||||
final List<String> _statuses = [
|
||||
'Gemeldet',
|
||||
'In Bearbeitung',
|
||||
'Erledigt',
|
||||
];
|
||||
|
||||
final List<String> _streets = [
|
||||
'Hauptstraße',
|
||||
'Seestraße',
|
||||
@@ -56,8 +71,31 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedStreet = _streets.first;
|
||||
_selectedCategory = _categories.first;
|
||||
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
|
||||
@@ -68,9 +106,10 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
if (_selectedImages.length >= 2) {
|
||||
if (_existingImageUrls.length + _selectedImages.length >= 2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
|
||||
const SnackBar(
|
||||
content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -99,6 +138,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
void _showImageSourceModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
@@ -135,43 +175,89 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||
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 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,
|
||||
// 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,
|
||||
);
|
||||
uploadedUrls.add(url);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
final newIssue = PostItem(
|
||||
id: '',
|
||||
title: _titleController.text.trim(),
|
||||
description: _descriptionController.text.trim(),
|
||||
imageUrl: uploadedUrls.isNotEmpty ? uploadedUrls.first : '',
|
||||
imageUrls: uploadedUrls,
|
||||
createdDate: DateTime.now(),
|
||||
authorUid: currentUserId,
|
||||
street: _selectedStreet,
|
||||
category: _selectedCategory,
|
||||
status: 'Gemeldet',
|
||||
);
|
||||
|
||||
await _firestoreService.addPost('Issues', newIssue);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Mangel wurde erfolgreich gemeldet!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -191,12 +277,21 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = Provider.of<AuthService>(context, listen: false);
|
||||
final isAdmin = auth.isAdmin;
|
||||
final totalPhotos = _existingImageUrls.length + _selectedImages.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Mangel melden'),
|
||||
title: Text(isEditing ? 'Mangel bearbeiten' : 'Mangel melden'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
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(
|
||||
@@ -215,7 +310,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedStreet,
|
||||
initialValue: _selectedStreet,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Straße / Örtlichkeit *',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -232,7 +327,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCategory,
|
||||
initialValue: _selectedCategory,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Grundlage / Kategorie *',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -245,6 +340,26 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedCategory = val),
|
||||
),
|
||||
if (isAdmin && isEditing) ...[
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedStatus,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bearbeitungsstatus (Admin) *',
|
||||
prefixIcon: Icon(Icons.assignment_turned_in_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _statuses
|
||||
.map((status) => DropdownMenuItem(
|
||||
value: status,
|
||||
child: Text(status),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedStatus = val);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
@@ -265,67 +380,127 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
..._selectedImages.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final file = entry.value;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
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(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: FileImage(file),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade400,
|
||||
style: BorderStyle.solid),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.cancel, color: Colors.red),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedImages.removeAt(index);
|
||||
});
|
||||
},
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
if (_selectedImages.length < 2)
|
||||
InkWell(
|
||||
onTap: _showImageSourceModal,
|
||||
child: Container(
|
||||
width: 110,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade400,
|
||||
style: BorderStyle.solid),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_a_photo, color: Colors.grey),
|
||||
SizedBox(height: 4),
|
||||
Text('Foto hinzufügen',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Colors.black54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
@@ -340,9 +515,13 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.check_circle_outline),
|
||||
: Icon(isEditing ? Icons.save : Icons.check_circle_outline),
|
||||
label: Text(
|
||||
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
|
||||
_isSaving
|
||||
? 'Wird gespeichert...'
|
||||
: (isEditing
|
||||
? 'Änderungen speichern'
|
||||
: 'Mangel fertigstellen'),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -9,11 +10,13 @@ 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
|
||||
@@ -29,8 +32,25 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
final StorageService _storageService = StorageService();
|
||||
|
||||
File? _selectedImage;
|
||||
String? _existingImageUrl;
|
||||
bool _isSaving = false;
|
||||
|
||||
bool get isEditing => widget.postToEdit != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.postToEdit != null) {
|
||||
_titleController.text = widget.postToEdit!.title;
|
||||
_descriptionController.text = widget.postToEdit!.description;
|
||||
if (widget.postToEdit!.imageUrls.isNotEmpty) {
|
||||
_existingImageUrl = widget.postToEdit!.imageUrls.first;
|
||||
} else if (widget.postToEdit!.imageUrl.isNotEmpty) {
|
||||
_existingImageUrl = widget.postToEdit!.imageUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
@@ -63,6 +83,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
void _showImageSourceModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
@@ -99,38 +120,85 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||
String imageUrl = '';
|
||||
if (isEditing) {
|
||||
String imageUrl = _existingImageUrl ?? '';
|
||||
|
||||
if (_selectedImage != null) {
|
||||
imageUrl = await _storageService.uploadFile(
|
||||
file: _selectedImage!,
|
||||
collectionName: widget.collectionName,
|
||||
optimize: true,
|
||||
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);
|
||||
}
|
||||
|
||||
final newPost = PostItem(
|
||||
id: '',
|
||||
title: _titleController.text.trim(),
|
||||
description: _descriptionController.text.trim(),
|
||||
imageUrl: imageUrl,
|
||||
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
|
||||
createdDate: DateTime.now(),
|
||||
authorUid: currentUserId,
|
||||
);
|
||||
|
||||
await _firestoreService.addPost(widget.collectionName, newPost);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Eintrag erfolgreich veröffentlicht!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -152,10 +220,15 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.screenTitle),
|
||||
title: Text(isEditing ? 'Beitrag bearbeiten' : widget.screenTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
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(
|
||||
@@ -212,12 +285,68 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
backgroundColor: Colors.black54,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => setState(() => _selectedImage = null),
|
||||
onPressed: () =>
|
||||
setState(() => _selectedImage = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_existingImageUrl != null &&
|
||||
_existingImageUrl!.isNotEmpty)
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _existingImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (ctx, url) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
errorWidget: (ctx, url, err) => Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.broken_image, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.black54,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
tooltip: 'Bild entfernen',
|
||||
onPressed: () =>
|
||||
setState(() => _existingImageUrl = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
),
|
||||
onPressed: _showImageSourceModal,
|
||||
icon: const Icon(Icons.camera_alt, size: 16),
|
||||
label: const Text('Ersetzen',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
InkWell(
|
||||
onTap: _showImageSourceModal,
|
||||
@@ -232,9 +361,11 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey),
|
||||
Icon(Icons.add_photo_alternate,
|
||||
size: 36, color: Colors.grey),
|
||||
SizedBox(height: 6),
|
||||
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)),
|
||||
Text('Foto hinzufügen',
|
||||
style: TextStyle(color: Colors.black54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -252,9 +383,13 @@ class _AddPostScreenState extends State<AddPostScreen> {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
: Icon(isEditing ? Icons.save : Icons.send),
|
||||
label: Text(
|
||||
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
|
||||
_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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+422
-170
@@ -25,7 +25,8 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
final _loginPasswordController = TextEditingController();
|
||||
|
||||
// Register Controllers
|
||||
final _regNameController = TextEditingController();
|
||||
final _regFirstNameController = TextEditingController();
|
||||
final _regLastNameController = TextEditingController();
|
||||
final _regEmailController = TextEditingController();
|
||||
final _regPasswordController = TextEditingController();
|
||||
final _regConfirmPasswordController = TextEditingController();
|
||||
@@ -36,10 +37,14 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
bool _obscureLoginPassword = true;
|
||||
bool _obscureRegPassword = true;
|
||||
bool _obscureRegConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -47,7 +52,8 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
_tabController.dispose();
|
||||
_loginEmailController.dispose();
|
||||
_loginPasswordController.dispose();
|
||||
_regNameController.dispose();
|
||||
_regFirstNameController.dispose();
|
||||
_regLastNameController.dispose();
|
||||
_regEmailController.dispose();
|
||||
_regPasswordController.dispose();
|
||||
_regConfirmPasswordController.dispose();
|
||||
@@ -89,13 +95,14 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
}
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
final name = _regNameController.text.trim();
|
||||
final firstName = _regFirstNameController.text.trim();
|
||||
final lastName = _regLastNameController.text.trim();
|
||||
final email = _regEmailController.text.trim();
|
||||
final password = _regPasswordController.text;
|
||||
final confirm = _regConfirmPasswordController.text;
|
||||
|
||||
if (name.isEmpty || email.isEmpty || password.isEmpty) {
|
||||
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen.');
|
||||
if (firstName.isEmpty || lastName.isEmpty || email.isEmpty || password.isEmpty) {
|
||||
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen (Vorname, Nachname, E-Mail, Passwort).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,17 +126,46 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
final error = await auth.signUpWithEmail(
|
||||
email: email,
|
||||
password: password,
|
||||
name: name,
|
||||
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('Konto erfolgreich erstellt! Willkommen im Bürgerforum.'),
|
||||
content: Text('Erfolgreich mit Google angemeldet!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -228,22 +264,48 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 440,
|
||||
maxHeight: screenHeight * 0.82,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Bürgerforum Oggau',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
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),
|
||||
@@ -258,11 +320,10 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
tabs: const [
|
||||
Tab(text: 'Anmelden'),
|
||||
Tab(text: 'Registrieren'),
|
||||
Tab(text: 'Code-Login'),
|
||||
],
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -286,168 +347,311 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Tab 1: Login
|
||||
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),
|
||||
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),
|
||||
TextField(
|
||||
controller: _loginPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _showPasswordResetDialog,
|
||||
child: const Text(
|
||||
'Passwort vergessen?',
|
||||
style: TextStyle(fontSize: 12),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Tab 2: Registrierung
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _regNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name / Vorname',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(),
|
||||
Expanded(child: Divider(color: Colors.grey.shade300)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildGoogleButton(label: 'Mit Google anmelden'),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => _tabController.animateTo(1),
|
||||
child: const Text(
|
||||
'Noch kein Konto? Hier registrieren oder Bürger-Code eingeben',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _regEmailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'E-Mail-Adresse',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _regPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Passwort (mind. 6 Zeichen)',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _regConfirmPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Passwort bestätigen',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Text('Konto erstellen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Tab 3: Bürger-Code
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Du möchtest kein persönliches Konto anlegen? Gib einfach den Zugangscode des Bürgerforums ein, um sofort Mängel und Ideen melden zu können.',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _codeController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bürger-Zugangscode',
|
||||
prefixIcon: Icon(Icons.key_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'z.B. oggau',
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleCodeUnlock,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Text('Freischalten'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -456,4 +660,52 @@ class _AuthDialogState extends State<AuthDialog>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+274
-64
@@ -1,5 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/firestore_service.dart';
|
||||
import 'admin_messages_screen.dart';
|
||||
import 'auth_dialog.dart';
|
||||
|
||||
class ContactScreen extends StatefulWidget {
|
||||
const ContactScreen({super.key});
|
||||
@@ -29,11 +33,12 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isValidEmail(String email) {
|
||||
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
|
||||
}
|
||||
Future<void> _submitForm(AuthService auth) async {
|
||||
if (!auth.isRegisteredUser) {
|
||||
await AuthDialog.show(context);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
if (!_formKey.currentState!.validate() || _isSubmitting) return;
|
||||
|
||||
setState(() {
|
||||
@@ -41,19 +46,27 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final firstName = auth.firstName.isNotEmpty
|
||||
? auth.firstName
|
||||
: _firstNameController.text.trim();
|
||||
final lastName = auth.lastName.isNotEmpty
|
||||
? auth.lastName
|
||||
: _lastNameController.text.trim();
|
||||
final email = (auth.email != null && auth.email!.isNotEmpty)
|
||||
? auth.email!
|
||||
: _emailController.text.trim();
|
||||
|
||||
await _firestoreService.submitContactMessage(
|
||||
firstName: _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: email,
|
||||
phone: _phoneController.text.trim(),
|
||||
message: _messageController.text.trim(),
|
||||
authorUid: auth.user?.uid,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
_firstNameController.clear();
|
||||
_lastNameController.clear();
|
||||
_emailController.clear();
|
||||
_phoneController.clear();
|
||||
_messageController.clear();
|
||||
|
||||
@@ -82,10 +95,45 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = Provider.of<AuthService>(context);
|
||||
final isRegistered = auth.isRegisteredUser;
|
||||
|
||||
// Sync controllers with authenticated user details
|
||||
if (isRegistered) {
|
||||
if (_firstNameController.text != auth.firstName) {
|
||||
_firstNameController.text = auth.firstName;
|
||||
}
|
||||
if (_lastNameController.text != auth.lastName) {
|
||||
_lastNameController.text = auth.lastName;
|
||||
}
|
||||
if (_emailController.text != (auth.email ?? '')) {
|
||||
_emailController.text = auth.email ?? '';
|
||||
}
|
||||
} else {
|
||||
if (_firstNameController.text.isNotEmpty) _firstNameController.clear();
|
||||
if (_lastNameController.text.isNotEmpty) _lastNameController.clear();
|
||||
if (_emailController.text.isNotEmpty) _emailController.clear();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Kontakt Bürgerforum'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (auth.isAdmin)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.inbox),
|
||||
tooltip: 'Posteingang: Bürgernachrichten',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AdminMessagesScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
@@ -94,54 +142,186 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
color: Colors.blue.shade50,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(14.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Hier kannst du direkt mit dem Bürgerforum Oggau in Kontakt treten. Wir freuen uns über deine Anregungen und Fragen!',
|
||||
style: TextStyle(fontSize: 13),
|
||||
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: 16),
|
||||
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,
|
||||
decoration: const InputDecoration(
|
||||
readOnly: true,
|
||||
style: TextStyle(
|
||||
color: isRegistered
|
||||
? Colors.black87
|
||||
: Colors.grey.shade600,
|
||||
fontWeight: isRegistered
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Vorname *',
|
||||
border: OutlineInputBorder(),
|
||||
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(),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Bitte Vorname eingeben'
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
decoration: const InputDecoration(
|
||||
readOnly: true,
|
||||
style: TextStyle(
|
||||
color: isRegistered
|
||||
? Colors.black87
|
||||
: Colors.grey.shade600,
|
||||
fontWeight: isRegistered
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nachname *',
|
||||
border: OutlineInputBorder(),
|
||||
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(),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Bitte Nachname eingeben'
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -149,43 +329,60 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
readOnly: true,
|
||||
style: TextStyle(
|
||||
color: isRegistered ? Colors.black87 : Colors.grey.shade600,
|
||||
fontWeight:
|
||||
isRegistered ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'E-Mail-Adresse *',
|
||||
border: OutlineInputBorder(),
|
||||
hintText:
|
||||
isRegistered ? null : 'Wird durch Anmeldung ausgefüllt',
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
prefixIcon: const Icon(Icons.email_outlined),
|
||||
suffixIcon: isRegistered
|
||||
? const Icon(Icons.lock_outline,
|
||||
size: 18, color: Colors.grey)
|
||||
: null,
|
||||
helperText: isRegistered
|
||||
? 'Antworten des Bürgerforums gehen an diese E-Mail'
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Bitte E-Mail-Adresse eingeben';
|
||||
}
|
||||
if (!_isValidEmail(value.trim())) {
|
||||
return 'Bitte eine gültige E-Mail eingeben';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
enabled: isRegistered,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rückrufnummer (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.phone_outlined),
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: isRegistered
|
||||
? 'Optional für eventuelle telefonische Rückfragen'
|
||||
: null,
|
||||
filled: !isRegistered,
|
||||
fillColor: !isRegistered ? Colors.grey.shade100 : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _messageController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
enabled: isRegistered,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Ihr Anliegen / Ihre Nachricht *',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
filled: !isRegistered,
|
||||
fillColor: !isRegistered ? Colors.grey.shade100 : null,
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Bitte Ihre Nachricht eingeben'
|
||||
: null,
|
||||
validator: (value) => (value == null || value.trim().isEmpty)
|
||||
? 'Bitte Ihre Nachricht eingeben'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -201,16 +398,29 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSubmitting ? null : _submitForm,
|
||||
onPressed: !isRegistered
|
||||
? () => AuthDialog.show(context)
|
||||
: (_isSubmitting ? null : () => _submitForm(auth)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isRegistered
|
||||
? Colors.blue.shade800
|
||||
: Colors.grey.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
icon: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
: Icon(!isRegistered ? Icons.login : Icons.send),
|
||||
label: Text(
|
||||
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
|
||||
!isRegistered
|
||||
? 'Anmelden zum Absenden'
|
||||
: (_isSubmitting
|
||||
? 'Wird gesendet...'
|
||||
: 'Nachricht absenden'),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
|
||||
+322
-62
@@ -1,58 +1,296 @@
|
||||
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 StatelessWidget {
|
||||
class DetailScreen extends StatefulWidget {
|
||||
final PostItem item;
|
||||
final String? collectionName;
|
||||
|
||||
const DetailScreen({super.key, required this.item});
|
||||
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);
|
||||
DateFormat('dd.MM.yyyy HH:mm').format(_item.createdDate);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(item.title, overflow: TextOverflow.ellipsis),
|
||||
title: Text(_item.title, overflow: TextOverflow.ellipsis),
|
||||
actions: [
|
||||
if (canEdit)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Beitrag bearbeiten',
|
||||
onPressed: _editItem,
|
||||
),
|
||||
if (canDelete)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Beitrag löschen',
|
||||
onPressed: _deleteItem,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: 'Als PDF drucken / teilen',
|
||||
onPressed: () => PdfService.printSinglePost(item),
|
||||
onPressed: () => PdfService.printSinglePost(_item),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
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)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.imageUrls.first,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
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),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 180,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image_not_supported, size: 48),
|
||||
),
|
||||
)
|
||||
else
|
||||
Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 260,
|
||||
width: double.infinity,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _item.imageUrls.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentImageIndex = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: _item.imageUrls[index],
|
||||
width: double.infinity,
|
||||
height: 260,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 260,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image_not_supported,
|
||||
size: 48),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Page indicator dots
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(
|
||||
_item.imageUrls.length,
|
||||
(i) => Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: _currentImageIndex == i ? 16 : 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _currentImageIndex == i
|
||||
? Colors.white
|
||||
: Colors.white54,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Photo counter badge (e.g. 1 / 2)
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.photo_library,
|
||||
color: Colors.white, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
item.title,
|
||||
_item.title,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -64,48 +302,63 @@ class DetailScreen extends StatelessWidget {
|
||||
formattedDate,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
|
||||
),
|
||||
if (item.status != null) ...[
|
||||
if (isIssue && _item.status != null) ...[
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
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: Colors.blue.shade200),
|
||||
border: Border.all(
|
||||
color: _item.status == 'Erledigt'
|
||||
? Colors.green.shade200
|
||||
: (_item.status == 'In Bearbeitung'
|
||||
? Colors.blue.shade200
|
||||
: Colors.orange.shade200),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item.status!,
|
||||
_item.status!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue.shade800),
|
||||
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) ...[
|
||||
if (_item.street != null && _item.street!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Örtlichkeit: ${item.street}',
|
||||
'Örtlichkeit: ${_item.street}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.category != null && item.category!.isNotEmpty) ...[
|
||||
if (_item.category != null && _item.category!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.category, size: 16, color: Colors.orange),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Kategorie: ${item.category}',
|
||||
'Kategorie: ${_item.category}',
|
||||
style: TextStyle(color: Colors.grey.shade800),
|
||||
),
|
||||
],
|
||||
@@ -120,38 +373,45 @@ class DetailScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.description,
|
||||
_item.description,
|
||||
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Additional images gallery if there are multiple images
|
||||
if (item.imageUrls.length > 1) ...[
|
||||
const Text(
|
||||
'Weitere Fotos',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: item.imageUrls.length - 1,
|
||||
separatorBuilder: (ctx, i) => const SizedBox(width: 8),
|
||||
itemBuilder: (ctx, index) {
|
||||
final url = item.imageUrls[index + 1];
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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});
|
||||
@@ -51,11 +52,13 @@ class InfoScreen extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
// Bürgerforum Access & Account Card
|
||||
Card(
|
||||
color: auth.isRegisteredUser
|
||||
? Colors.green.shade50
|
||||
: auth.isCommunityUnlocked
|
||||
? Colors.teal.shade50
|
||||
: Colors.blue.shade50,
|
||||
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(
|
||||
@@ -65,16 +68,20 @@ class InfoScreen extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
auth.isRegisteredUser
|
||||
? Icons.account_circle
|
||||
: auth.isCommunityUnlocked
|
||||
? Icons.verified_user
|
||||
: Icons.lock_outline,
|
||||
color: auth.isRegisteredUser
|
||||
? Colors.green.shade700
|
||||
: auth.isCommunityUnlocked
|
||||
? Colors.teal.shade800
|
||||
: Colors.blue.shade800,
|
||||
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),
|
||||
@@ -83,23 +90,27 @@ class InfoScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
auth.isRegisteredUser
|
||||
? (auth.displayName?.isNotEmpty == true
|
||||
? auth.displayName!
|
||||
: 'Bürger-Konto')
|
||||
: auth.isCommunityUnlocked
|
||||
? 'Bürgerzugang: Aktiv (Code)'
|
||||
: 'Gastmodus (Nur Lesen)',
|
||||
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.isRegisteredUser
|
||||
? auth.email ?? 'Angemeldet'
|
||||
: auth.isCommunityUnlocked
|
||||
? 'Mängel & Ideen freigeschaltet'
|
||||
: 'Anmelden oder Code eingeben zum Mitwirken.',
|
||||
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),
|
||||
),
|
||||
@@ -126,6 +137,29 @@ class InfoScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (auth.isAdmin) ...[
|
||||
const Divider(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.manage_accounts),
|
||||
label: const Text('Benutzerverwaltung & Admin-Rechte'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const UserManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[
|
||||
const Divider(height: 16),
|
||||
InkWell(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+616
-207
@@ -1,9 +1,10 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../models/post_item.dart';
|
||||
import '../services/aussendungen_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/firestore_service.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
@@ -12,6 +13,7 @@ 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;
|
||||
@@ -30,12 +32,44 @@ class PostListScreen extends StatefulWidget {
|
||||
class _PostListScreenState extends State<PostListScreen> {
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
final StorageService _storageService = StorageService();
|
||||
final AussendungenService _aussendungenService = AussendungenService();
|
||||
|
||||
final Map<String, PostItem> _selectedItems = {};
|
||||
String _searchQuery = '';
|
||||
bool _isSearchActive = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<PostItem> _aussendungen = [];
|
||||
bool _isLoadingAussendungen = false;
|
||||
String _newsFilter = 'all'; // 'all', 'aussendungen', 'news'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.collectionName == 'News') {
|
||||
_loadAussendungen();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAussendungen({bool force = false}) async {
|
||||
if (widget.collectionName != 'News') return;
|
||||
if (mounted) setState(() => _isLoadingAussendungen = true);
|
||||
try {
|
||||
final items = await _aussendungenService.getAussendungen(forceRefresh: force);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aussendungen = items;
|
||||
_isLoadingAussendungen = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading Aussendungen: $e');
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingAussendungen = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -59,9 +93,28 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _deletePost(PostItem item) async {
|
||||
final auth = Provider.of<AuthService>(context, listen: false);
|
||||
final currentUser = auth.user;
|
||||
final isAuthor = currentUser != null && item.authorUid == currentUser.uid;
|
||||
final canDelete = isAuthor || auth.isAdmin;
|
||||
|
||||
if (!canDelete) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keine Berechtigung zum Löschen dieses Beitrags.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (final url in item.imageUrls) {
|
||||
await _storageService.deleteFileByUrl(url);
|
||||
try {
|
||||
await _storageService.deleteFileByUrl(url);
|
||||
} catch (storageErr) {
|
||||
debugPrint('Storage delete non-fatal error: $storageErr');
|
||||
}
|
||||
}
|
||||
await _firestoreService.deletePost(widget.collectionName, item.id);
|
||||
if (!mounted) return;
|
||||
@@ -76,9 +129,45 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _editPost(PostItem item) {
|
||||
if (widget.collectionName == 'Issues') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => AddIssueScreen(issueToEdit: item),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => AddPostScreen(
|
||||
collectionName: widget.collectionName,
|
||||
screenTitle: widget.collectionName == 'News'
|
||||
? 'Nachricht bearbeiten'
|
||||
: 'Idee bearbeiten',
|
||||
postToEdit: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAddNewPressed() async {
|
||||
final auth = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
// News are official announcements and can only be created by Admins
|
||||
if (widget.collectionName == 'News' && !auth.isAdmin) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Nur Administratoren können Neuigkeiten veröffentlichen.'),
|
||||
backgroundColor: Colors.amber,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||
await AuthDialog.show(context);
|
||||
if (!mounted) return;
|
||||
@@ -111,7 +200,8 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = FirebaseAuth.instance.currentUser;
|
||||
final auth = Provider.of<AuthService>(context);
|
||||
final currentUser = auth.user;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -180,180 +270,505 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
],
|
||||
],
|
||||
),
|
||||
body: 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) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final allItems = snapshot.data ?? [];
|
||||
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),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: filteredItems.length,
|
||||
separatorBuilder: (ctx, idx) => const Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: Colors.black12,
|
||||
),
|
||||
],
|
||||
)
|
||||
: _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),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = filteredItems[index];
|
||||
final isSelected = _selectedItems.containsKey(item.id);
|
||||
final isAuthor =
|
||||
currentUser != null && item.authorUid == currentUser.uid;
|
||||
final formattedDate =
|
||||
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
);
|
||||
}
|
||||
|
||||
Widget itemTile = ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.shade50,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
onTap: () {
|
||||
if (_selectedItems.isNotEmpty) {
|
||||
_toggleSelection(item);
|
||||
} else {
|
||||
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) => DetailScreen(item: item),
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(item),
|
||||
leading: item.imageUrl.isNotEmpty
|
||||
? 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),
|
||||
} 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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: isSelected
|
||||
? const CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.check, color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
] 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.street!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
const SizedBox(height: 4),
|
||||
if (item.isAussendung)
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Aussendung (PDF)',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
),
|
||||
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,
|
||||
@@ -363,57 +778,51 @@ class _PostListScreenState extends State<PostListScreen> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthor) {
|
||||
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;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _onAddNewPressed,
|
||||
tooltip: 'Neuen Eintrag erstellen',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
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: ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AuthService extends ChangeNotifier {
|
||||
@@ -18,16 +19,112 @@ class AuthService extends ChangeNotifier {
|
||||
String? get email => _user?.email;
|
||||
String? get displayName => _user?.displayName;
|
||||
|
||||
String? _cachedFirstName;
|
||||
String? _cachedLastName;
|
||||
bool _isAdmin = false;
|
||||
bool get isAdmin => _isAdmin;
|
||||
|
||||
String get firstName {
|
||||
if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) {
|
||||
return _cachedFirstName!;
|
||||
}
|
||||
final name = _user?.displayName ?? '';
|
||||
if (name.isEmpty) return '';
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
return parts.isNotEmpty ? parts.first : '';
|
||||
}
|
||||
|
||||
String get lastName {
|
||||
if (_cachedLastName != null && _cachedLastName!.isNotEmpty) {
|
||||
return _cachedLastName!;
|
||||
}
|
||||
final name = _user?.displayName ?? '';
|
||||
if (name.isEmpty) return '';
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
return parts.length > 1 ? parts.sublist(1).join(' ') : '';
|
||||
}
|
||||
|
||||
AuthService() {
|
||||
_initAuth();
|
||||
}
|
||||
|
||||
Future<void> _checkAdminClaim(User? user) async {
|
||||
if (user != null && !user.isAnonymous) {
|
||||
try {
|
||||
// 1. Custom claims check (token.admin == true)
|
||||
final tokenResult = await user.getIdTokenResult();
|
||||
if (tokenResult.claims?['admin'] == true) {
|
||||
_isAdmin = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final firestore = FirebaseFirestore.instance;
|
||||
|
||||
// 2. Check Firestore 'Admins' collection by user UID (doc id)
|
||||
final docByUid =
|
||||
await firestore.collection('Admins').doc(user.uid).get();
|
||||
if (docByUid.exists) {
|
||||
_isAdmin = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check Firestore 'Admins' collection by email (doc id)
|
||||
final userEmail = user.email?.trim().toLowerCase();
|
||||
if (userEmail != null && userEmail.isNotEmpty) {
|
||||
final docByEmail =
|
||||
await firestore.collection('Admins').doc(userEmail).get();
|
||||
if (docByEmail.exists) {
|
||||
_isAdmin = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Check Firestore 'Admins' collection where email field == userEmail
|
||||
final queryByEmail = await firestore
|
||||
.collection('Admins')
|
||||
.where('email', isEqualTo: userEmail)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (queryByEmail.docs.isNotEmpty) {
|
||||
_isAdmin = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check Firestore 'Admins' collection where uid field == user.uid
|
||||
final queryByUid = await firestore
|
||||
.collection('Admins')
|
||||
.where('uid', isEqualTo: user.uid)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (queryByUid.docs.isNotEmpty) {
|
||||
_isAdmin = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isAdmin = false;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Error checking admin status: $e');
|
||||
}
|
||||
_isAdmin = false;
|
||||
}
|
||||
} else {
|
||||
_isAdmin = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initAuth() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
|
||||
_cachedFirstName = prefs.getString('user_first_name');
|
||||
_cachedLastName = prefs.getString('user_last_name');
|
||||
|
||||
_auth.authStateChanges().listen((User? user) {
|
||||
_auth.authStateChanges().listen((User? user) async {
|
||||
_user = user;
|
||||
await _checkAdminClaim(user);
|
||||
if (user != null && !user.isAnonymous) {
|
||||
await _syncUserProfile(user);
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
@@ -36,6 +133,33 @@ class AuthService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncUserProfile(User user, {String? firstName, String? lastName}) async {
|
||||
if (user.isAnonymous) return;
|
||||
try {
|
||||
final cleanEmail = user.email?.trim().toLowerCase() ?? '';
|
||||
if (cleanEmail.isEmpty) return;
|
||||
|
||||
final cleanFirst = firstName ?? this.firstName;
|
||||
final cleanLast = lastName ?? this.lastName;
|
||||
final name = user.displayName ?? '$cleanFirst $cleanLast'.trim();
|
||||
|
||||
await FirebaseFirestore.instance.collection('Users').doc(user.uid).set({
|
||||
'uid': user.uid,
|
||||
'email': cleanEmail,
|
||||
'displayName': name,
|
||||
'firstName': cleanFirst,
|
||||
'lastName': cleanLast,
|
||||
'emailVerified': user.emailVerified,
|
||||
'lastLogin': FieldValue.serverTimestamp(),
|
||||
if (_isAdmin) 'isAdmin': true,
|
||||
}, SetOptions(merge: true));
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Error syncing user profile in auth service: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies community access code
|
||||
Future<bool> verifyCommunityCode(String enteredCode) async {
|
||||
final cleanCode = enteredCode.trim().toLowerCase();
|
||||
@@ -71,6 +195,10 @@ class AuthService extends ChangeNotifier {
|
||||
password: password,
|
||||
);
|
||||
_user = credential.user;
|
||||
await _checkAdminClaim(_user);
|
||||
if (_user != null) {
|
||||
await _syncUserProfile(_user!);
|
||||
}
|
||||
notifyListeners();
|
||||
return null; // success
|
||||
} on FirebaseAuthException catch (e) {
|
||||
@@ -80,11 +208,12 @@ class AuthService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign up with Email, Password, and Full Name
|
||||
/// Sign up with Email, Password, First Name, and Last Name
|
||||
Future<String?> signUpWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
required String name,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
}) async {
|
||||
try {
|
||||
final credential = await _auth.createUserWithEmailAndPassword(
|
||||
@@ -92,8 +221,31 @@ class AuthService extends ChangeNotifier {
|
||||
password: password,
|
||||
);
|
||||
_user = credential.user;
|
||||
if (_user != null && name.isNotEmpty) {
|
||||
await _user!.updateDisplayName(name.trim());
|
||||
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
|
||||
@@ -104,6 +256,36 @@ class AuthService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user's email is verified
|
||||
bool get isEmailVerified => _user?.emailVerified ?? false;
|
||||
|
||||
/// Reload current user from Firebase to refresh emailVerified status and admin claim
|
||||
Future<void> reloadUser() async {
|
||||
await _user?.reload();
|
||||
_user = _auth.currentUser;
|
||||
await _checkAdminClaim(_user);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Re-send verification email
|
||||
Future<String?> sendEmailVerification() async {
|
||||
try {
|
||||
await _user?.sendEmailVerification();
|
||||
return null;
|
||||
} on FirebaseAuthException catch (e) {
|
||||
return _getLocalizedErrorMessage(e.code);
|
||||
} catch (e) {
|
||||
return 'Fehler beim Senden der Bestätigungs-E-Mail: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign in with Google (UI stub / preparation)
|
||||
Future<String?> signInWithGoogle() async {
|
||||
// Note: Once SHA-1 fingerprint is registered in Firebase Console,
|
||||
// the full interactive Google OAuth sign-in flow can be activated.
|
||||
return 'Google Sign-In ist in Vorbereitung. Bitte nutze derzeit die E-Mail-Anmeldung oder den Bürger-Code.';
|
||||
}
|
||||
|
||||
/// Send password reset email
|
||||
Future<String?> sendPasswordReset(String email) async {
|
||||
try {
|
||||
@@ -130,6 +312,12 @@ class AuthService extends ChangeNotifier {
|
||||
/// Sign out
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('user_first_name');
|
||||
await prefs.remove('user_last_name');
|
||||
_cachedFirstName = null;
|
||||
_cachedLastName = null;
|
||||
_isAdmin = false;
|
||||
await _auth.signOut();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
|
||||
@@ -43,6 +43,14 @@ class FirestoreService {
|
||||
await _firestore.collection(collectionName).doc(documentId).delete();
|
||||
}
|
||||
|
||||
/// Update an existing post by ID
|
||||
Future<void> updatePost(String collectionName, PostItem item) async {
|
||||
await _firestore
|
||||
.collection(collectionName)
|
||||
.doc(item.id)
|
||||
.update(item.toFirestore());
|
||||
}
|
||||
|
||||
/// Send a contact form submission to Firestore
|
||||
Future<void> submitContactMessage({
|
||||
required String firstName,
|
||||
@@ -50,6 +58,7 @@ class FirestoreService {
|
||||
required String email,
|
||||
String? phone,
|
||||
required String message,
|
||||
String? authorUid,
|
||||
}) async {
|
||||
await _firestore.collection('ContactMessages').add({
|
||||
'firstName': firstName,
|
||||
@@ -57,8 +66,143 @@ class FirestoreService {
|
||||
'email': email,
|
||||
'phone': phone ?? '',
|
||||
'message': message,
|
||||
'author_uid': authorUid ?? '',
|
||||
'createdDate': Timestamp.now(),
|
||||
'status': 'Neu',
|
||||
});
|
||||
}
|
||||
|
||||
/// Stream of all contact messages (for admins)
|
||||
Stream<QuerySnapshot<Map<String, dynamic>>> getContactMessagesStream() {
|
||||
return _firestore
|
||||
.collection('ContactMessages')
|
||||
.orderBy('createdDate', descending: true)
|
||||
.snapshots();
|
||||
}
|
||||
|
||||
/// Update the status of a contact message
|
||||
Future<void> updateContactMessageStatus(String docId, String status) async {
|
||||
await _firestore.collection('ContactMessages').doc(docId).update({
|
||||
'status': status,
|
||||
'statusUpdatedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete a contact message
|
||||
Future<void> deleteContactMessage(String docId) async {
|
||||
await _firestore.collection('ContactMessages').doc(docId).delete();
|
||||
}
|
||||
|
||||
/// Stream of all registered users
|
||||
Stream<QuerySnapshot<Map<String, dynamic>>> getUsersStream() {
|
||||
return _firestore.collection('Users').snapshots();
|
||||
}
|
||||
|
||||
/// Stream of all admin documents
|
||||
Stream<QuerySnapshot<Map<String, dynamic>>> getAdminsStream() {
|
||||
return _firestore.collection('Admins').snapshots();
|
||||
}
|
||||
|
||||
/// Upsert a registered user profile into Firestore
|
||||
Future<void> syncUserProfile({
|
||||
required String uid,
|
||||
required String email,
|
||||
String? displayName,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
bool? emailVerified,
|
||||
bool? isAdmin,
|
||||
}) async {
|
||||
try {
|
||||
final docRef = _firestore.collection('Users').doc(uid);
|
||||
final doc = await docRef.get();
|
||||
final data = <String, dynamic>{
|
||||
'uid': uid,
|
||||
'email': email.trim().toLowerCase(),
|
||||
'lastLogin': FieldValue.serverTimestamp(),
|
||||
if (displayName != null && displayName.isNotEmpty)
|
||||
'displayName': displayName,
|
||||
if (firstName != null && firstName.isNotEmpty)
|
||||
'firstName': firstName,
|
||||
if (lastName != null && lastName.isNotEmpty)
|
||||
'lastName': lastName,
|
||||
'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));
|
||||
}
|
||||
}
|
||||
|
||||
+106
-18
@@ -35,8 +35,15 @@ class PdfService {
|
||||
/// Print single post item as PDF
|
||||
static Future<void> printSinglePost(PostItem item) async {
|
||||
final pdf = pw.Document();
|
||||
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
|
||||
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
|
||||
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(
|
||||
@@ -58,7 +65,8 @@ class PdfService {
|
||||
),
|
||||
pw.Text(
|
||||
formattedDate,
|
||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -68,6 +76,53 @@ class PdfService {
|
||||
item.title,
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
if (item.status != null && item.status!.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Text(
|
||||
'Status: ',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.grey800,
|
||||
),
|
||||
),
|
||||
pw.Container(
|
||||
padding:
|
||||
const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: item.status == 'Erledigt'
|
||||
? const PdfColor.fromInt(0xFFE8F5E9)
|
||||
: (item.status == 'In Bearbeitung'
|
||||
? const PdfColor.fromInt(0xFFE3F2FD)
|
||||
: const PdfColor.fromInt(0xFFFFF3E0)),
|
||||
borderRadius:
|
||||
const pw.BorderRadius.all(pw.Radius.circular(4)),
|
||||
border: pw.Border.all(
|
||||
color: item.status == 'Erledigt'
|
||||
? const PdfColor.fromInt(0xFF81C784)
|
||||
: (item.status == 'In Bearbeitung'
|
||||
? const PdfColor.fromInt(0xFF64B5F6)
|
||||
: const PdfColor.fromInt(0xFFFFB74D)),
|
||||
),
|
||||
),
|
||||
child: pw.Text(
|
||||
item.status!,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: item.status == 'Erledigt'
|
||||
? const PdfColor.fromInt(0xFF2E7D32)
|
||||
: (item.status == 'In Bearbeitung'
|
||||
? const PdfColor.fromInt(0xFF1565C0)
|
||||
: const PdfColor.fromInt(0xFFE65100)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
@@ -84,13 +139,29 @@ class PdfService {
|
||||
],
|
||||
pw.Divider(thickness: 1, color: PdfColors.grey400),
|
||||
pw.SizedBox(height: 10),
|
||||
if (imageProvider != null) ...[
|
||||
pw.Center(
|
||||
child: pw.Container(
|
||||
height: 220,
|
||||
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
|
||||
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(
|
||||
@@ -123,8 +194,10 @@ class PdfService {
|
||||
// Preload all images in parallel
|
||||
final Map<String, pw.ImageProvider?> imageMap = {};
|
||||
await Future.wait(items.map((item) async {
|
||||
if (item.imageUrl.isNotEmpty) {
|
||||
imageMap[item.id] = await _fetchImage(item.imageUrl);
|
||||
final url =
|
||||
item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl;
|
||||
if (url.isNotEmpty) {
|
||||
imageMap[item.id] = await _fetchImage(url);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -137,7 +210,8 @@ class PdfService {
|
||||
margin: const pw.EdgeInsets.only(bottom: 12),
|
||||
padding: const pw.EdgeInsets.only(bottom: 6),
|
||||
decoration: const pw.BoxDecoration(
|
||||
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
|
||||
border: pw.Border(
|
||||
bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
|
||||
),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
@@ -152,7 +226,8 @@ class PdfService {
|
||||
),
|
||||
pw.Text(
|
||||
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
|
||||
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
|
||||
style:
|
||||
const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -160,7 +235,8 @@ class PdfService {
|
||||
build: (context) => [
|
||||
pw.TableHelper.fromTextArray(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||
headerStyle:
|
||||
pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
cellHeight: 65,
|
||||
cellAlignments: {
|
||||
@@ -170,20 +246,32 @@ class PdfService {
|
||||
3: pw.Alignment.centerLeft,
|
||||
4: pw.Alignment.center,
|
||||
},
|
||||
headers: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
|
||||
headers: <String>[
|
||||
'Datum',
|
||||
'Örtlichkeit / Kat.',
|
||||
'Titel & Mangel',
|
||||
'Status',
|
||||
'Foto'
|
||||
],
|
||||
data: items.map((item) {
|
||||
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
|
||||
final formattedDate =
|
||||
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||
final locInfo =
|
||||
'${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
|
||||
final descSnippet = item.description.length > 120
|
||||
? '${item.description.substring(0, 120)}...'
|
||||
: item.description;
|
||||
final imageCount = item.imageUrls.length;
|
||||
final imageLabel = imageMap[item.id] != null
|
||||
? (imageCount > 1 ? '$imageCount Fotos' : 'Foto vorhanden')
|
||||
: '-';
|
||||
|
||||
return [
|
||||
formattedDate,
|
||||
locInfo,
|
||||
'${item.title}\n$descSnippet',
|
||||
item.status ?? 'Gemeldet',
|
||||
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
|
||||
imageLabel,
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
+309
-213
File diff suppressed because it is too large
Load Diff
+35
-82
@@ -1,108 +1,61 @@
|
||||
name: oggauergemeindetrommler
|
||||
description: Gemeinde Trommler
|
||||
|
||||
# Private app – never publish to pub.dev.
|
||||
publish_to: 'none'
|
||||
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
# Version + build number (build-name+build-number). Can be overridden with
|
||||
# `flutter build ... --build-name --build-number`.
|
||||
version: 0.0.1
|
||||
|
||||
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:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# Firebase (Auth, Firestore, Storage)
|
||||
firebase_core: ^4.15.0
|
||||
firebase_auth: ^6.7.0
|
||||
cloud_firestore: ^6.10.0
|
||||
firebase_storage: ^13.6.0
|
||||
|
||||
# UI / state
|
||||
provider: ^6.1.5
|
||||
cupertino_icons: ^1.0.8
|
||||
cached_network_image: ^3.4.1
|
||||
intl: ^0.20.2
|
||||
|
||||
# Media
|
||||
image_picker: ^1.2.3
|
||||
image: ^4.5.4
|
||||
|
||||
# PDF export & sharing
|
||||
pdf: ^3.11.3
|
||||
printing: ^5.14.3
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
image_picker: ^1.1.2
|
||||
firebase_core: ^3.4.0
|
||||
firebase_auth: ^5.2.0
|
||||
firebase_storage: ^12.2.0
|
||||
cloud_firestore: ^5.4.0
|
||||
change_app_package_name: ^1.1.0
|
||||
cached_network_image: ^3.2.3
|
||||
image: ^4.0.16
|
||||
pdf: ^3.10.1
|
||||
printing: ^5.10.4
|
||||
image_cropper: ^8.0.1
|
||||
logging: ^1.1.1
|
||||
provider: ^6.0.5
|
||||
http: ^1.2.0
|
||||
shared_preferences: ^2.2.2
|
||||
# Misc
|
||||
http: ^1.2.2
|
||||
shared_preferences: ^2.5.3
|
||||
url_launcher: ^6.3.1
|
||||
|
||||
# Transitive (via flutter_cache_manager / printing), pinned on purpose:
|
||||
# path_provider_android >= 2.3 is implemented with `jni` and compiles native
|
||||
# code (CMake + NDK). The ARM64 Gitea runner would have to run the x86_64 NDK
|
||||
# toolchain through QEMU, so stay on the pure-Java implementation for now.
|
||||
path_provider_android: '>=2.2.0 <2.3.0'
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+7
-2
@@ -12,8 +12,13 @@ service firebase.storage {
|
||||
&& request.resource.size < 10 * 1024 * 1024
|
||||
&& request.resource.contentType.matches('image/.*');
|
||||
|
||||
// Nur der Verfasser (oder berechtigte Nutzer) darf sein Foto löschen
|
||||
allow delete: if request.auth != null && request.auth.uid == userId;
|
||||
// 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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:oggauergemeindetrommler/models/post_item.dart';
|
||||
|
||||
@@ -13,6 +14,7 @@ void main() {
|
||||
createdDate: now,
|
||||
street: 'Hauptstraße',
|
||||
category: 'Straßenbeleuchtung',
|
||||
status: 'Gemeldet',
|
||||
);
|
||||
|
||||
expect(item.id, 'test-123');
|
||||
@@ -23,6 +25,46 @@ void main() {
|
||||
expect(item.status, 'Gemeldet');
|
||||
});
|
||||
|
||||
test('defaults status to Gemeldet only for issues and null for news/ideas', () {
|
||||
final issueJson = {
|
||||
'id': 'issue-1',
|
||||
'title': 'Schlagloch',
|
||||
'description': 'Tiefes Schlagloch',
|
||||
'createdDate': '2026-09-18T10:00:00.000',
|
||||
'street': 'Kirchengasse',
|
||||
'category': 'Straße',
|
||||
};
|
||||
final issueItem = PostItem.fromJson(issueJson);
|
||||
expect(issueItem.status, 'Gemeldet');
|
||||
|
||||
final newsJson = {
|
||||
'id': 'news-1',
|
||||
'title': 'Dorffest Ankündigung',
|
||||
'description': 'Am Samstag findet das Fest statt.',
|
||||
'createdDate': '2026-09-18T10:00:00.000',
|
||||
};
|
||||
final newsItem = PostItem.fromJson(newsJson);
|
||||
expect(newsItem.status, isNull);
|
||||
});
|
||||
|
||||
test('handles multiple images in PostItem', () {
|
||||
final item = PostItem(
|
||||
id: 'multi-img-1',
|
||||
title: 'Baustelle',
|
||||
description: 'Zwei Fotos vorhanden',
|
||||
createdDate: DateTime.now(),
|
||||
imageUrls: [
|
||||
'https://example.com/img1.jpg',
|
||||
'https://example.com/img2.jpg',
|
||||
],
|
||||
);
|
||||
|
||||
expect(item.imageUrls.length, 2);
|
||||
expect(item.imageUrls[0], 'https://example.com/img1.jpg');
|
||||
expect(item.imageUrls[1], 'https://example.com/img2.jpg');
|
||||
expect(item.imageUrl, 'https://example.com/img1.jpg');
|
||||
});
|
||||
|
||||
test('serializes to Firestore map accurately', () {
|
||||
final now = DateTime.now();
|
||||
final item = PostItem(
|
||||
@@ -41,5 +83,298 @@ void main() {
|
||||
expect(map['category'], 'Müll / Verunreinigung');
|
||||
expect(map['status'], 'In Bearbeitung');
|
||||
});
|
||||
|
||||
test('parses WordPress JSON accurately with HTML unescape and PDF extraction', () {
|
||||
final wpJson = {
|
||||
'id': 659,
|
||||
'date': '2026-09-10T10:42:00',
|
||||
'title': {'rendered': 'Aussendung 17 – 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