Compare commits

...
60 Commits
Author SHA1 Message Date
Nizz05 974223f7e7 fix: PDF zoom boundaries, Mängel status display in list view, and swipeable image gallery with PDF export
Build Android APK / Build Release APKs (push) Successful in 14m49s
2026-09-18 13:46:21 +02:00
Nizz05 f10d5ad5ed feat: pinch-to-zoom gestures for PDF reader & post editing for admins and authors
Build Android APK / Build Release APKs (push) Successful in 14m41s
2026-09-18 12:05:18 +02:00
Nizz05andClaude Opus 5 a228392dc6 ci(android): fix split build, produce universal APK in the same Gradle run
Build Android APK / Build Release APKs (push) Successful in 14m40s
The previous commit made `assembleRelease` depend on AGP's
`packageReleaseUniversalApk`. That task derives the universal APK from an
App Bundle, which pulls `buildReleasePreBundle` into the graph - and AGP
refuses to build a bundle while ABI splits are enabled
("Multiple shrunk-resources files found ... Please disable building
multiple APKs when building an Android app bundle", run 52).

Instead only flip `splits.abi.universalApk` back on when Flutter passes
`-Psplit-per-abi=true`: the Flutter Gradle plugin enables the splits and
copies every output - including the universal one, as app-release.apk -
to build/app/outputs/flutter-apk/. Plain builds (`flutter run`) keep the
plugin's abiFilters path, which AGP does not allow together with splits.

Workflow: one `flutter build apk --release --split-per-abi` call, copy the
four well-known output names (a missing one fails the step), job timeout
60 min (a green run takes ~17), drop the unneeded platform pre-install and
add a per-ref concurrency group - runs 50/51 ran in parallel on the single
ARM64 runner and both died at the same instant.

Verified locally with Flutter 3.47.4: split build yields all four APKs
(universal 62 MB, per-ABI 20-24 MB) in one Gradle run; plain release
build still produces app-release.apk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:30:13 +02:00
Nizz05 b7b6c4a2ab ci(android): optimize release build to single pass with ABI splits and universal APK, pre-install platform SDKs, and increase timeout
Build Android APK / Build Release APKs (push) Failing after 14m39s
2026-09-17 22:02:50 +02:00
Nizz05 c655ba713b ci(github): publish iOS IPA to GitHub Releases on build completion
Build Android APK / Build Release APKs (push) Failing after 1h2m17s
2026-09-17 09:40:18 +02:00
Nizz05 0b3cacf4b3 feat(ui): gesture zoom & double-tap for PDF viewer, fix navigation bar overlaps
Build Android APK / Build Release APKs (push) Failing after 1h4m35s
2026-09-17 09:37:40 +02:00
Nizz05andClaude Opus 5 1eb089f6b5 ci(gitea): strip native libs with a native llvm-strip so APKs are not 15x too big
Build Android APK / Build Release APKs (push) Successful in 16m29s
AGP's stripReleaseDebugSymbols failed silently on the ARM64 runner because
the NDK r28 x86_64 llvm-strip cannot load libz.so.1 under QEMU, leaving the
unstripped libflutter.so (165 MB per ABI) in the APKs (universal 521 MB).

Pre-install the NDK version Flutter expects via sdkmanager and replace its
llvm-strip/llvm-objcopy with Ubuntu's native arm64 LLVM tools, which strip
any ELF target without emulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:18:48 +02:00
Nizz05andClaude Opus 5 744def72fd ci(gitea): use QEMU 10 for x86_64 emulation and stop masking build failures
Build Android APK / Build Release APKs (push) Successful in 17m46s
Flutter 3.47's gen_snapshot calls pthread_getattr_np(), which crashes the
QEMU 8.2.2 shipped by Ubuntu 24.04 ("QEMU internal SIGSEGV {code=MAPERR,
addr=0x20}", qemu#2170, fixed in 8.2.3/9.0). Install the static qemu-x86_64
from Debian trixie's qemu-user package (sha1-pinned) over the apt binary.

The build steps swallowed non-zero exit codes with an `|| { find ... }`
fallback, so the job turned green without producing any APK. Run the
builds plainly, fail "Organize Artifacts" unless all four APKs exist and
set if-no-files-found: error on the uploads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:16:40 +02:00
Nizz05andClaude Opus 5 1c66c31f6d ci(gitea): run Flutter SDK switch step under bash (dash has no pipefail)
Build Android APK / Build Release APKs (push) Successful in 6m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:59:25 +02:00
Nizz05andClaude Opus 5 df34d66d38 build: upgrade to Flutter 3.47.4 (AGP 9.1 / Gradle 9.3, iOS 15 + UIScene), refresh deps and CI
Build Android APK / Build Release APKs (push) Failing after 1m7s
Flutter 3.24.5 -> 3.47.4 with the matching toolchain changes:

- pubspec: Dart ^3.11, Firebase core 4 / auth 6 / firestore 6 / storage 13,
  drop unused image_cropper, logging, change_app_package_name,
  pdf_widget_wrapper; pin path_provider_android <2.3 (needs NDK/CMake, not
  feasible on the ARM64 Gitea runner)
- Dart: CardTheme -> CardThemeData, replace deprecated withOpacity,
  Matrix4.scale, DropdownButtonFormField.value
- iOS: deployment target 15.0, UIScene lifecycle (AppDelegate/Info.plist),
  untrack stale Podfile.lock, add LaunchImage asset set
- Android: Gradle 9.3.1, AGP 9.1.0, Kotlin 2.4.0, google-services 4.5.0,
  layout.buildDirectory instead of removed Project.buildDir, remove manifest
  package attribute and duplicate MainActivity, kotlin.incremental=false
- CI: pin FLUTTER_VERSION in both workflows; iOS on macos-26 with SwiftPM
  disabled (CocoaPods only); Gitea job keeps cirruslabs 3.44.0 as base image
  (images discontinued) and switches the bundled Flutter SDK by git tag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:45:30 +02:00
Nizz05 85556dabb4 fix(deps): bump flutter to 3.24.5 across CI and update pdf_widget_wrapper to 1.0.4
Build Android APK / Build Release APKs (push) Successful in 5m1s
2026-09-16 00:13:20 +02:00
Nizz05 230430c91c ci: use Flutter 3.24.5 on macos-15 for Xcode 16 and Firebase 11 compatibility
Build Android APK / Build Release APKs (push) Successful in 13m5s
2026-09-15 23:55:24 +02:00
Nizz05 285ba029f4 fix(ios): align Flutter to 3.19.3 and pin Firebase pods to 10.29 to match project code
Build Android APK / Build Release APKs (push) Successful in 13m18s
2026-09-15 23:47:38 +02:00
Nizz05 2b32b3712f fix(ios): satisfy Flutter development team check and disable signing in Release.xcconfig
Build Android APK / Build Release APKs (push) Successful in 14m34s
2026-09-15 23:27:44 +02:00
Nizz05 7affff6e15 ci: switch to macos-15 runner with Xcode 16 and Swift 6 for Firebase SDK compatibility
Build Android APK / Build Release APKs (push) Successful in 16m51s
2026-09-15 23:09:05 +02:00
Nizz05 098c0105f5 fix(ci): fix bash redirection syntax error in iOS build step
Build Android APK / Build Release APKs (push) Successful in 16m57s
2026-09-15 23:00:49 +02:00
Nizz05 a21d370b91 ci: use Flutter stable, refresh pods, and log build diagnostics to step summary
Build Android APK / Build Release APKs (push) Successful in 16m55s
2026-09-15 22:56:09 +02:00
Nizz05 a2da5cdc89 fix(ios): bump iOS deployment target to 13.0 and disable hardcoded development team for CI builds
Build Android APK / Build Release APKs (push) Successful in 16m51s
2026-09-15 22:53:42 +02:00
Nizz05 362a7da74f ci: let Flutter handle CocoaPods, set iOS platform 13.0, and restrict Android build to Gitea
Build Android APK / Build Release APKs (push) Successful in 17m37s
2026-09-15 22:49:26 +02:00
Nizz05 2b34d4a16f ci: add GitHub Actions iOS IPA build workflow on macos-14
Build Android APK / Build Release APKs (push) Successful in 13m58s
2026-09-15 22:42:39 +02:00
Nizz05 c1cdd54964 fix(test): use const declarations in widget_test to satisfy linter 2026-09-15 22:34:50 +02:00
Nizz05 fc95c6e6f7 docs: update README with current features, in-app user management, and security architecture 2026-09-15 22:31:02 +02:00
Nizz05 75f1610cde feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management 2026-09-15 22:06:36 +02:00
Nizz05 9a823cbcdb feat: integrate WordPress PDF Aussendungen with in-app reader into News 2026-09-14 21:32:03 +02:00
Nizz05 e512c081e3 security(storage): strictly restrict delete to photo author or verified firestore admin 2026-09-14 20:59:40 +02:00
Nizz05 cd3b71736d fix(admin): make storage delete resilient and allow authenticated deletion in storage rules 2026-09-14 20:53:41 +02:00
Nizz05 3df2e4c5c8 feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool 2026-09-14 11:47:58 +02:00
Nizz05 877862aaf2 refactor(admin): remove hardcoded email and dynamically resolve admin privileges via Firestore Admins collection 2026-09-14 11:44:04 +02:00
Nizz05 c3513e85e5 feat(admin): grant full admin permissions in app and security rules to nicolas@gruber.info 2026-09-14 11:35:40 +02:00
Nizz05 e64b7a085c feat(security & admin): update firestore and storage rules with admin token check and enable admin deletion in app 2026-09-14 11:33:17 +02:00
Nizz05 2e9abac25f feat(auth & contact): separate Vorname/Nachname in registration, pre-fill read-only profile in contact, and restrict contact messages to registered users 2026-09-14 11:29:12 +02:00
Nizz05 010904a48a refactor(auth): simplify to 2 tabs and embed Buerger-Code under Google in registration 2026-09-14 11:12:35 +02:00
Nizz05 a4755f6d53 feat(auth): integrate Google Sign-In UI with custom button and friendly preparation notice 2026-09-14 10:33:07 +02:00
Nizz05 80ff6cd95d fix(auth): make auth dialog responsive and scrollable without overflow, add email verification on signup, and add password toggles 2026-09-14 10:26:44 +02:00
Nizz05 1420d0a113 feat(ci): restore universal APK, set 7-day retention for artifacts, and clean workspace to save storage 2026-09-14 10:07:15 +02:00
Nizz05 adc65e056d feat(ci): build only 3 split APKs, upload individual artifacts, and publish unzipped APKs to Gitea Releases 2026-09-14 09:55:04 +02:00
Nizz05 ab9b22fc4a fix(ci): use actions/upload-artifact@v3 for Gitea compatibility 2026-09-14 09:31:34 +02:00
Nizz05 b6baaf67e8 feat(build): migrate to declarative Gradle plugin DSL and bump Dart SDK constraint to >=3.0.0 <4.0.0 2026-09-14 09:28:36 +02:00
Nizz05 2917f93323 fix(build): route subprojects buildDir to ../build so Flutter CLI finds generated APKs 2026-09-14 09:17:01 +02:00
Nizz05 7f4218b0a2 fix(ci): strip LD_PRELOAD on child QEMU execution and remove rename watcher 2026-09-14 00:27:57 +02:00
Nizz05 8be95162d2 fix(ci): delegate execvp and posix_spawnp to real glibc symbols for PATH resolution 2026-09-14 00:17:36 +02:00
Nizz05 565a7d1347 fix(ci): restore string literal quotes in qemu_intercept.c 2026-09-14 00:16:33 +02:00
Nizz05 e44fcf1223 fix(ci): extract QEMU interceptor to dedicated C file to fix YAML indentation 2026-09-14 00:15:20 +02:00
Nizz05 f446fd044b fix(ci): inject LD_PRELOAD interceptor to redirect all x86_64 ELF executions to QEMU 2026-09-14 00:13:23 +02:00
Nizz05 9226ce4b33 fix(ci): direct AAPT2 userspace wrapping with QEMU to bypass host binfmt and FEX 2026-09-14 00:03:12 +02:00
Nizz05 1acbeddc79 fix(ci): mount binfmt_misc and cleanly register QEMU x86_64 for AAPT2 on ARM64 2026-09-13 23:51:32 +02:00
Nizz05 46dc3368c6 fix(ci): unregister host FEX from binfmt and wrap FEXInterpreter with QEMU for AAPT2 2026-09-13 23:34:50 +02:00
Nizz05 b443213224 ci: enable privileged container mode and binfmt support for aapt2 execution 2026-09-13 23:07:19 +02:00
Nizz05 ca59dff4db fix(ci): install x86_64 glibc cross libraries for qemu user emulation 2026-09-13 22:44:53 +02:00
Nizz05 f6f3a5ff06 fix(ci): correct YAML indentation in workflow file 2026-09-13 22:39:19 +02:00
Nizz05 ac395dad07 fix(ci): bridge Flutter linux-arm64 gen_snapshot using qemu wrapper 2026-09-13 22:30:22 +02:00
Nizz05 3c8ab00991 fix(android): remove hardcoded Windows java.home from repo gradle.properties 2026-09-13 22:20:56 +02:00
Nizz05 5969d23a98 fix(ci): track gradlew and wrapper jar, ensure executable permissions 2026-09-13 22:11:52 +02:00
Nizz05 6b9703d79c fix(ci): install official Node.js 20 binary for ES2022 syntax support 2026-09-13 22:06:48 +02:00
Nizz05 91c2e9659b fix(ci): install nodejs in build container before checkout action 2026-09-13 22:03:22 +02:00
Nizz05 8b4916246b feat(ci): add docker-compose setup for Gitea act_runner and local build script 2026-09-13 21:53:16 +02:00
Nizz05 0aa5fda335 ci: add Gitea Actions workflow to build and upload APK artifacts 2026-09-13 21:44:01 +02:00
Nizz05 3b5531a9d6 feat(auth): add AuthDialog with registration, login, code-unlock and wire into UI 2026-09-13 21:26:57 +02:00
Nizz05 9a8ee35fc2 Add production Firestore and Storage security rules 2026-09-13 21:10:39 +02:00
Nizz05 50a6025937 Fix UI camera notch clipping, embed Bürgerforum Oggau logo, and align branding with Bürgerliste Oggau 2026-09-13 16:06:54 +02:00
60 changed files with 7112 additions and 1999 deletions
+235
View File
@@ -0,0 +1,235 @@
name: Build Android APK
on:
push:
branches:
- main
- master
- 'refactor/**'
tags:
- 'v*'
pull_request:
branches:
- main
- master
workflow_dispatch:
# Only one build per ref at a time: two QEMU-emulated Gradle builds in parallel
# take the ARM64 runner down (both runs then die at the same instant).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Keep in sync with .github/workflows/build-ios.yaml and pubspec.yaml (environment).
FLUTTER_VERSION: '3.47.4'
# Static qemu-x86_64 for the ARM64 runner (Debian trixie `qemu-user`, see below).
QEMU_DEB_VERSION: '10.0.13+ds-0+deb13u1'
QEMU_DEB_SHA1: '02b553a4532192bc999d15408d1e56d7b254906f'
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
container:
# Cirrus Labs stopped publishing Flutter images in May 2026 (last tag: 3.44.0,
# multi-arch amd64/arm64, Ubuntu 24.04, OpenJDK 21, Android SDK 36). The image is
# only used for its JDK + Android SDK; the bundled Flutter SDK is switched to
# FLUTTER_VERSION in the first step.
image: ghcr.io/cirruslabs/flutter:3.44.0
options: --privileged
steps:
- name: Switch Flutter SDK to pinned version
# act_runner defaults to `sh -e` (dash); bash is needed for `pipefail`.
shell: bash
run: |
set -euo pipefail
FLUTTER_ROOT="${FLUTTER_ROOT:-/sdks/flutter}"
git config --global --add safe.directory "$FLUTTER_ROOT"
git config --global --add safe.directory '*'
CURRENT="$(git -C "$FLUTTER_ROOT" describe --tags --exact-match 2>/dev/null || true)"
if [ "$CURRENT" != "$FLUTTER_VERSION" ]; then
echo "Switching Flutter SDK from '${CURRENT:-unknown}' to '$FLUTTER_VERSION'"
git -C "$FLUTTER_ROOT" fetch --depth 1 origin "refs/tags/${FLUTTER_VERSION}:refs/tags/${FLUTTER_VERSION}"
git -C "$FLUTTER_ROOT" checkout -q "$FLUTTER_VERSION"
# Drop the cache of the previous version; the Dart SDK and the engine
# artifacts for the new version are downloaded on first use.
rm -rf "$FLUTTER_ROOT/bin/cache"
fi
flutter --version
- name: Setup Node.js 20 & ARM64 Flutter Engine
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; else NODE_ARCH="x64"; fi
curl -fsSL "https://nodejs.org/dist/v20.18.0/node-v20.18.0-linux-${NODE_ARCH}.tar.gz" | tar -xzf - -C /usr/local --strip-components=1
node --version
# On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..."
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc llvm
# Setup x86_64 dynamic libraries & prefix for QEMU emulation
mkdir -p /etc/qemu-binfmt
ln -sf /usr/x86_64-linux-gnu /etc/qemu-binfmt/x86_64
mkdir -p /lib64 /usr/lib64
ln -sf /usr/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
ln -sf /usr/x86_64-linux-gnu/lib/* /lib64/ 2>/dev/null || true
ln -sf /usr/x86_64-linux-gnu/lib/* /usr/lib64/ 2>/dev/null || true
export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu
echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV"
# Ubuntu 24.04 ships QEMU 8.2.2, whose x86_64 user-mode emulation dies with
# "QEMU internal SIGSEGV {code=MAPERR, addr=0x20}" as soon as the guest calls
# pthread_getattr_np() - which Dart's gen_snapshot does since Flutter 3.4x
# (https://gitlab.com/qemu-project/qemu/-/issues/2170, fixed in 8.2.3/9.0).
# Replace it with the static binary from Debian trixie's qemu-user package.
QEMU_DEB="qemu-user_${QEMU_DEB_VERSION}_arm64.deb"
curl -fsSL -o /tmp/qemu-user.deb "https://deb.debian.org/debian/pool/main/q/qemu/${QEMU_DEB}" ||
curl -fsSL -o /tmp/qemu-user.deb "https://snapshot.debian.org/file/${QEMU_DEB_SHA1}"
echo "${QEMU_DEB_SHA1} /tmp/qemu-user.deb" | sha1sum -c -
dpkg-deb -x /tmp/qemu-user.deb /tmp/qemu-user
install -m 0755 /tmp/qemu-user/usr/bin/qemu-x86_64 /usr/bin/qemu-x86_64-static
rm -rf /tmp/qemu-user /tmp/qemu-user.deb
/usr/bin/qemu-x86_64-static --version | head -n 1
# Must match the path hard-coded in scripts/ci/qemu_intercept.c.
QEMU_BIN=/usr/bin/qemu-x86_64-static
# Dummy fallbacks for FEX binaries/sockets
for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
printf '#!/bin/sh\nexit 0\n' > "$fex"
chmod +x "$fex" 2>/dev/null || true
done
flutter precache --android
# Flutter (still) only ships an x86_64 gen_snapshot for Linux hosts; wrap it
# with QEMU under the linux-arm64 path the tool expects.
for dir in "${FLUTTER_ROOT:-/sdks/flutter}"/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ] && [ ! -x "$dir/linux-arm64/gen_snapshot" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec env -u LD_PRELOAD %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
chmod +x "$dir/linux-arm64/gen_snapshot"
echo "Configured gen_snapshot wrapper for $(basename "$dir")"
fi
done
# AGP strips libflutter.so/libapp.so with the NDK's llvm-strip; unstripped they
# are ~15x larger (165 MB instead of 12 MB per ABI). The NDK's x86_64 llvm-strip
# cannot run under QEMU (needs libz.so.1 etc. in the cross sysroot), so install
# the NDK Flutter expects up front and swap in Ubuntu's native arm64 llvm-strip.
NDK_VERSION="$(sed -nE 's/.*ndkVersion: String = "([^"]+)".*/\1/p' "${FLUTTER_ROOT:-/sdks/flutter}/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt")"
[ -n "$NDK_VERSION" ] || { echo "::error::Could not determine Flutter's NDK version"; exit 1; }
echo "Installing NDK $NDK_VERSION ..."
yes | sdkmanager --install "ndk;${NDK_VERSION}" > /dev/null
NDK_BIN="${ANDROID_HOME:-/opt/android-sdk-linux}/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin"
for tool in llvm-strip llvm-objcopy; do
mv "$NDK_BIN/$tool" "$NDK_BIN/$tool.x86_64"
ln -s "$(command -v "$tool")" "$NDK_BIN/$tool"
done
"$NDK_BIN/llvm-strip" --version | head -n 2
fi
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup QEMU Interceptor
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Compiling transparent QEMU execution interceptor..."
gcc -O2 -shared -fPIC -o /usr/local/lib/libqemu_intercept.so scripts/ci/qemu_intercept.c -ldl
echo "LD_PRELOAD=/usr/local/lib/libqemu_intercept.so" >> "$GITHUB_ENV"
rm -f /etc/ld.so.preload
echo "Enabled LD_PRELOAD interceptor via GITHUB_ENV"
fi
- name: Make Gradlew Executable
run: |
if [ -f android/gradlew ]; then
chmod +x android/gradlew
fi
- name: Inspect Build Environment
run: |
flutter --version
java -version
node --version
- name: Fetch Flutter Dependencies
run: flutter pub get
- name: Run Code Analysis & Tests
run: |
flutter analyze
flutter test
- name: Build Release APKs (Split-per-ABI + Universal)
# android/app/build.gradle re-enables the universal APK for split builds, so
# this one Gradle run yields all four APKs in build/app/outputs/flutter-apk/.
run: flutter build apk --release --split-per-abi
- name: Organize Artifacts
run: |
mkdir -p release-apks
APK_DIR=build/app/outputs/flutter-apk
echo "=== Flutter APK outputs ==="
ls -lah "$APK_DIR"
cp "$APK_DIR/app-release.apk" release-apks/oggauer-gemeindetrommler-universal.apk
cp "$APK_DIR/app-arm64-v8a-release.apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
cp "$APK_DIR/app-armeabi-v7a-release.apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
cp "$APK_DIR/app-x86_64-release.apk" release-apks/oggauer-gemeindetrommler-x86_64.apk
echo "=== Generated APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7
if-no-files-found: error
- name: Upload ARM64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-arm64-v8a
path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
retention-days: 7
if-no-files-found: error
- name: Upload ARMv7 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-armeabi-v7a
path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
retention-days: 7
if-no-files-found: error
- name: Upload x86_64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-x86_64
path: release-apks/oggauer-gemeindetrommler-x86_64.apk
retention-days: 7
if-no-files-found: error
- name: Publish Direct APKs to Gitea Releases
continue-on-error: true
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_API: ${{ github.api_url }}
REPO: ${{ github.repository }}
RELEASE_TAG: latest
run: |
node scripts/ci/publish_release.js
- name: Clean Workspace Disk Space
if: always()
run: |
rm -rf build android/.gradle release-apks 2>/dev/null || true
+102
View File
@@ -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
+6
View File
@@ -42,3 +42,9 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/app/build/
.gradle/
release-apks/
gitea-runner/data/
gitea-runner/.env
backups/
+159 -79
View File
@@ -1,122 +1,202 @@
# 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/
├── AddPage.dart # Erfassungsdialog für Beiträge, Mängel & Ideen (Upload, Crop)
├── DetailPage.dart # Detailansicht für einzelne Beiträge inkl. PDF-Export
├── LoginPage.dart # (Fragment) E-Mail/Passwort-Loginmaske (nicht verdrahtet)
├── register.dart # (Fragment) Registrierungsmaske (nicht verdrahtet)
├── UserDefinedList.dart # Universelle Listenanzeige, Firestore-Stream, Selektion & Sammel-PDF
├── anonym_aut.dart # AuthState Provider & einfacher anonymer Login-Screen
├── firebase_options.dart # Firebase-Projektkonfiguration (Android & iOS)
├── home.dart # Haupt-Scaffold mit BottomNavigationBar (4 Tabs)
└── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
├── firebase_options.dart # Firebase-Konfiguration für Android & iOS
├── main.dart # Einstiegspunkt, Theme & MultiProvider Setup
├── models/
│ └── post_item.dart # Datenmodell für News, Aussendungen, Mängel & Ideen
├── screens/
│ ├── add_issue_screen.dart # Mängelerfassung (Straßen-Dropdown & bis zu 2 Fotos)
│ ├── add_post_screen.dart # Erfassung von News (Admins) & Bürgerideen
│ ├── admin_messages_screen.dart # Admin-Posteingang für eingegangene Kontaktanfragen
│ ├── auth_dialog.dart # Anmelde-, Registrierungs- & Freischaltungs-Dialog
│ ├── contact_screen.dart # Bürger-Kontaktformular mit Admin-Posteingang-Banner
│ ├── detail_screen.dart # Beitrags-Detailansicht mit PDF-Direktaufruf
│ ├── home_screen.dart # Haupt-Navigation mit 5 Tabs
│ ├── info_screen.dart # Bürgerforum Leitbild, Notrufnummern & Kontostatus
│ ├── pdf_viewer_screen.dart # In-App PDF-Viewer mit Pinch-to-Zoom & Schwebepanel
│ ├── post_list_screen.dart # News-, Mängel- & Ideen-Listen mit Volltextsuche & Filter
│ └── user_management_screen.dart# In-App Benutzerverwaltung & Admin-Rollenvergabe
└── services/
├── aussendungen_service.dart # WordPress REST-API Client mit SharedPreferences-Cache
├── auth_service.dart # Authentifizierung & automatischer Firestore-Profilabgleich
├── firestore_service.dart # Firestore CRUD, Admin-Verwaltung & Kontakt-Stream
├── pdf_service.dart # PDF-Tabellen und Einzeldokument-Erstellung
└── storage_service.dart # Komprimierter Cloud-Storage Bild-Upload
```
---
## 🛠️ Einrichtung & Lokaler Start
## 🤖 CI/CD Pipeline
### Voraussetzungen
1. **Flutter SDK** (empfohlen: `>= 3.19.x`)
2. **Dart SDK** (enthalten in Flutter)
3. **Android Studio** / **Xcode** (für Emulatoren oder physische Geräte)
4. Konfiguriertes Firebase-Projekt (`gemeinde-trommler`)
Die 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.
### Installation & Ausführung
### Android (Gitea Actions, `.gitea/workflows/build-apk.yaml`)
- **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 & Lokale Ausführung
### 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
### Befehle:
```bash
# 1. Abhängigkeiten installieren
# 1. Abhängigkeiten laden
flutter pub get
# 2. Statische Code-Analyse ausführen
flutter analyze
# 3. App starten
# 3. Unit-Tests ausführen
flutter test
# 4. App im Entwicklungsmodus starten
flutter run
```
---
## 🚀 Empfohlene Verbesserungen & Roadmap
Die Erstellung durch frühe KI-Modelle hat zu funktionalen Proof-of-Concepts geführt, weist jedoch typischen Refactoring-Bedarf auf:
1. **Code-Hygiene & Best Practices:**
- Umbenennung der Dateien nach Dart-Konvention (`snake_case`, z. B. `add_page.dart`, `detail_page.dart`).
- Behebung der `flutter analyze` Warnungen (Deprecations wie `enablePersistence`, `BuildContext` across async gaps, ungenutzte Imports).
- Saubere Abhängigkeitsdeklaration (`http` direkt in `pubspec.yaml` aufnehmen).
2. **Architektur & Wartbarkeit:**
- Trennung von UI und Business-Logik (Einführung von Repositories / Services für Firestore und Firebase Storage).
- Auslagerung der CPU-intensiven Bildverarbeitung (`image.decodeImage`) in Isolate (`compute`), um UI-Ruckler zu vermeiden.
3. **Vervollständigung der Fachanforderungen:**
- **Kontakt-Formular:** Umsetzung mit Pflichtfeldern und Mail-Trigger (z. B. via Firebase Extension `Trigger Email` oder Cloud Function).
- **Informationen / Impressum / Veranstaltungen:** Fünfter Tab gemäß Anforderung.
- **Mängelmeldung:** Dropdown für Straßenverzeichnis Oggau und Grundlage; Unterstützung von bis zu 2 Fotos im Querformat.
- **Volltextsuche:** Suchleiste / Suchsymbol in den Listenansichten.
- **Zugangskonzept:** Echtes Bürger-Passwort bzw. PIN-Schutz statt reinem anonymen Login.
---
## 📄 Lizenz & Urheberschaft
Erstellt für die Marktgemeinde Oggau am Neusiedler See. Private Verwendung.
Entwickelt für das **Bürgerforum Oggau**. Private & gemeinnützige Verwendung.
+6
View File
@@ -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`
-3
View File
@@ -1,8 +1,5 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
+38 -50
View File
@@ -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"
}
+20 -2
View File
@@ -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>
@@ -1,6 +0,0 @@
package com.oggauergemeindetrommler.oggauer_gemeindetrommler
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
+7 -30
View File
@@ -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'
subprojects {
// Note: Do not override project.buildDir across different drives on Windows (H: vs C:)
// 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)
// Inject namespace into plugins that don't have it (required for AGP 8+)
afterEvaluate { project ->
if (project.hasProperty('android')) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
subprojects {
project.layout.buildDirectory.value(newBuildDir.dir(project.name))
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
delete rootProject.layout.buildDirectory
}
+10 -3
View File
@@ -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
Binary file not shown.
+1 -1
View File
@@ -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
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+30 -8
View File
@@ -1,11 +1,33 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
def localPropertiesFile = file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { properties.load(it) }
}
def flutterSdkPath = properties.getProperty("flutter.sdk") ?: System.getenv('FLUTTER_ROOT') ?: System.getenv('FLUTTER_HOME')
assert flutterSdkPath != null, "flutter.sdk not set in local.properties or FLUTTER_ROOT environment variable"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
// 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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+9
View File
@@ -0,0 +1,9 @@
@echo off
echo ========================================================
echo Oggauer Gemeindetrommler - Firestore Backup Tool
echo ========================================================
echo.
node scripts\backup_firestore.js
echo.
echo Backup abgeschlossen. Taste druecken zum Schliessen...
pause >nul
+59
View File
@@ -0,0 +1,59 @@
@echo off
setlocal enabledelayedexpansion
echo ========================================================
echo Oggauer Gemeindetrommler - Android APK Builder
echo ========================================================
echo.
set FLUTTER=C:\src\flutter\bin\flutter.bat
if not exist "%FLUTTER%" (
set FLUTTER=flutter
)
echo [1/4] Dependencies laden...
call %FLUTTER% pub get
if %ERRORLEVEL% NEQ 0 (
echo [FEHLER] 'flutter pub get' fehlgeschlagen.
pause
exit /b %ERRORLEVEL%
)
echo.
echo [2/4] Universelle Release APK bauen...
call %FLUTTER% build apk --release
if %ERRORLEVEL% NEQ 0 (
echo [FEHLER] 'flutter build apk --release' fehlgeschlagen.
pause
exit /b %ERRORLEVEL%
)
echo.
echo [3/4] Split-per-ABI APKs bauen (fuer schlankere Downloads)...
call %FLUTTER% build apk --release --split-per-abi
echo.
echo [4/4] APKs organisieren...
if not exist "release-apks" mkdir "release-apks"
if exist "build\app\outputs\flutter-apk\app-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-release.apk" "release-apks\oggauer-gemeindetrommler-universal.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk" "release-apks\oggauer-gemeindetrommler-arm64.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk" "release-apks\oggauer-gemeindetrommler-armv7.apk" >nul
)
if exist "build\app\outputs\flutter-apk\app-x86_64-release.apk" (
copy /Y "build\app\outputs\flutter-apk\app-x86_64-release.apk" "release-apks\oggauer-gemeindetrommler-x86_64.apk" >nul
)
echo.
echo ========================================================
echo ERFOLG! Alle APKs befinden sich im Ordner:
echo %CD%\release-apks\
echo ========================================================
echo.
explorer release-apks
pause
+61
View File
@@ -0,0 +1,61 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Hilfsfunktionen
function isAuthenticated() {
return request.auth != null;
}
function isAdmin() {
return request.auth != null && (
request.auth.token.admin == true ||
exists(/databases/$(database)/documents/Admins/$(request.auth.uid)) ||
(request.auth.token.email != null && exists(/databases/$(database)/documents/Admins/$(request.auth.token.email)))
);
}
function isAuthorOrAdmin() {
return isAuthenticated() && (
(resource.data.author_uid != null && resource.data.author_uid == request.auth.uid) ||
(resource.data.authorUid != null && resource.data.authorUid == request.auth.uid) ||
isAdmin()
);
}
// Benutzerprofile: Nutzer verwaltet eigenes Profil, Admins haben Vollzugriff
match /Users/{userId} {
allow read: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
allow create, update: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
allow delete: if isAdmin();
}
// Admins-Sammlung: Angemeldete Nutzer können prüfen, nur Admins schreiben
match /Admins/{adminDoc} {
allow read: if isAuthenticated();
allow write: if isAdmin();
}
// Öffentliche Sammlungen: News, Mängel (Issues) & Ideen (CitizensForum)
match /{collection}/{document=**} {
// Jeder darf News, Mängel & Ideen öffentlich lesen
allow read: if collection in ['News', 'Issues', 'CitizensForum'];
// Erstellen: News nur Admin; Mängel und Ideen für angemeldete Nutzer
allow create: if collection == 'News'
? isAdmin()
: (isAuthenticated() && collection in ['Issues', 'CitizensForum']);
// Ändern/Löschen nur der eigene Autor oder Admin
allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum'];
}
// Kontakt-Nachrichten: Geschützt (nur registrierte Nutzer senden, Lesen nur Admin & Autor)
match /ContactMessages/{document} {
// Senden nur für registrierte Nutzer (nicht-anonym)
allow create: if isAuthenticated() && request.auth.token.firebase.sign_in_provider != 'anonymous';
// Lesen, Ändern und Löschen für Admin und Autor
allow read, update, delete: if isAuthorOrAdmin();
}
}
}
+9
View File
@@ -0,0 +1,9 @@
# Gitea URL
GITEA_INSTANCE_URL=https://git.djnizz.at
# Hier das Token einfügen aus Gitea:
# Website-Verwaltung -> Aktionen -> Runner -> "Neuen Runner erstellen"
GITEA_RUNNER_REGISTRATION_TOKEN=hier_dein_token_einfuegen
# Beliebiger Name für den Runner
GITEA_RUNNER_NAME=oracle-arm-runner
+25
View File
@@ -0,0 +1,25 @@
# Gitea Act Runner (Oracle Cloud ARM64 / Docker)
Docker Compose Setup für den Gitea CI/CD Runner zum automatischen Bauen der Android-APKs.
## Schnellstart
1. **Token holen:**
In Gitea unter **Website-Verwaltung** (oder Repo-Einstellungen) ➔ **Aktionen** ➔ **Runner** ➔ **Neuen Runner erstellen** das Registrierungs-Token kopieren.
2. **Konfiguration erstellen:**
```bash
cp .env.example .env
nano .env
```
Dort das kopierte Token bei `GITEA_RUNNER_REGISTRATION_TOKEN` eintragen.
3. **Runner starten:**
```bash
docker compose up -d
```
4. **Logs ansehen:**
```bash
docker compose logs -f
```
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: always
environment:
# URL deiner Gitea-Instanz
- GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.djnizz.at}
# Registrierungs-Token aus Gitea (Website-Verwaltung -> Aktionen -> Runner -> "Neuen Runner erstellen")
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
# Name des Runners in Gitea
- GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-oracle-arm-runner}
# TLS-Verifizierung überspringen (falls SSL abgelaufen/selbstsigniert ist)
- GITEA_RUNNER_INSECURE=true
# Maximale parallele Jobs
- GITEA_RUNNER_CAPACITY=2
volumes:
# Docker-Socket des Host-Systems (nötig für Container-Builds wie Flutter)
- /var/run/docker.sock:/var/run/docker.sock
# Persistenter Speicher: Registrierung bleibt nach Neustart erhalten
- ./data:/data
+5
View File
@@ -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
-2
View File
@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
</dict>
</plist>
+3
View File
@@ -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=
+10 -2
View File
@@ -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'
@@ -37,5 +37,13 @@ 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
-842
View File
@@ -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
+10 -10
View File
@@ -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",
+7 -4
View File
@@ -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.
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.replaceAll('&#8211;', '–')
.replaceAll('&#8212;', '—')
.replaceAll('&#8216;', '‘')
.replaceAll('&#8217;', '’')
.replaceAll('&#8220;', '“')
.replaceAll('&#8221;', '”')
.replaceAll('&#8230;', '…');
// Decode numerical entities &#DDD;
result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) {
final code = int.tryParse(match.group(1)!);
return code != null ? String.fromCharCode(code) : match.group(0)!;
});
// Decode hex numerical entities &#xHH;
result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) {
final code = int.tryParse(match.group(1)!, radix: 16);
return code != null ? String.fromCharCode(code) : match.group(0)!;
});
return result.trim();
}
/// Extracts the direct PDF URL from WordPress post HTML content
static String? extractPdfUrl(String htmlContent) {
final pdfRegExp = RegExp(
r'https?://[^\s"<>]+?\.pdf',
caseSensitive: false,
);
final match = pdfRegExp.firstMatch(htmlContent);
if (match != null) {
return match.group(0);
}
// Fallback: check docx or doc files
final docRegExp = RegExp(
r'https?://[^\s"<>]+?\.(?:docx|doc)',
caseSensitive: false,
);
final docMatch = docRegExp.firstMatch(htmlContent);
return docMatch?.group(0);
}
/// Strip HTML tags and clean whitespace for preview text
static String extractCleanText(String htmlContent) {
var clean = htmlContent.replaceAll(RegExp(r'<style[^>]*>.*?</style>', dotAll: true), '');
clean = clean.replaceAll(RegExp(r'<script[^>]*>.*?</script>', dotAll: true), '');
clean = clean.replaceAll(RegExp(r'<[^>]*>'), ' ');
clean = unescapeHtml(clean);
clean = clean.replaceAll(RegExp(r'\s+'), ' ').trim();
return clean;
}
/// Parse a post from the WordPress REST API (/wp-json/wp/v2/posts)
factory PostItem.fromWordPressJson(Map<String, dynamic> json) {
final rawTitle = (json['title'] is Map)
? (json['title']['rendered'] as String? ?? '')
: (json['title'] as String? ?? '');
final title = unescapeHtml(rawTitle);
final rawContent = (json['content'] is Map)
? (json['content']['rendered'] as String? ?? '')
: (json['content'] as String? ?? '');
final pdfUrl = extractPdfUrl(rawContent);
final webUrl = json['link'] as String?;
DateTime date;
if (json['date'] is String) {
date = DateTime.tryParse(json['date'] as String) ?? DateTime.now();
} else {
date = DateTime.now();
}
String desc = extractCleanText(rawContent);
if (desc.isEmpty || desc == title) {
desc = 'Offizielle Aussendung des Bürgerforums Oggau (PDF).';
}
return PostItem(
id: 'wp_${json['id']}',
title: title.isNotEmpty ? title : 'Aussendung',
description: desc,
createdDate: date,
category: 'Aussendung',
status: 'Aussendung',
pdfUrl: pdfUrl,
webUrl: webUrl,
isAussendung: true,
);
}
/// 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,
);
}
}
+192 -13
View File
@@ -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,9 +71,32 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
@override
void initState() {
super.initState();
if (widget.issueToEdit != null) {
final issue = widget.issueToEdit!;
_titleController.text = issue.title;
_descriptionController.text = issue.description;
if (issue.street != null && _streets.contains(issue.street)) {
_selectedStreet = issue.street;
} else {
_selectedStreet = _streets.first;
}
if (issue.category != null && _categories.contains(issue.category)) {
_selectedCategory = issue.category;
} else {
_selectedCategory = _categories.first;
}
_existingImageUrls = List<String>.from(issue.imageUrls);
if (_existingImageUrls.isEmpty && issue.imageUrl.isNotEmpty) {
_existingImageUrls.add(issue.imageUrl);
}
if (issue.status != null && _statuses.contains(issue.status)) {
_selectedStatus = issue.status!;
}
} else {
_selectedStreet = _streets.first;
_selectedCategory = _categories.first;
}
}
@override
void dispose() {
@@ -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,6 +175,51 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
});
try {
if (isEditing) {
// Delete removed old images from Storage
for (final oldUrl in widget.issueToEdit!.imageUrls) {
if (!_existingImageUrls.contains(oldUrl)) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
// Upload newly selected images to Storage
final List<String> newlyUploadedUrls = [];
for (final file in _selectedImages) {
final url = await _storageService.uploadFile(
file: file,
collectionName: 'Issues',
optimize: true,
);
newlyUploadedUrls.add(url);
}
final allImages = [..._existingImageUrls, ...newlyUploadedUrls];
final updatedIssue = widget.issueToEdit!.copyWith(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: allImages.isNotEmpty ? allImages.first : '',
imageUrls: allImages,
street: _selectedStreet,
category: _selectedCategory,
status: _selectedStatus,
);
await _firestoreService.updatePost('Issues', updatedIssue);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mangel erfolgreich aktualisiert!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, updatedIssue);
} else {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
// Upload selected images to Firebase Storage
@@ -171,7 +256,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
Navigator.pop(context, newIssue);
}
} 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,8 +380,60 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
Row(
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;
@@ -288,8 +455,14 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
Positioned(
top: -4,
right: 4,
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.cancel, color: Colors.red),
padding: EdgeInsets.zero,
icon: const Icon(Icons.close,
color: Colors.white, size: 16),
tooltip: 'Foto entfernen',
onPressed: () {
setState(() {
_selectedImages.removeAt(index);
@@ -297,10 +470,11 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
},
),
),
),
],
);
}),
if (_selectedImages.length < 2)
if (totalPhotos < 2)
InkWell(
onTap: _showImageSourceModal,
child: Container(
@@ -327,6 +501,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -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),
),
),
+143 -8
View File
@@ -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,6 +120,52 @@ class _AddPostScreenState extends State<AddPostScreen> {
});
try {
if (isEditing) {
String imageUrl = _existingImageUrl ?? '';
if (_selectedImage != null) {
// If a new image was chosen, remove the old one from storage if it existed
if (widget.postToEdit!.imageUrls.isNotEmpty) {
for (final oldUrl in widget.postToEdit!.imageUrls) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
imageUrl = await _storageService.uploadFile(
file: _selectedImage!,
collectionName: widget.collectionName,
optimize: true,
);
} else if (_existingImageUrl == null &&
widget.postToEdit!.imageUrls.isNotEmpty) {
// Existing image was deleted by the user
for (final oldUrl in widget.postToEdit!.imageUrls) {
try {
await _storageService.deleteFileByUrl(oldUrl);
} catch (_) {}
}
}
final updatedPost = widget.postToEdit!.copyWith(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: imageUrl,
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
);
await _firestoreService.updatePost(widget.collectionName, updatedPost);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eintrag erfolgreich aktualisiert!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, updatedPost);
} else {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
String imageUrl = '';
@@ -130,7 +197,8 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
Navigator.pop(context, newPost);
}
} 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),
),
),
+614
View File
@@ -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),
);
},
);
},
),
),
],
),
);
}
}
+711
View File
@@ -0,0 +1,711 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class AuthDialog extends StatefulWidget {
const AuthDialog({super.key});
static Future<void> show(BuildContext context) async {
await showDialog(
context: context,
builder: (ctx) => const AuthDialog(),
);
}
@override
State<AuthDialog> createState() => _AuthDialogState();
}
class _AuthDialogState extends State<AuthDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Login Controllers
final _loginEmailController = TextEditingController();
final _loginPasswordController = TextEditingController();
// Register Controllers
final _regFirstNameController = TextEditingController();
final _regLastNameController = TextEditingController();
final _regEmailController = TextEditingController();
final _regPasswordController = TextEditingController();
final _regConfirmPasswordController = TextEditingController();
// Code Controller
final _codeController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
bool _obscureLoginPassword = true;
bool _obscureRegPassword = true;
bool _obscureRegConfirmPassword = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_loginEmailController.dispose();
_loginPasswordController.dispose();
_regFirstNameController.dispose();
_regLastNameController.dispose();
_regEmailController.dispose();
_regPasswordController.dispose();
_regConfirmPasswordController.dispose();
_codeController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final email = _loginEmailController.text.trim();
final password = _loginPasswordController.text;
if (email.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Bitte E-Mail und Passwort eingeben.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signInWithEmail(email, password);
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erfolgreich angemeldet!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleRegister() async {
final firstName = _regFirstNameController.text.trim();
final lastName = _regLastNameController.text.trim();
final email = _regEmailController.text.trim();
final password = _regPasswordController.text;
final confirm = _regConfirmPasswordController.text;
if (firstName.isEmpty || lastName.isEmpty || email.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen (Vorname, Nachname, E-Mail, Passwort).');
return;
}
if (password != confirm) {
setState(() => _errorMessage = 'Die Passwörter stimmen nicht überein.');
return;
}
if (password.length < 6) {
setState(
() => _errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signUpWithEmail(
email: email,
password: password,
firstName: firstName,
lastName: lastName,
);
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Konto erfolgreich erstellt! Wir haben einen Bestätigungslink an $email gesendet. Bitte prüfe dein Postfach.',
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 6),
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleGoogleSignIn() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final error = await auth.signInWithGoogle();
if (!mounted) return;
setState(() => _isLoading = false);
if (error == null) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erfolgreich mit Google angemeldet!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage = error);
}
}
Future<void> _handleCodeUnlock() async {
final code = _codeController.text.trim();
if (code.isEmpty) {
setState(() => _errorMessage = 'Bitte den Zugangscode eingeben.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final auth = Provider.of<AuthService>(context, listen: false);
final success = await auth.verifyCommunityCode(code);
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bürgerzugang freigeschaltet!'),
backgroundColor: Colors.green,
),
);
} else {
setState(() => _errorMessage =
'Ungültiger Zugangscode. Bitte wende dich an das Bürgerforum.');
}
}
void _showPasswordResetDialog() {
final resetEmailController = TextEditingController(
text: _loginEmailController.text,
);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Passwort zurücksetzen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Gib deine E-Mail-Adresse ein. Wir senden dir einen Link, mit dem du ein neues Passwort festlegen kannst.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 12),
TextField(
controller: resetEmailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final email = resetEmailController.text.trim();
if (email.isEmpty) return;
final auth = Provider.of<AuthService>(context, listen: false);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(ctx);
final err = await auth.sendPasswordReset(email);
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(err ?? 'E-Mail zum Zurücksetzen wurde versendet!'),
backgroundColor: err == null ? Colors.green : Colors.red,
),
);
},
child: const Text('Link senden'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 440,
maxHeight: screenHeight * 0.82,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/images/logo.png',
height: 28,
errorBuilder: (_, _, _) => const Icon(
Icons.campaign,
color: Colors.blue,
),
),
),
const SizedBox(width: 10),
const Text(
'Bürgerforum Oggau',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
TabBar(
controller: _tabController,
labelColor: Colors.blue.shade800,
indicatorColor: Colors.blue.shade800,
tabs: const [
Tab(text: 'Anmelden'),
Tab(text: 'Registrieren'),
],
),
if (_errorMessage != null) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
const Icon(Icons.error_outline,
color: Colors.red, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
fontSize: 12, color: Colors.red.shade900),
),
),
],
),
),
],
const SizedBox(height: 12),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Anmelden
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _loginEmailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _loginPasswordController,
obscureText: _obscureLoginPassword,
decoration: InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureLoginPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureLoginPassword =
!_obscureLoginPassword;
});
},
),
border: const OutlineInputBorder(),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _showPasswordResetDialog,
child: const Text(
'Passwort vergessen?',
style: TextStyle(fontSize: 12),
),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Anmelden',
style: TextStyle(fontSize: 15)),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
fontWeight: FontWeight.bold,
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
_buildGoogleButton(label: 'Mit Google anmelden'),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () => _tabController.animateTo(1),
child: const Text(
'Noch kein Konto? Hier registrieren oder Bürger-Code eingeben',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
),
),
],
),
),
// Tab 2: Registrierung & Bürger-Code
SingleChildScrollView(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _regFirstNameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Vorname *',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _regLastNameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nachname *',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 10),
TextField(
controller: _regEmailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
helperText:
'Erhält einen Bestätigungslink zur Prüfung',
),
),
const SizedBox(height: 10),
TextField(
controller: _regPasswordController,
obscureText: _obscureRegPassword,
decoration: InputDecoration(
labelText: 'Passwort (mind. 6 Zeichen)',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureRegPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureRegPassword = !_obscureRegPassword;
});
},
),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextField(
controller: _regConfirmPasswordController,
obscureText: _obscureRegConfirmPassword,
decoration: InputDecoration(
labelText: 'Passwort bestätigen',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureRegConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureRegConfirmPassword =
!_obscureRegConfirmPassword;
});
},
),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text(
'Konto erstellen & E-Mail bestätigen',
style: TextStyle(fontSize: 14)),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
fontWeight: FontWeight.bold,
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
_buildGoogleButton(label: 'Mit Google registrieren'),
const SizedBox(height: 18),
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'ODER MIT BÜRGER-CODE',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
),
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade100),
),
child: const Text(
'Kein persönliches Konto gewünscht? Gib den Zugangscode des Bürgerforums ein (z.B. oggau), um sofort Mängel & Ideen melden zu können.',
style: TextStyle(
fontSize: 12, color: Colors.black87),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _codeController,
decoration: const InputDecoration(
labelText: 'Bürger-Zugangscode',
prefixIcon: Icon(Icons.key_outlined),
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
isDense: true,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _handleCodeUnlock,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 16),
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
),
child: const Text('Freischalten'),
),
],
),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () => _tabController.animateTo(0),
child: const Text(
'Bereits ein Konto? Hier anmelden',
style: TextStyle(fontSize: 12),
),
),
),
],
),
),
],
),
),
],
),
),
),
);
}
Widget _buildGoogleButton({required String label}) {
return OutlinedButton(
onPressed: _isLoading ? null : _handleGoogleSignIn,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildGoogleIcon(),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
);
}
Widget _buildGoogleIcon() {
return Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.shade200),
),
alignment: Alignment.center,
child: const Text(
'G',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF4285F4),
),
),
);
}
}
+269 -55
View File
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import 'admin_messages_screen.dart';
import 'auth_dialog.dart';
class ContactScreen extends StatefulWidget {
const ContactScreen({super.key});
@@ -29,11 +33,12 @@ class _ContactScreenState extends State<ContactScreen> {
super.dispose();
}
bool _isValidEmail(String email) {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
Future<void> _submitForm(AuthService auth) async {
if (!auth.isRegisteredUser) {
await AuthDialog.show(context);
return;
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate() || _isSubmitting) return;
setState(() {
@@ -41,19 +46,27 @@ class _ContactScreenState extends State<ContactScreen> {
});
try {
final firstName = auth.firstName.isNotEmpty
? auth.firstName
: _firstNameController.text.trim();
final lastName = auth.lastName.isNotEmpty
? auth.lastName
: _lastNameController.text.trim();
final email = (auth.email != null && auth.email!.isNotEmpty)
? auth.email!
: _emailController.text.trim();
await _firestoreService.submitContactMessage(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
firstName: firstName,
lastName: lastName,
email: email,
phone: _phoneController.text.trim(),
message: _messageController.text.trim(),
authorUid: auth.user?.uid,
);
if (!mounted) return;
_firstNameController.clear();
_lastNameController.clear();
_emailController.clear();
_phoneController.clear();
_messageController.clear();
@@ -82,28 +95,130 @@ class _ContactScreenState extends State<ContactScreen> {
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
final isRegistered = auth.isRegisteredUser;
// Sync controllers with authenticated user details
if (isRegistered) {
if (_firstNameController.text != auth.firstName) {
_firstNameController.text = auth.firstName;
}
if (_lastNameController.text != auth.lastName) {
_lastNameController.text = auth.lastName;
}
if (_emailController.text != (auth.email ?? '')) {
_emailController.text = auth.email ?? '';
}
} else {
if (_firstNameController.text.isNotEmpty) _firstNameController.clear();
if (_lastNameController.text.isNotEmpty) _lastNameController.clear();
if (_emailController.text.isNotEmpty) _emailController.clear();
}
return Scaffold(
appBar: AppBar(
title: const Text('Kontakt Bürgerforum'),
centerTitle: true,
actions: [
if (auth.isAdmin)
IconButton(
icon: const Icon(Icons.inbox),
tooltip: 'Posteingang: Bürgernachrichten',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (auth.isAdmin) ...[
Card(
color: Colors.blue.shade50,
color: Colors.indigo.shade50,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(14.0),
child: Row(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.indigo.shade200),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo.shade700,
child: const Icon(Icons.mark_email_unread, color: Colors.white),
),
title: const Text(
'Admin-Posteingang',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Alle eingegangenen Bürgernachrichten einsehen & verwalten'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminMessagesScreen(),
),
);
},
),
),
const SizedBox(height: 12),
],
if (!isRegistered) ...[
Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300),
),
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
SizedBox(width: 12),
Expanded(
child: Text(
'Hier können Sie auch ohne Anmeldung direkt mit der Bürgerliste / Gemeindevertretung in Kontakt treten.',
style: TextStyle(fontSize: 13),
Row(
children: [
Icon(Icons.lock_outline,
color: Colors.amber.shade900, size: 22),
const SizedBox(width: 8),
Text(
'Anmeldung erforderlich',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
],
),
const SizedBox(height: 6),
Text(
'Um eine Nachricht an das Bürgerforum zu senden, ist ein registriertes Benutzerkonto erforderlich. Deine Kontaktdaten werden dann automatisch hinterlegt.',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade800),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => AuthDialog.show(context),
icon: const Icon(Icons.login),
label: const Text('Jetzt anmelden oder registrieren'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 11),
),
),
),
],
@@ -111,33 +226,102 @@ class _ContactScreenState extends State<ContactScreen> {
),
),
const SizedBox(height: 16),
] else ...[
Card(
color: Colors.blue.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.blue.shade100),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Icon(Icons.verified_user_outlined,
color: Colors.blue.shade700, size: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Angemeldet als ${auth.displayName ?? auth.email}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'Deine Kontaktdaten sind automatisch hinterlegt.',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade700),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Vorname *',
border: OutlineInputBorder(),
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Vorname eingeben'
decoration: InputDecoration(
labelText: 'Vorname *',
hintText:
isRegistered ? null : 'Wird automatisch ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nachname *',
border: OutlineInputBorder(),
readOnly: true,
style: TextStyle(
color: isRegistered
? Colors.black87
: Colors.grey.shade600,
fontWeight: isRegistered
? FontWeight.w500
: FontWeight.normal,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Nachname eingeben'
decoration: InputDecoration(
labelText: 'Nachname *',
hintText:
isRegistered ? null : 'Wird automatisch ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
border: const OutlineInputBorder(),
),
),
),
],
@@ -145,41 +329,58 @@ class _ContactScreenState extends State<ContactScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
readOnly: true,
style: TextStyle(
color: isRegistered ? Colors.black87 : Colors.grey.shade600,
fontWeight:
isRegistered ? FontWeight.w500 : FontWeight.normal,
),
decoration: InputDecoration(
labelText: 'E-Mail-Adresse *',
border: OutlineInputBorder(),
hintText:
isRegistered ? null : 'Wird durch Anmeldung ausgefüllt',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.email_outlined),
suffixIcon: isRegistered
? const Icon(Icons.lock_outline,
size: 18, color: Colors.grey)
: null,
helperText: isRegistered
? 'Antworten des Bürgerforums gehen an diese E-Mail'
: null,
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Bitte E-Mail-Adresse eingeben';
}
if (!_isValidEmail(value.trim())) {
return 'Bitte eine gültige E-Mail eingeben';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Rückrufnummer (optional)',
border: OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone_outlined),
border: const OutlineInputBorder(),
helperText: isRegistered
? 'Optional für eventuelle telefonische Rückfragen'
: null,
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _messageController,
maxLines: 5,
decoration: const InputDecoration(
enabled: isRegistered,
decoration: InputDecoration(
labelText: 'Ihr Anliegen / Ihre Nachricht *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
filled: !isRegistered,
fillColor: !isRegistered ? Colors.grey.shade100 : null,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
validator: (value) => (value == null || value.trim().isEmpty)
? 'Bitte Ihre Nachricht eingeben'
: null,
),
@@ -197,16 +398,29 @@ class _ContactScreenState extends State<ContactScreen> {
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _submitForm,
onPressed: !isRegistered
? () => AuthDialog.show(context)
: (_isSubmitting ? null : () => _submitForm(auth)),
style: ElevatedButton.styleFrom(
backgroundColor: isRegistered
? Colors.blue.shade800
: Colors.grey.shade700,
foregroundColor: Colors.white,
),
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.send),
: Icon(!isRegistered ? Icons.login : Icons.send),
label: Text(
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
!isRegistered
? 'Anmelden zum Absenden'
: (_isSubmitting
? 'Wird gesendet...'
: 'Nachricht absenden'),
style: const TextStyle(fontSize: 16),
),
),
+304 -44
View File
@@ -1,41 +1,177 @@
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)
if (_item.imageUrls.isNotEmpty) ...[
if (_item.imageUrls.length == 1)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.imageUrls.first,
imageUrl: _item.imageUrls.first,
width: double.infinity,
height: 240,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
@@ -44,15 +180,117 @@ class DetailScreen extends StatelessWidget {
),
),
errorWidget: (context, url, error) => Container(
height: 180,
height: 200,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48),
),
),
)
else
Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 260,
width: double.infinity,
child: PageView.builder(
controller: _pageController,
itemCount: _item.imageUrls.length,
onPageChanged: (index) {
setState(() {
_currentImageIndex = index;
});
},
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: _item.imageUrls[index],
width: double.infinity,
height: 260,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 260,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported,
size: 48),
),
);
},
),
),
),
// Page indicator dots
Positioned(
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
_item.imageUrls.length,
(i) => Container(
margin:
const EdgeInsets.symmetric(horizontal: 3),
width: _currentImageIndex == i ? 16 : 6,
height: 6,
decoration: BoxDecoration(
color: _currentImageIndex == i
? Colors.white
: Colors.white54,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
),
// Photo counter badge (e.g. 1 / 2)
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library,
color: Colors.white, size: 14),
const SizedBox(width: 4),
Text(
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
],
Text(
item.title,
_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),
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),
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: 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,
),
);
},
),
),
],
const SizedBox(height: 24),
],
),
),
+3 -3
View File
@@ -16,11 +16,11 @@ class _HomeScreenState extends State<HomeScreen> {
final List<Widget> _pages = const [
PostListScreen(
collectionName: 'News',
title: 'Nachrichten & Aktuelles',
title: 'Bürgerforum Aktuelles',
),
PostListScreen(
collectionName: 'Issues',
title: 'Mängel & Bürgeranliegen',
title: 'Mängel & Anliegen',
),
PostListScreen(
collectionName: 'CitizensForum',
@@ -75,7 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
activeIcon: Icon(Icons.info),
label: 'Informationen',
label: 'Bürgerforum',
),
],
),
+219 -126
View File
@@ -1,115 +1,116 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import 'auth_dialog.dart';
import 'user_management_screen.dart';
class InfoScreen extends StatefulWidget {
class InfoScreen extends StatelessWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
final TextEditingController _codeController = TextEditingController();
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
void _showUnlockDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Gemeinde-Zugang freischalten'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Geben Sie das Passwort bzw. den Zugangscode der Marktgemeinde Oggau ein, um Mängel zu melden und im Bürgerforum mitzuwirken.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
TextField(
controller: _codeController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Gemeinde-Passwort',
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final auth = Provider.of<AuthService>(context, listen: false);
final success =
await auth.verifyCommunityCode(_codeController.text);
if (!dialogCtx.mounted) return;
Navigator.pop(dialogCtx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Zugang erfolgreich freigeschaltet!'
: 'Ungültiges Passwort. Bitte kontaktieren Sie das Gemeindeamt.'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
},
child: const Text('Freischalten'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Bürgerforum Oggau'),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
children: [
// Community Access Card
// Bürgerforum Logo & Branding Header
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/logo.png',
height: 72,
fit: BoxFit.contain,
),
),
const SizedBox(height: 8),
const Text(
'Bürgerforum Oggau',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Unabhängige Bürgerliste für Oggau am Neusiedler See',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 8),
// Bürgerforum Access & Account Card
Card(
color: auth.isCommunityUnlocked ? Colors.green.shade50 : Colors.blue.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: auth.isAdmin
? Colors.indigo.shade50
: auth.isRegisteredUser
? Colors.green.shade50
: auth.isCommunityUnlocked
? Colors.teal.shade50
: Colors.blue.shade50,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
padding: const EdgeInsets.all(14.0),
child: Column(
children: [
Row(
children: [
Icon(
auth.isCommunityUnlocked
auth.isAdmin
? Icons.admin_panel_settings
: auth.isRegisteredUser
? Icons.account_circle
: auth.isCommunityUnlocked
? Icons.verified_user
: Icons.lock_outline,
color: auth.isCommunityUnlocked ? Colors.green : Colors.blue,
color: auth.isAdmin
? Colors.indigo.shade700
: auth.isRegisteredUser
? Colors.green.shade700
: auth.isCommunityUnlocked
? Colors.teal.shade800
: Colors.blue.shade800,
size: 36,
),
const SizedBox(width: 16),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isCommunityUnlocked
? 'Bürgerzugang: Aktiv'
auth.isAdmin
? 'Administrator'
: auth.isRegisteredUser
? (auth.displayName?.isNotEmpty == true
? auth.displayName!
: 'Bürger-Konto')
: auth.isCommunityUnlocked
? 'Bürgerzugang: Aktiv (Code)'
: 'Gastmodus (Nur Lesen)',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 16),
fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 2),
Text(
auth.isCommunityUnlocked
? 'Sie können Mängel und Ideen einreichen.'
: 'Gemeinde-Passwort eingeben zum Freischalten.',
auth.isAdmin
? '${auth.email ?? "Angemeldet"} (Admin-Rechte aktiv)'
: auth.isRegisteredUser
? auth.email ?? 'Angemeldet'
: auth.isCommunityUnlocked
? 'Mängel & Ideen freigeschaltet'
: 'Anmelden oder Code eingeben zum Mitwirken.',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade700),
),
@@ -117,24 +118,127 @@ class _InfoScreenState extends State<InfoScreen> {
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
),
onPressed: () {
if (auth.isCommunityUnlocked) {
if (auth.isRegisteredUser || auth.isCommunityUnlocked) {
auth.lockCommunityAccess();
} else {
_showUnlockDialog(context);
AuthDialog.show(context);
}
},
child: Text(auth.isCommunityUnlocked ? 'Sperren' : 'Anmelden'),
child: Text(
(auth.isRegisteredUser || auth.isCommunityUnlocked)
? 'Abmelden'
: 'Anmelden',
),
),
],
),
if (auth.isAdmin) ...[
const Divider(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.manage_accounts),
label: const Text('Benutzerverwaltung & Admin-Rechte'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const UserManagementScreen(),
),
);
},
),
),
],
if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[
const Divider(height: 16),
InkWell(
onTap: () => AuthDialog.show(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add,
size: 16, color: Colors.blue.shade800),
const SizedBox(width: 6),
Text(
'Persönliches Konto anlegen für dauerhaften Login',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blue.shade800,
),
),
],
),
),
),
const SizedBox(height: 16),
],
],
),
),
),
const SizedBox(height: 14),
// Gemeinde Info Header
// About Bürgerforum Oggau
Card(
elevation: 2,
elevation: 1.5,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.campaign, color: Colors.blue.shade800, size: 24),
const SizedBox(width: 8),
const Text(
'Über das Bürgerforum Oggau',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 10),
const Text(
'Das Bürgerforum Oggau ist eine unabhängige Bürgerliste für unsere Marktgemeinde Oggau am Neusiedler See.\n\n'
'Wir stehen für Bürgernähe, Transparenz und eine zukunftsorientierte Gemeindepolitik. '
'Mit dieser App (unserem digitalen „Gemeindetrommler“) wollen wir allen Bürgerinnen und Bürgern eine unkomplizierte Möglichkeit geben, Mängel im Ort aufzuzeigen, Ideen einzubringen und sich zu informieren.',
style: TextStyle(fontSize: 13, height: 1.45),
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.group, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Text(
'Gemeindevertretung & Team Bürgerforum',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey.shade800),
),
],
),
],
),
),
),
const SizedBox(height: 14),
// Gemeinde Bürgerservice (Nützliche Kontakte)
Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
@@ -142,33 +246,29 @@ class _InfoScreenState extends State<InfoScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Marktgemeinde Oggau am Neusiedler See',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
'Gemeindeamt & Bürgerservice Oggau',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Hauptstraße 52, 7063 Oggau'),
Text('Telefon: +43 2685 7201'),
Text('E-Mail: post@oggau.bgld.gv.at'),
Text('Web: www.oggau.at'),
SizedBox(height: 12),
Divider(),
Text('Adresse: Hauptstraße 52, 7063 Oggau', style: TextStyle(fontSize: 13)),
Text('Telefon: +43 2685 7201', style: TextStyle(fontSize: 13)),
Text('Web: www.oggau.at', style: TextStyle(fontSize: 13)),
SizedBox(height: 8),
Text(
'Parteienverkehr / Öffnungszeiten:',
style: TextStyle(fontWeight: FontWeight.bold),
'Öffnungszeiten Gemeindeamt:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text('Montag bis Freitag: 08:00 – 12:00 Uhr'),
Text('Dienstag zusätzlich: 13:00 – 17:00 Uhr'),
Text('Mo – Fr: 08:00 – 12:00 Uhr | Di zusätzlich: 13:00 – 17:00 Uhr',
style: TextStyle(fontSize: 12)),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
// Notrufnummern
// Wichtige Notrufnummern
Card(
elevation: 2,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
@@ -177,9 +277,9 @@ class _InfoScreenState extends State<InfoScreen> {
children: [
Text(
'Wichtige Notrufnummern',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.red),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.red),
),
SizedBox(height: 12),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -187,7 +287,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -195,7 +295,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -203,7 +303,7 @@ class _InfoScreenState extends State<InfoScreen> {
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -211,19 +311,11 @@ class _InfoScreenState extends State<InfoScreen> {
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Ärztefunkdienst'),
Text('141', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
// Impressum
Card(
@@ -235,22 +327,23 @@ class _InfoScreenState extends State<InfoScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Impressum & Rechtliches',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
'Impressum',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
SizedBox(height: 6),
Text(
'Medieninhaber & Herausgeber:\n'
'Marktgemeinde Oggau am Neusiedler See\n'
'Hauptstraße 52, A-7063 Oggau\n\n'
'Erstellt als Gemeinde-Informationssystem für Bürgerinnen und Bürger.\n'
'Alle Rechte vorbehalten.',
style: TextStyle(fontSize: 12, color: Colors.black87),
'Bürgerforum Oggau (Unabhängige Bürgerliste Oggau)\n'
'7063 Oggau am Neusiedler See\n\n'
'Zweck der App:\n'
'Informations- und Mitmach-Plattform für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.',
style: TextStyle(fontSize: 12, color: Colors.black87, height: 1.4),
),
],
),
),
),
const SizedBox(height: 16),
],
),
);
+265
View File
@@ -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,
),
),
),
],
],
),
),
),
),
);
},
);
},
),
),
);
}
}
+472 -47
View File
@@ -1,16 +1,19 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/post_item.dart';
import '../services/aussendungen_service.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_service.dart';
import '../services/storage_service.dart';
import 'add_issue_screen.dart';
import 'add_post_screen.dart';
import 'auth_dialog.dart';
import 'detail_screen.dart';
import 'pdf_viewer_screen.dart';
class PostListScreen extends StatefulWidget {
final String collectionName;
@@ -29,12 +32,44 @@ class PostListScreen extends StatefulWidget {
class _PostListScreenState extends State<PostListScreen> {
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
final AussendungenService _aussendungenService = AussendungenService();
final Map<String, PostItem> _selectedItems = {};
String _searchQuery = '';
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
List<PostItem> _aussendungen = [];
bool _isLoadingAussendungen = false;
String _newsFilter = 'all'; // 'all', 'aussendungen', 'news'
@override
void initState() {
super.initState();
if (widget.collectionName == 'News') {
_loadAussendungen();
}
}
Future<void> _loadAussendungen({bool force = false}) async {
if (widget.collectionName != 'News') return;
if (mounted) setState(() => _isLoadingAussendungen = true);
try {
final items = await _aussendungenService.getAussendungen(forceRefresh: force);
if (mounted) {
setState(() {
_aussendungen = items;
_isLoadingAussendungen = false;
});
}
} catch (e) {
debugPrint('Error loading Aussendungen: $e');
if (mounted) {
setState(() => _isLoadingAussendungen = false);
}
}
}
@override
void dispose() {
_searchController.dispose();
@@ -58,9 +93,28 @@ class _PostListScreenState extends State<PostListScreen> {
}
Future<void> _deletePost(PostItem item) async {
final auth = Provider.of<AuthService>(context, listen: false);
final currentUser = auth.user;
final isAuthor = currentUser != null && item.authorUid == currentUser.uid;
final canDelete = isAuthor || auth.isAdmin;
if (!canDelete) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keine Berechtigung zum Löschen dieses Beitrags.'),
backgroundColor: Colors.red,
),
);
return;
}
try {
for (final url in item.imageUrls) {
try {
await _storageService.deleteFileByUrl(url);
} catch (storageErr) {
debugPrint('Storage delete non-fatal error: $storageErr');
}
}
await _firestoreService.deletePost(widget.collectionName, item.id);
if (!mounted) return;
@@ -75,21 +129,53 @@ class _PostListScreenState extends State<PostListScreen> {
}
}
void _onAddNewPressed(BuildContext context) {
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);
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
// News are official announcements and can only be created by Admins
if (widget.collectionName == 'News' && !auth.isAdmin) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Bitte schalten Sie den Zugang unter "Informationen" mit dem Gemeinde-Passwort frei.',
),
backgroundColor: Colors.orange,
content:
Text('Nur Administratoren können Neuigkeiten veröffentlichen.'),
backgroundColor: Colors.amber,
),
);
return;
}
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
await AuthDialog.show(context);
if (!mounted) return;
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
return;
}
}
_clearSelection();
if (widget.collectionName == 'Issues') {
@@ -114,7 +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(
@@ -124,13 +211,32 @@ class _PostListScreenState extends State<PostListScreen> {
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Suche nach ${widget.title}...',
hintText: 'Suche in ${widget.title}...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: Text(widget.title),
: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/images/logo.png',
height: 26,
fit: BoxFit.contain,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.title,
overflow: TextOverflow.ellipsis,
),
),
],
),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
@@ -164,7 +270,86 @@ class _PostListScreenState extends State<PostListScreen> {
],
],
),
body: StreamBuilder<List<PostItem>>(
body: widget.collectionName == 'News'
? Column(
children: [
_buildNewsFilterBar(),
if (_isLoadingAussendungen && _aussendungen.isEmpty)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: RefreshIndicator(
onRefresh: () async {
await _loadAussendungen(force: true);
},
child: _buildPostList(auth, currentUser),
),
),
],
)
: _buildPostList(auth, currentUser),
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
? null
: FloatingActionButton(
onPressed: _onAddNewPressed,
tooltip: widget.collectionName == 'News'
? 'Nachricht verfassen'
: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
Widget _buildNewsFilterBar() {
return Container(
width: double.infinity,
color: Colors.grey.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Alle'),
selected: _newsFilter == 'all',
onSelected: (_) => setState(() => _newsFilter = 'all'),
selectedColor: Colors.blue.shade100,
),
const SizedBox(width: 8),
ChoiceChip(
avatar: Icon(
Icons.picture_as_pdf,
size: 16,
color: _newsFilter == 'aussendungen'
? Colors.red.shade900
: Colors.red.shade700,
),
label: Text('Aussendungen (${_aussendungen.length})'),
selected: _newsFilter == 'aussendungen',
onSelected: (_) => setState(() => _newsFilter = 'aussendungen'),
selectedColor: Colors.red.shade100,
),
const SizedBox(width: 8),
ChoiceChip(
avatar: Icon(
Icons.newspaper,
size: 16,
color: _newsFilter == 'news'
? Colors.blue.shade900
: Colors.blue.shade700,
),
label: const Text('Aktuelles'),
selected: _newsFilter == 'news',
onSelected: (_) => setState(() => _newsFilter = 'news'),
selectedColor: Colors.blue.shade100,
),
],
),
),
);
}
Widget _buildPostList(AuthService auth, dynamic currentUser) {
return StreamBuilder<List<PostItem>>(
stream: _firestoreService.getPostsStream(widget.collectionName),
builder: (context, snapshot) {
if (snapshot.hasError) {
@@ -176,11 +361,27 @@ class _PostListScreenState extends State<PostListScreen> {
);
}
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState == ConnectionState.waiting &&
widget.collectionName != 'News') {
return const Center(child: CircularProgressIndicator());
}
final allItems = snapshot.data ?? [];
final firestoreItems = snapshot.data ?? [];
List<PostItem> allItems;
if (widget.collectionName == 'News') {
allItems = [...firestoreItems, ..._aussendungen];
allItems.sort((a, b) => b.createdDate.compareTo(a.createdDate));
if (_newsFilter == 'aussendungen') {
allItems = allItems.where((item) => item.isAussendung).toList();
} else if (_newsFilter == 'news') {
allItems = allItems.where((item) => !item.isAussendung).toList();
}
} else {
allItems = firestoreItems;
}
final filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
@@ -224,29 +425,16 @@ class _PostListScreenState extends State<PostListScreen> {
final isSelected = _selectedItems.containsKey(item.id);
final isAuthor =
currentUser != null && item.authorUid == currentUser.uid;
final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
final canEdit = !item.isAussendung && (isAuthor || auth.isAdmin);
final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
final isIssue =
widget.collectionName == 'Issues' || item.street != null;
Widget itemTile = ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () {
if (_selectedItems.isNotEmpty) {
_toggleSelection(item);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(item: item),
),
);
}
},
onLongPress: () => _toggleSelection(item),
leading: item.imageUrl.isNotEmpty
? Stack(
Widget? leadingWidget;
if (item.imageUrl.isNotEmpty) {
leadingWidget = Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -286,13 +474,164 @@ class _PostListScreenState extends State<PostListScreen> {
),
),
],
)
: isSelected
? const CircleAvatar(
);
} else if (item.isAussendung) {
leadingWidget = Stack(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade200),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.picture_as_pdf,
color: Colors.red.shade700, size: 26),
const SizedBox(height: 2),
Text(
'PDF',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.red.shade800,
),
),
],
),
),
if (isSelected)
const Positioned(
top: 2,
left: 2,
child: CircleAvatar(
radius: 12,
backgroundColor: Colors.blue,
child: Icon(Icons.check, size: 16, color: Colors.white),
),
),
],
);
} else if (isSelected) {
leadingWidget = const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.check, color: Colors.white),
);
}
Widget itemTile = ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () async {
if (_selectedItems.isNotEmpty) {
_toggleSelection(item);
} else if (item.isAussendung) {
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => PdfViewerScreen(
title: item.title,
pdfUrl: item.pdfUrl!,
),
),
);
} else if (item.webUrl != null && item.webUrl!.isNotEmpty) {
final uri = Uri.parse(item.webUrl!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
}
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(
item: item,
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,
: null)),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -303,7 +642,7 @@ class _PostListScreenState extends State<PostListScreen> {
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
@@ -320,11 +659,69 @@ class _PostListScreenState extends State<PostListScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.street != null && item.street!.isNotEmpty) ...[
if (isIssue && item.status != null && item.status!.isNotEmpty) ...[
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.status == 'Erledigt'
? Colors.green.shade50
: (item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: item.status == 'Erledigt'
? Colors.green.shade300
: (item.status == 'In Bearbeitung'
? Colors.blue.shade300
: Colors.orange.shade300),
),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: item.status == 'Erledigt'
? Colors.green.shade800
: (item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
),
),
if (item.street != null && item.street!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 2),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
),
] else if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 4),
Text(
item.street!,
@@ -338,6 +735,40 @@ class _PostListScreenState extends State<PostListScreen> {
),
],
const SizedBox(height: 4),
if (item.isAussendung)
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Aussendung (PDF)',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.red.shade900,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
),
],
)
else
Text(
item.description,
maxLines: 2,
@@ -351,7 +782,7 @@ class _PostListScreenState extends State<PostListScreen> {
),
);
if (isAuthor) {
if (canDelete) {
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
@@ -392,12 +823,6 @@ class _PostListScreenState extends State<PostListScreen> {
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onAddNewPressed(context),
tooltip: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
}
+555
View File
@@ -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,
),
),
],
),
),
],
),
);
},
);
},
);
},
),
),
],
),
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/post_item.dart';
class AussendungenService {
static const String _cacheKey = 'cached_aussendungen_v1';
static const String _apiUrl =
'https://buergerforumoggau.at/wp-json/wp/v2/posts?categories=9&per_page=50';
static List<PostItem>? _memoryCache;
/// Returns cached posts immediately if available, followed by fresh network posts
Future<List<PostItem>> getAussendungen({bool forceRefresh = false}) async {
if (!forceRefresh && _memoryCache != null && _memoryCache!.isNotEmpty) {
return _memoryCache!;
}
// Try loading from local SharedPreferences cache first
if (!forceRefresh) {
final cached = await _loadFromLocalCache();
if (cached.isNotEmpty) {
_memoryCache = cached;
// Trigger background refresh silently
_fetchFromNetworkAndCache().catchError((e) {
debugPrint('Silent background refresh failed: ');
return <PostItem>[];
});
return cached;
}
}
// Otherwise fetch fresh from network
try {
final fresh = await _fetchFromNetworkAndCache();
if (fresh.isNotEmpty) {
_memoryCache = fresh;
return fresh;
}
} catch (e) {
debugPrint('Aussendungen network fetch error: ');
final fallback = await _loadFromLocalCache();
if (fallback.isNotEmpty) {
_memoryCache = fallback;
return fallback;
}
rethrow;
}
return _memoryCache ?? [];
}
/// Network request to WordPress REST API
Future<List<PostItem>> _fetchFromNetworkAndCache() async {
final response = await http
.get(Uri.parse(_apiUrl))
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final dynamic decoded = jsonDecode(utf8.decode(response.bodyBytes));
if (decoded is List) {
final List<PostItem> items = [];
for (final item in decoded) {
if (item is Map<String, dynamic>) {
try {
final post = PostItem.fromWordPressJson(item);
items.add(post);
} catch (e) {
debugPrint('Error parsing WP post item: ');
}
}
}
await _saveToLocalCache(decoded);
_memoryCache = items;
return items;
}
} else {
throw Exception('Server returned ');
}
return [];
}
/// Load from SharedPreferences
Future<List<PostItem>> _loadFromLocalCache() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? jsonString = prefs.getString(_cacheKey);
if (jsonString != null && jsonString.isNotEmpty) {
final dynamic decoded = jsonDecode(jsonString);
if (decoded is List) {
final List<PostItem> items = [];
for (final item in decoded) {
if (item is Map<String, dynamic>) {
try {
items.add(PostItem.fromWordPressJson(item));
} catch (e) {
debugPrint('Error parsing cached WP item: ');
}
}
}
return items;
}
}
} catch (e) {
debugPrint('Error loading cached aussendungen: ');
}
return [];
}
/// Save to SharedPreferences
Future<void> _saveToLocalCache(List<dynamic> jsonList) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cacheKey, jsonEncode(jsonList));
} catch (e) {
debugPrint('Error saving aussendungen to cache: ');
}
}
}
+279 -6
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthService extends ChangeNotifier {
@@ -12,41 +13,172 @@ class AuthService extends ChangeNotifier {
User? get user => _user;
bool get isAuthenticated => _user != null;
bool get isCommunityUnlocked => _isCommunityUnlocked;
bool get isAnonymous => _user?.isAnonymous ?? true;
bool get isRegisteredUser => _user != null && !_user!.isAnonymous;
bool get isCommunityUnlocked => _isCommunityUnlocked || isRegisteredUser;
String? get email => _user?.email;
String? get displayName => _user?.displayName;
String? _cachedFirstName;
String? _cachedLastName;
bool _isAdmin = false;
bool get isAdmin => _isAdmin;
String get firstName {
if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) {
return _cachedFirstName!;
}
final name = _user?.displayName ?? '';
if (name.isEmpty) return '';
final parts = name.trim().split(RegExp(r'\s+'));
return parts.isNotEmpty ? parts.first : '';
}
String get lastName {
if (_cachedLastName != null && _cachedLastName!.isNotEmpty) {
return _cachedLastName!;
}
final name = _user?.displayName ?? '';
if (name.isEmpty) return '';
final parts = name.trim().split(RegExp(r'\s+'));
return parts.length > 1 ? parts.sublist(1).join(' ') : '';
}
AuthService() {
_initAuth();
}
Future<void> _checkAdminClaim(User? user) async {
if (user != null && !user.isAnonymous) {
try {
// 1. Custom claims check (token.admin == true)
final tokenResult = await user.getIdTokenResult();
if (tokenResult.claims?['admin'] == true) {
_isAdmin = true;
return;
}
final firestore = FirebaseFirestore.instance;
// 2. Check Firestore 'Admins' collection by user UID (doc id)
final docByUid =
await firestore.collection('Admins').doc(user.uid).get();
if (docByUid.exists) {
_isAdmin = true;
return;
}
// 3. Check Firestore 'Admins' collection by email (doc id)
final userEmail = user.email?.trim().toLowerCase();
if (userEmail != null && userEmail.isNotEmpty) {
final docByEmail =
await firestore.collection('Admins').doc(userEmail).get();
if (docByEmail.exists) {
_isAdmin = true;
return;
}
// 4. Check Firestore 'Admins' collection where email field == userEmail
final queryByEmail = await firestore
.collection('Admins')
.where('email', isEqualTo: userEmail)
.limit(1)
.get();
if (queryByEmail.docs.isNotEmpty) {
_isAdmin = true;
return;
}
}
// 5. Check Firestore 'Admins' collection where uid field == user.uid
final queryByUid = await firestore
.collection('Admins')
.where('uid', isEqualTo: user.uid)
.limit(1)
.get();
if (queryByUid.docs.isNotEmpty) {
_isAdmin = true;
return;
}
_isAdmin = false;
} catch (e) {
if (kDebugMode) {
debugPrint('Error checking admin status: $e');
}
_isAdmin = false;
}
} else {
_isAdmin = false;
}
}
Future<void> _initAuth() async {
final prefs = await SharedPreferences.getInstance();
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
_cachedFirstName = prefs.getString('user_first_name');
_cachedLastName = prefs.getString('user_last_name');
_auth.authStateChanges().listen((User? user) {
_auth.authStateChanges().listen((User? user) async {
_user = user;
await _checkAdminClaim(user);
if (user != null && !user.isAnonymous) {
await _syncUserProfile(user);
}
notifyListeners();
});
// Automatically ensure anonymous login if unlocked
if (_user == null && _isCommunityUnlocked) {
await signInAnonymously();
}
}
Future<void> _syncUserProfile(User user, {String? firstName, String? lastName}) async {
if (user.isAnonymous) return;
try {
final cleanEmail = user.email?.trim().toLowerCase() ?? '';
if (cleanEmail.isEmpty) return;
final cleanFirst = firstName ?? this.firstName;
final cleanLast = lastName ?? this.lastName;
final name = user.displayName ?? '$cleanFirst $cleanLast'.trim();
await FirebaseFirestore.instance.collection('Users').doc(user.uid).set({
'uid': user.uid,
'email': cleanEmail,
'displayName': name,
'firstName': cleanFirst,
'lastName': cleanLast,
'emailVerified': user.emailVerified,
'lastLogin': FieldValue.serverTimestamp(),
if (_isAdmin) 'isAdmin': true,
}, SetOptions(merge: true));
} catch (e) {
if (kDebugMode) {
debugPrint('Error syncing user profile in auth service: $e');
}
}
}
/// Verifies community access code
Future<bool> verifyCommunityCode(String enteredCode) async {
final cleanCode = enteredCode.trim().toLowerCase();
// Accept default code 'oggau' or any custom community password
if (cleanCode == defaultCommunityCode || cleanCode == 'oggau2024' || cleanCode == 'oggau7063') {
if (cleanCode == defaultCommunityCode ||
cleanCode == 'oggau2024' ||
cleanCode == 'oggau7063') {
_isCommunityUnlocked = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyUnlocked, true);
if (_user == null) {
await signInAnonymously();
}
notifyListeners();
return true;
}
return false;
}
/// Lock community access and log out
Future<void> lockCommunityAccess() async {
_isCommunityUnlocked = false;
final prefs = await SharedPreferences.getInstance();
@@ -55,6 +187,118 @@ class AuthService extends ChangeNotifier {
notifyListeners();
}
/// Sign in with Email and Password
Future<String?> signInWithEmail(String email, String password) async {
try {
final credential = await _auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
_user = credential.user;
await _checkAdminClaim(_user);
if (_user != null) {
await _syncUserProfile(_user!);
}
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
}
}
/// Sign up with Email, Password, First Name, and Last Name
Future<String?> signUpWithEmail({
required String email,
required String password,
required String firstName,
required String lastName,
}) async {
try {
final credential = await _auth.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
);
_user = credential.user;
if (_user != null) {
final cleanFirst = firstName.trim();
final cleanLast = lastName.trim();
final fullName = '$cleanFirst $cleanLast'.trim();
if (fullName.isNotEmpty) {
await _user!.updateDisplayName(fullName);
}
_cachedFirstName = cleanFirst;
_cachedLastName = cleanLast;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_first_name', cleanFirst);
await prefs.setString('user_last_name', cleanLast);
await _checkAdminClaim(_user);
await _syncUserProfile(_user!,
firstName: cleanFirst, lastName: cleanLast);
// Send email verification so user verifies they own the email address
try {
await _user!.sendEmailVerification();
} catch (e) {
if (kDebugMode) {
debugPrint('Error sending verification email: $e');
}
}
}
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
}
}
/// Check if user's email is verified
bool get isEmailVerified => _user?.emailVerified ?? false;
/// Reload current user from Firebase to refresh emailVerified status and admin claim
Future<void> reloadUser() async {
await _user?.reload();
_user = _auth.currentUser;
await _checkAdminClaim(_user);
notifyListeners();
}
/// Re-send verification email
Future<String?> sendEmailVerification() async {
try {
await _user?.sendEmailVerification();
return null;
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Fehler beim Senden der Bestätigungs-E-Mail: $e';
}
}
/// Sign in with Google (UI stub / preparation)
Future<String?> signInWithGoogle() async {
// Note: Once SHA-1 fingerprint is registered in Firebase Console,
// the full interactive Google OAuth sign-in flow can be activated.
return 'Google Sign-In ist in Vorbereitung. Bitte nutze derzeit die E-Mail-Anmeldung oder den Bürger-Code.';
}
/// Send password reset email
Future<String?> sendPasswordReset(String email) async {
try {
await _auth.sendPasswordResetEmail(email: email.trim());
return null; // success
} on FirebaseAuthException catch (e) {
return _getLocalizedErrorMessage(e.code);
} catch (e) {
return 'Fehler beim Senden der E-Mail: $e';
}
}
/// Anonymous login
Future<void> signInAnonymously() async {
try {
await _auth.signInAnonymously();
@@ -65,8 +309,15 @@ class AuthService extends ChangeNotifier {
}
}
/// Sign out
Future<void> signOut() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('user_first_name');
await prefs.remove('user_last_name');
_cachedFirstName = null;
_cachedLastName = null;
_isAdmin = false;
await _auth.signOut();
} catch (e) {
if (kDebugMode) {
@@ -74,4 +325,26 @@ class AuthService extends ChangeNotifier {
}
}
}
String _getLocalizedErrorMessage(String code) {
switch (code) {
case 'user-not-found':
return 'Kein Benutzer mit dieser E-Mail-Adresse gefunden.';
case 'wrong-password':
case 'invalid-credential':
return 'Falsches Passwort oder ungültige Anmeldedaten.';
case 'email-already-in-use':
return 'Diese E-Mail-Adresse wird bereits für ein Konto verwendet.';
case 'invalid-email':
return 'Bitte gib eine gültige E-Mail-Adresse ein.';
case 'weak-password':
return 'Das Passwort ist zu schwach (mindestens 6 Zeichen erforderlich).';
case 'too-many-requests':
return 'Zu viele fehlgeschlagene Versuche. Bitte warte kurz.';
case 'network-request-failed':
return 'Keine Internetverbindung vorhanden.';
default:
return 'Authentifizierungsfehler ($code).';
}
}
}
+144
View File
@@ -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));
}
}
+102 -14
View File
@@ -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,12 +139,28 @@ class PdfService {
],
pw.Divider(thickness: 1, color: PdfColors.grey400),
pw.SizedBox(height: 10),
if (imageProvider != null) ...[
if (imageProviders.isNotEmpty) ...[
if (imageProviders.length == 1)
pw.Center(
child: pw.Container(
height: 220,
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
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),
],
@@ -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
View File
File diff suppressed because it is too large Load Diff
+37 -86
View File
@@ -1,110 +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
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
assets:
- assets/images/
+57
View File
@@ -0,0 +1,57 @@
const https = require('https');
const fs = require('fs');
const path = require('path');
const PROJECT_ID = 'gemeinde-trommler';
const COLLECTIONS = ['News', 'Issues', 'CitizensForum', 'ContactMessages', 'Admins'];
function fetchCollection(collection) {
return new Promise((resolve) => {
const url = `https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/(default)/documents/${collection}?pageSize=300`;
https.get(url, (res) => {
let raw = '';
res.on('data', (chunk) => raw += chunk);
res.on('end', () => {
try {
const json = JSON.parse(raw);
if (json.documents) {
resolve({ collection, status: 'OK', count: json.documents.length, documents: json.documents });
} else if (json.error) {
resolve({ collection, status: 'PERMISSION_OR_EMPTY', error: json.error.message, documents: [] });
} else {
resolve({ collection, status: 'EMPTY', count: 0, documents: [] });
}
} catch (e) {
resolve({ collection, status: 'PARSE_ERROR', error: e.message, documents: [] });
}
});
}).on('error', (err) => {
resolve({ collection, status: 'NETWORK_ERROR', error: err.message, documents: [] });
});
});
}
async function runBackup() {
console.log(`[Backup] Starte Sicherung fuer Firebase Projekt: ${PROJECT_ID}...`);
const results = {};
const backupDir = path.join(__dirname, '..', 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
for (const col of COLLECTIONS) {
process.stdout.write(`[Backup] Lade ${col}... `);
const res = await fetchCollection(col);
results[col] = res;
console.log(`${res.status} (${res.documents ? res.documents.length : 0} Dokumente)`);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `firestore_backup_${timestamp}.json`;
const filePath = path.join(backupDir, filename);
fs.writeFileSync(filePath, JSON.stringify(results, null, 2), 'utf-8');
console.log(`\n[Backup] Erfolgreich gespeichert in: ${filePath}`);
}
runBackup();
+108
View File
@@ -0,0 +1,108 @@
const fs = require('fs');
const path = require('path');
async function main() {
const token = process.env.GITEA_TOKEN;
const repo = process.env.REPO;
let apiUrl = (process.env.GITEA_API || 'https://git.djnizz.at/api/v1').replace(/\/$/, '');
if (!apiUrl.includes('/api/v1')) {
apiUrl += '/api/v1';
}
const tag = process.env.RELEASE_TAG || 'latest';
const apksDir = path.resolve('release-apks');
if (!token) {
console.log('No GITEA_TOKEN provided, skipping direct release publishing.');
return;
}
if (!fs.existsSync(apksDir)) {
console.log('No release-apks directory found, skipping.');
return;
}
const apkFiles = fs.readdirSync(apksDir).filter(f => f.endsWith('.apk'));
if (apkFiles.length === 0) {
console.log('No APK files found in release-apks, skipping.');
return;
}
console.log(`Publishing ${apkFiles.length} APK(s) to Gitea release tag '${tag}'...`);
// 1. Get or create release
let release = null;
const tagRes = await fetch(`${apiUrl}/repos/${repo}/releases/tags/${tag}`, {
headers: { 'Authorization': `token ${token}` }
});
if (tagRes.ok) {
release = await tagRes.json();
console.log(`Found existing release (ID: ${release.id}) for tag '${tag}'.`);
} else {
console.log(`Release for tag '${tag}' does not exist, creating new release...`);
const createRes = await fetch(`${apiUrl}/repos/${repo}/releases`, {
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tag_name: tag,
name: `Oggauer Gemeindetrommler - ${tag.toUpperCase()}`,
body: `Automatische Builds der Android APKs (Split per ABI).\nDirekte APK-Downloads ohne ZIP.`,
draft: false,
prerelease: false
})
});
if (!createRes.ok) {
console.warn(`Could not create release: ${createRes.status} ${await createRes.text()}`);
return;
}
release = await createRes.json();
console.log(`Created release with ID ${release.id}.`);
}
// 2. Query existing assets to delete duplicates
const assetsRes = await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets`, {
headers: { 'Authorization': `token ${token}` }
});
const existingAssets = assetsRes.ok ? await assetsRes.json() : [];
// 3. Upload each APK
for (const fileName of apkFiles) {
const filePath = path.join(apksDir, fileName);
const existing = existingAssets.find(a => a.name === fileName);
if (existing) {
console.log(`Deleting old asset ${fileName} (ID: ${existing.id})...`);
await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets/${existing.id}`, {
method: 'DELETE',
headers: { 'Authorization': `token ${token}` }
});
}
console.log(`Uploading ${fileName} (${(fs.statSync(filePath).size / (1024 * 1024)).toFixed(1)} MB)...`);
const fileBytes = fs.readFileSync(filePath);
const form = new FormData();
form.append('attachment', new Blob([fileBytes]), fileName);
const uploadRes = await fetch(`${apiUrl}/repos/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(fileName)}`, {
method: 'POST',
headers: {
'Authorization': `token ${token}`
},
body: form
});
if (uploadRes.ok) {
const asset = await uploadRes.json();
console.log(`✓ Uploaded ${fileName}: ${asset.browser_download_url || fileName}`);
} else {
console.warn(`Failed to upload ${fileName}: ${uploadRes.status} ${await uploadRes.text()}`);
}
}
console.log('Finished publishing APKs to Gitea Releases!');
}
main().catch(err => {
console.error('Error in publish_release.js:', err);
});
+162
View File
@@ -0,0 +1,162 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <stdint.h>
#include <spawn.h>
static int is_x86_64_elf(const char *path) {
if (!path) return 0;
int fd = open(path, O_RDONLY);
if (fd < 0) return 0;
unsigned char header[20];
ssize_t n = read(fd, header, sizeof(header));
close(fd);
if (n < 20) return 0;
if (header[0] != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F') return 0;
if (header[4] != 2 || header[5] != 1) return 0;
uint16_t machine = (uint16_t)(header[18] | (header[19] << 8));
return machine == 62;
}
static char **clean_env(char *const envp[]) {
extern char **environ;
char *const *src = envp ? envp : environ;
int count = 0;
while (src && src[count]) count++;
char **new_env = (char **)malloc((count + 1) * sizeof(char *));
if (!new_env) return (char **)src;
int j = 0;
for (int i = 0; i < count; i++) {
if (strncmp(src[i], "LD_PRELOAD=", 11) != 0) {
new_env[j++] = src[i];
}
}
new_env[j] = NULL;
return new_env;
}
static char **build_qemu_argv(const char *pathname, char *const argv[]) {
int argc = 0;
while (argv && argv[argc]) argc++;
char **new_argv = (char **)malloc((argc + 4) * sizeof(char *));
if (!new_argv) return NULL;
new_argv[0] = "/usr/bin/qemu-x86_64-static";
new_argv[1] = "-L";
new_argv[2] = "/usr/x86_64-linux-gnu";
new_argv[3] = (char *)pathname;
for (int i = 1; i < argc; i++) {
new_argv[i + 3] = argv[i];
}
new_argv[argc + 3] = NULL;
return new_argv;
}
int execve(const char *pathname, char *const argv[], char *const envp[]) {
static int (*real_execve)(const char *, char *const [], char *const []) = NULL;
if (!real_execve) real_execve = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
if (pathname && is_x86_64_elf(pathname)) {
char **new_argv = build_qemu_argv(pathname, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
return real_execve("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execve(pathname, argv, envp);
}
int execv(const char *pathname, char *const argv[]) {
static int (*real_execv)(const char *, char *const []) = NULL;
if (!real_execv) real_execv = (int (*)(const char *, char *const []))dlsym(RTLD_NEXT, "execv");
if (pathname && is_x86_64_elf(pathname)) {
char **new_argv = build_qemu_argv(pathname, argv);
if (new_argv) {
extern char **environ;
char **clean_envp = clean_env(environ);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execv(pathname, argv);
}
int execvp(const char *file, char *const argv[]) {
static int (*real_execvp)(const char *, char *const []) = NULL;
if (!real_execvp) real_execvp = (int (*)(const char *, char *const []))dlsym(RTLD_NEXT, "execvp");
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
extern char **environ;
char **clean_envp = clean_env(environ);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execvp(file, argv);
}
int execvpe(const char *file, char *const argv[], char *const envp[]) {
static int (*real_execvpe)(const char *, char *const [], char *const []) = NULL;
if (!real_execvpe) real_execvpe = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execvpe");
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
static int (*real_execve_local)(const char *, char *const [], char *const []) = NULL;
if (!real_execve_local) real_execve_local = (int (*)(const char *, char *const [], char *const []))dlsym(RTLD_NEXT, "execve");
return real_execve_local("/usr/bin/qemu-x86_64-static", new_argv, clean_envp);
}
}
return real_execvpe(file, argv, envp);
}
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]) {
static int (*real_posix_spawn)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawn) {
real_posix_spawn = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawn");
}
if (path && is_x86_64_elf(path)) {
char **new_argv = build_qemu_argv(path, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
return real_posix_spawn(pid, "/usr/bin/qemu-x86_64-static", file_actions, attrp, new_argv, clean_envp);
}
}
return real_posix_spawn(pid, path, file_actions, attrp, argv, envp);
}
int posix_spawnp(pid_t *pid, const char *file,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]) {
static int (*real_posix_spawnp)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawnp) {
real_posix_spawnp = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawnp");
}
if (file && is_x86_64_elf(file)) {
char **new_argv = build_qemu_argv(file, argv);
if (new_argv) {
char **clean_envp = clean_env(envp);
static int (*real_posix_spawn_local)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []) = NULL;
if (!real_posix_spawn_local) {
real_posix_spawn_local = (int (*)(pid_t *, const char *, const posix_spawn_file_actions_t *,
const posix_spawnattr_t *, char *const [], char *const []))dlsym(RTLD_NEXT, "posix_spawn");
}
return real_posix_spawn_local(pid, "/usr/bin/qemu-x86_64-static", file_actions, attrp, new_argv, clean_envp);
}
}
return real_posix_spawnp(pid, file, file_actions, attrp, argv, envp);
}
+24
View File
@@ -0,0 +1,24 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// Fotos von News, Mängeln und Ideen
match /{collection}/{userId}/{fileName} {
// Jeder darf Bilder in der App laden/ansehen
allow read: if true;
// Nur angemeldete Nutzer dürfen Fotos hochladen (max. 10 MB, nur Bilder)
allow create, update: if request.auth != null
&& request.resource.size < 10 * 1024 * 1024
&& request.resource.contentType.matches('image/.*');
// Löschen streng gesichert: Nur der eigene Verfasser ODER ein Admin aus der Admins-Sammlung
allow delete: if request.auth != null && (
request.auth.uid == userId ||
request.auth.token.admin == true ||
firestore.exists(/databases/(default)/documents/Admins/$(request.auth.uid)) ||
(request.auth.token.email != null && firestore.exists(/databases/(default)/documents/Admins/$(request.auth.token.email)))
);
}
}
}
+335
View File
@@ -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 &#8211; Konzept &amp; Camping'},
'content': {
'rendered':
'<h2 class="wp-block-heading">Aussendung 17</h2><div class="wp-block-file"><object data="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf" type="application/pdf"></object><a href="https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf">Download</a></div>'
},
'link': 'https://buergerforumoggau.at/2026/09/aussendung-17/',
};
final item = PostItem.fromWordPressJson(wpJson);
expect(item.id, 'wp_659');
expect(item.title, 'Aussendung 17 – Konzept & Camping');
expect(item.isAussendung, true);
expect(item.pdfUrl,
'https://buergerforumoggau.at/wp-content/uploads/2026/09/20260917_Aussendung_17_-_Camping_Final.pdf');
expect(item.webUrl, 'https://buergerforumoggau.at/2026/09/aussendung-17/');
expect(item.category, 'Aussendung');
expect(item.createdDate, DateTime.parse('2026-09-10T10:42:00'));
});
test('serializes to and from local JSON cache correctly', () {
final item = PostItem(
id: 'wp_999',
title: 'Aussendung 99',
description: 'Test Aussendung',
createdDate: DateTime(2026, 9, 14, 12, 0),
pdfUrl: 'https://buergerforumoggau.at/test.pdf',
webUrl: 'https://buergerforumoggau.at/test/',
isAussendung: true,
);
final json = item.toJson();
final restored = PostItem.fromJson(json);
expect(restored.id, item.id);
expect(restored.title, item.title);
expect(restored.pdfUrl, item.pdfUrl);
expect(restored.webUrl, item.webUrl);
expect(restored.isAussendung, true);
expect(restored.createdDate, item.createdDate);
});
test('sorts mixed list of Firestore news and Aussendungen chronologically', () {
final oldNews = PostItem(
id: 'news_1',
title: 'Ältere News',
description: 'Text',
createdDate: DateTime(2026, 8, 1),
);
final newAussendung = PostItem(
id: 'wp_659',
title: 'Aussendung 17',
description: 'Text',
createdDate: DateTime(2026, 9, 10),
isAussendung: true,
);
final newestNews = PostItem(
id: 'news_2',
title: 'Neueste Nachricht',
description: 'Text',
createdDate: DateTime(2026, 9, 14),
);
final list = [oldNews, newAussendung, newestNews];
list.sort((a, b) => b.createdDate.compareTo(a.createdDate));
expect(list[0].id, 'news_2');
expect(list[1].id, 'wp_659');
expect(list[2].id, 'news_1');
});
});
group('Contact Message & Admin Management Tests', () {
test('validates contact message status states and defaults', () {
const allowedStatuses = ['Neu', 'In Bearbeitung', 'Erledigt'];
expect(allowedStatuses.contains('Neu'), isTrue);
expect(allowedStatuses.contains('In Bearbeitung'), isTrue);
expect(allowedStatuses.contains('Erledigt'), isTrue);
expect(allowedStatuses.contains('Unbekannt'), isFalse);
const messageMap = {
'subject': 'Straßenbeleuchtung kaputt',
'message': 'Vor der Schule ist es dunkel.',
'senderName': 'Max Mustermann',
'senderEmail': 'max@example.com',
'senderPhone': '0664 1234567',
'status': 'Neu',
'userId': 'user-123',
};
expect(messageMap['status'], 'Neu');
expect(messageMap['senderEmail'], 'max@example.com');
expect(messageMap['userId'], 'user-123');
});
test('validates user profile normalization and admin lookup', () {
String normalizeEmail(String email) => email.trim().toLowerCase();
const rawEmail = ' Admin.Test@Oggau.at ';
expect(normalizeEmail(rawEmail), 'admin.test@oggau.at');
const adminUids = {'uid_123', 'uid_456'};
const adminEmails = {'admin.test@oggau.at', 'buergermeister@oggau.at'};
bool isUserAdmin(String uid, String email) {
return adminUids.contains(uid) || adminEmails.contains(normalizeEmail(email));
}
expect(isUserAdmin('uid_123', 'other@mail.com'), isTrue);
expect(isUserAdmin('uid_999', 'Admin.Test@oggau.at'), isTrue);
expect(isUserAdmin('uid_999', 'regular.user@oggau.at'), isFalse);
});
test('prevents self-demotion check logic', () {
bool canDemote(String targetUid, String currentUid) {
return targetUid != currentUid;
}
expect(canDemote('target_user', 'current_admin'), isTrue);
expect(canDemote('current_admin', 'current_admin'), isFalse);
});
});
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);
});
});
}