Compare commits

..
41 Commits
Author SHA1 Message Date
Nizz05 a0724af9e2 fix(test): use const declarations in widget_test to satisfy linter
Build Android APK / Build Release APKs (push) Canceled after 4m37s
2026-09-15 22:34:50 +02:00
Nizz05 d9dd2a33c8 docs: update README with current features, in-app user management, and security architecture
Build Android APK / Build Release APKs (push) Failing after 50s
2026-09-15 22:31:02 +02:00
Nizz05 f27ad83908 feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management
Build Android APK / Build Release APKs (push) Failing after 53s
2026-09-15 22:06:36 +02:00
Nizz05 21f115499c feat: integrate WordPress PDF Aussendungen with in-app reader into News
Build Android APK / Build Release APKs (push) Successful in 11m39s
2026-09-14 21:32:03 +02:00
Nizz05 d63222c2ac security(storage): strictly restrict delete to photo author or verified firestore admin
Build Android APK / Build Release APKs (push) Successful in 14m16s
2026-09-14 20:59:40 +02:00
Nizz05 c62d41f44a fix(admin): make storage delete resilient and allow authenticated deletion in storage rules
Build Android APK / Build Release APKs (push) Successful in 14m29s
2026-09-14 20:53:41 +02:00
Nizz05 16801d39c6 feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool
Build Android APK / Build Release APKs (push) Successful in 12m58s
2026-09-14 11:47:58 +02:00
Nizz05 fb91666402 refactor(admin): remove hardcoded email and dynamically resolve admin privileges via Firestore Admins collection
Build Android APK / Build Release APKs (push) Successful in 16m46s
2026-09-14 11:44:04 +02:00
Nizz05 2f2170573d feat(admin): grant full admin permissions in app and security rules to nicolas@gruber.info
Build Android APK / Build Release APKs (push) Successful in 16m30s
2026-09-14 11:35:40 +02:00
Nizz05 f9a88a5ba2 feat(security & admin): update firestore and storage rules with admin token check and enable admin deletion in app
Build Android APK / Build Release APKs (push) Successful in 16m34s
2026-09-14 11:33:17 +02:00
Nizz05 23d10ae953 feat(auth & contact): separate Vorname/Nachname in registration, pre-fill read-only profile in contact, and restrict contact messages to registered users
Build Android APK / Build Release APKs (push) Successful in 14m55s
2026-09-14 11:29:12 +02:00
Nizz05 33f7d1d36c refactor(auth): simplify to 2 tabs and embed Buerger-Code under Google in registration
Build Android APK / Build Release APKs (push) Successful in 11m21s
2026-09-14 11:12:35 +02:00
Nizz05 5ed7288c0e feat(auth): integrate Google Sign-In UI with custom button and friendly preparation notice
Build Android APK / Build Release APKs (push) Successful in 13m30s
2026-09-14 10:33:07 +02:00
Nizz05 a50456032c fix(auth): make auth dialog responsive and scrollable without overflow, add email verification on signup, and add password toggles
Build Android APK / Build Release APKs (push) Successful in 13m30s
2026-09-14 10:26:44 +02:00
Nizz05 66246c9b85 feat(ci): restore universal APK, set 7-day retention for artifacts, and clean workspace to save storage
Build Android APK / Build Release APKs (push) Successful in 11m29s
2026-09-14 10:07:15 +02:00
Nizz05 fe1e5b2685 feat(ci): build only 3 split APKs, upload individual artifacts, and publish unzipped APKs to Gitea Releases
Build Android APK / Build Release APKs (push) Successful in 10m32s
2026-09-14 09:55:04 +02:00
Nizz05 4d376b4c3a fix(ci): use actions/upload-artifact@v3 for Gitea compatibility
Build Android APK / Build Release APKs (push) Successful in 15m32s
2026-09-14 09:31:34 +02:00
Nizz05 3d57f4563e feat(build): migrate to declarative Gradle plugin DSL and bump Dart SDK constraint to >=3.0.0 <4.0.0
Build Android APK / Build Release APKs (push) Failing after 15m10s
2026-09-14 09:28:36 +02:00
Nizz05 20f023d2d5 fix(build): route subprojects buildDir to ../build so Flutter CLI finds generated APKs
Build Android APK / Build Release APKs (push) Failing after 10m55s
2026-09-14 09:17:01 +02:00
Nizz05 a3ddd4dfa3 fix(ci): strip LD_PRELOAD on child QEMU execution and remove rename watcher
Build Android APK / Build Release APKs (push) Failing after 9m49s
2026-09-14 00:27:57 +02:00
Nizz05 f810b801ad fix(ci): delegate execvp and posix_spawnp to real glibc symbols for PATH resolution
Build Android APK / Build Release APKs (push) Failing after 8m20s
2026-09-14 00:17:36 +02:00
Nizz05 810396d4f7 fix(ci): restore string literal quotes in qemu_intercept.c
Build Android APK / Build Release APKs (push) Failing after 14s
2026-09-14 00:16:33 +02:00
Nizz05 b53d31d8cf fix(ci): extract QEMU interceptor to dedicated C file to fix YAML indentation
Build Android APK / Build Release APKs (push) Failing after 18s
2026-09-14 00:15:20 +02:00
Nizz05 23bc802dae fix(ci): inject LD_PRELOAD interceptor to redirect all x86_64 ELF executions to QEMU 2026-09-14 00:13:23 +02:00
Nizz05 e8556839b6 fix(ci): direct AAPT2 userspace wrapping with QEMU to bypass host binfmt and FEX
Build Android APK / Build Release APKs (push) Failing after 8m29s
2026-09-14 00:03:12 +02:00
Nizz05 07789fc06b fix(ci): mount binfmt_misc and cleanly register QEMU x86_64 for AAPT2 on ARM64
Build Android APK / Build Release APKs (push) Failing after 7m43s
2026-09-13 23:51:32 +02:00
Nizz05 2583dc3572 fix(ci): unregister host FEX from binfmt and wrap FEXInterpreter with QEMU for AAPT2
Build Android APK / Build Release APKs (push) Failing after 7m45s
2026-09-13 23:34:50 +02:00
Nizz05 b1970e69ac ci: enable privileged container mode and binfmt support for aapt2 execution
Build Android APK / Build Release APKs (push) Failing after 7m52s
2026-09-13 23:07:19 +02:00
Nizz05 e71063f179 fix(ci): install x86_64 glibc cross libraries for qemu user emulation
Build Android APK / Build Release APKs (push) Failing after 7m49s
2026-09-13 22:44:53 +02:00
Nizz05 d04d539e68 fix(ci): correct YAML indentation in workflow file
Build Android APK / Build Release APKs (push) Failing after 3m48s
2026-09-13 22:39:19 +02:00
Nizz05 1d8a83d1da fix(ci): bridge Flutter linux-arm64 gen_snapshot using qemu wrapper 2026-09-13 22:30:22 +02:00
Nizz05 c6a39b4e02 fix(android): remove hardcoded Windows java.home from repo gradle.properties
Build Android APK / Build Release APKs (push) Failing after 3m43s
2026-09-13 22:20:56 +02:00
Nizz05 a3323c8b2d fix(ci): track gradlew and wrapper jar, ensure executable permissions
Build Android APK / Build Release APKs (push) Failing after 59s
2026-09-13 22:11:52 +02:00
Nizz05 626185aa4f fix(ci): install official Node.js 20 binary for ES2022 syntax support
Build Android APK / Build Release APKs (push) Failing after 5s
2026-09-13 22:06:48 +02:00
Nizz05 a62d80f909 fix(ci): install nodejs in build container before checkout action
Build Android APK / Build Release APKs (push) Failing after 15s
2026-09-13 22:03:22 +02:00
Nizz05 3c41af882d feat(ci): add docker-compose setup for Gitea act_runner and local build script
Build Android APK / Build Release APKs (push) Failing after 1m31s
2026-09-13 21:53:16 +02:00
Nizz05 e2fe4ce6c4 ci: add Gitea Actions workflow to build and upload APK artifacts
Build Android APK / Build Release APKs (push) Failing after 1m31s
2026-09-13 21:44:01 +02:00
Nizz05 cfb68fd8bb feat(auth): add AuthDialog with registration, login, code-unlock and wire into UI 2026-09-13 21:26:57 +02:00
Nizz05 9fd2b2aa4b Add production Firestore and Storage security rules 2026-09-13 21:10:39 +02:00
Nizz05 2854afdd22 Untrack android/app/build directory 2026-09-13 20:52:06 +02:00
Nizz05 02c8189ada 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
40 changed files with 1882 additions and 2115 deletions
+37 -91
View File
@@ -14,64 +14,29 @@ on:
- master
workflow_dispatch:
# Only one build per ref at a time: two QEMU-emulated Gradle builds in parallel
# take the ARM64 runner down (both runs then die at the same instant).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Keep in sync with .github/workflows/build-ios.yaml and pubspec.yaml (environment).
FLUTTER_VERSION: '3.47.4'
# Static qemu-x86_64 for the ARM64 runner (Debian trixie `qemu-user`, see below).
QEMU_DEB_VERSION: '10.0.13+ds-0+deb13u1'
QEMU_DEB_SHA1: '02b553a4532192bc999d15408d1e56d7b254906f'
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
container:
# 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
image: ghcr.io/cirruslabs/flutter:3.19.3
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
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
# On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..."
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc llvm
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc
# Setup x86_64 dynamic libraries & prefix for QEMU emulation
mkdir -p /etc/qemu-binfmt
@@ -84,22 +49,7 @@ jobs:
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
QEMU_BIN=$(command -v qemu-x86_64-static || command -v qemu-x86_64)
# Dummy fallbacks for FEX binaries/sockets
for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
@@ -108,31 +58,14 @@ jobs:
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
for dir in /sdks/flutter/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec 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"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
chmod +x "$dir/linux-arm64/gen_snapshot"
echo "Configured gen_snapshot wrapper for $(basename "$dir")"
fi
done
# 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
@@ -169,22 +102,39 @@ jobs:
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: Build Universal Release APK
run: |
flutter build apk --release || {
echo "Checking if APK was built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Build Split-per-ABI Release APKs
run: |
flutter build apk --release --split-per-abi || {
echo "Checking if split APKs were built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Organize Artifacts
run: |
mkdir -p release-apks
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 ==="
# Copy universal APK
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
elif [ -f android/app/build/outputs/apk/release/app-release.apk ]; then
cp android/app/build/outputs/apk/release/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
fi
# Copy split APKs
for apk in build/app/outputs/flutter-apk/*.apk android/app/build/outputs/apk/release/*.apk; do
[ -f "$apk" ] || continue
case "$(basename "$apk")" in
*arm64-v8a*) cp "$apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk ;;
*armeabi-v7a*) cp "$apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk ;;
*x86_64*) cp "$apk" release-apks/oggauer-gemeindetrommler-x86_64.apk ;;
esac
done
echo "=== Generated All 4 APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
@@ -193,7 +143,6 @@ jobs:
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
@@ -201,7 +150,6 @@ jobs:
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
@@ -209,7 +157,6 @@ jobs:
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
@@ -217,7 +164,6 @@ jobs:
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
+171
View File
@@ -0,0 +1,171 @@
name: Build Android APK
on:
push:
branches:
- main
- master
- 'refactor/**'
tags:
- 'v*'
pull_request:
branches:
- main
- master
workflow_dispatch:
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
permissions:
contents: write
container:
image: ghcr.io/cirruslabs/flutter:3.19.3
options: --privileged
steps:
- name: Setup Node.js 20 & ARM64 Flutter Engine
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; else NODE_ARCH="x64"; fi
curl -fsSL "https://nodejs.org/dist/v20.18.0/node-v20.18.0-linux-${NODE_ARCH}.tar.gz" | tar -xzf - -C /usr/local --strip-components=1
node --version
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory '*'
# On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..."
apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc
# Setup x86_64 dynamic libraries & prefix for QEMU emulation
mkdir -p /etc/qemu-binfmt
ln -sf /usr/x86_64-linux-gnu /etc/qemu-binfmt/x86_64
mkdir -p /lib64 /usr/lib64
ln -sf /usr/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
ln -sf /usr/x86_64-linux-gnu/lib/* /lib64/ 2>/dev/null || true
ln -sf /usr/x86_64-linux-gnu/lib/* /usr/lib64/ 2>/dev/null || true
export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu
echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV"
QEMU_BIN=$(command -v qemu-x86_64-static || command -v qemu-x86_64)
# Dummy fallbacks for FEX binaries/sockets
for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
printf '#!/bin/sh\nexit 0\n' > "$fex"
chmod +x "$fex" 2>/dev/null || true
done
flutter precache --android
for dir in /sdks/flutter/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ]; then
mkdir -p "$dir/linux-arm64"
printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
chmod +x "$dir/linux-arm64/gen_snapshot"
echo "Configured gen_snapshot wrapper for $(basename "$dir")"
fi
done
fi
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup QEMU Interceptor
run: |
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Compiling transparent QEMU execution interceptor..."
gcc -O2 -shared -fPIC -o /usr/local/lib/libqemu_intercept.so scripts/ci/qemu_intercept.c -ldl
echo "LD_PRELOAD=/usr/local/lib/libqemu_intercept.so" >> "$GITHUB_ENV"
rm -f /etc/ld.so.preload
echo "Enabled LD_PRELOAD interceptor via GITHUB_ENV"
fi
- name: Make Gradlew Executable
run: |
if [ -f android/gradlew ]; then
chmod +x android/gradlew
fi
- name: Inspect Build Environment
run: |
flutter --version
java -version
node --version
- name: Fetch Flutter Dependencies
run: flutter pub get
- name: Run Code Analysis & Tests
run: |
flutter analyze
flutter test
- name: Build Universal Release APK
run: |
flutter build apk --release || {
echo "Checking if APK was built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Build Split-per-ABI Release APKs
run: |
flutter build apk --release --split-per-abi || {
echo "Checking if split APKs were built despite path mismatch..."
find build android -type f -name "*release*.apk" 2>/dev/null
}
- name: Organize Artifacts
run: |
mkdir -p release-apks
# Copy universal APK
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
elif [ -f android/app/build/outputs/apk/release/app-release.apk ]; then
cp android/app/build/outputs/apk/release/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk
fi
# Copy split APKs
for apk in build/app/outputs/flutter-apk/*.apk android/app/build/outputs/apk/release/*.apk; do
[ -f "$apk" ] || continue
case "$(basename "$apk")" in
*arm64-v8a*) cp "$apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk ;;
*armeabi-v7a*) cp "$apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk ;;
*x86_64*) cp "$apk" release-apks/oggauer-gemeindetrommler-x86_64.apk ;;
esac
done
echo "=== Generated All 4 APK Artifacts ==="
ls -lah release-apks/
- name: Upload Universal APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7
- name: Upload ARM64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-arm64-v8a
path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
retention-days: 7
- name: Upload ARMv7 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-armeabi-v7a
path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
retention-days: 7
- name: Upload x86_64 APK
uses: actions/upload-artifact@v3
with:
name: oggauer-gemeindetrommler-x86_64
path: release-apks/oggauer-gemeindetrommler-x86_64.apk
retention-days: 7
- name: Clean Workspace Disk Space
if: always()
run: |
rm -rf build android/.gradle release-apks 2>/dev/null || true
-102
View File
@@ -1,102 +0,0 @@
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
+7 -16
View File
@@ -104,7 +104,7 @@ Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sich
## 🏗️ Technische Architektur & Tech-Stack
### Verwendete Technologien:
- **Framework:** [Flutter](https://flutter.dev) `3.47.x` (Dart SDK `>=3.11`), Android `minSdk 23`, iOS `>= 15.0`
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=3.0.0`)
- **Backend & Cloud Services (Google Firebase):**
- `firebase_core` (Initialisierung)
- `firebase_auth` (E-Mail & Passwort Authentifizierung, Gast-Modus)
@@ -117,7 +117,7 @@ Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sich
- `pdf`, `printing` (PDF-Generierung, In-App Vorschau & Druck)
- `url_launcher` (System-Browser, Telefon- und E-Mail-Intents)
- **Bildverarbeitung:**
- `image_picker`, `cached_network_image`, `image`
- `image_picker`, `image_cropper`, `cached_network_image`, `image`
- **State Management:**
- `provider` (`MultiProvider` Architektur)
@@ -150,34 +150,25 @@ lib/
---
## 🤖 CI/CD Pipeline
## 🤖 CI/CD Pipeline (Gitea Actions)
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.
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
### 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`).
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.19.3` mit vorkonfiguriertem Android SDK und Java 17.
- **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus.
- **Erzeugte APK-Pakete:**
1. `oggauer-gemeindetrommler-universal-release.apk` (Universelle Version für alle Android-Smartphones).
2. `oggauer-gemeindetrommler-arm64-v8a.apk` (Optimiert für moderne 64-Bit Geräte).
3. `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Geräte).
4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren).
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als 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.
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als ZIP-Artefakt (`oggauer-gemeindetrommler-apks`) im Gitea-Reiter **Aktionen** zum Download bereit.
---
## 🛠️ Einrichtung & Lokale Ausführung
### Voraussetzungen:
- **Flutter SDK:** `3.47.x` (stable) – identisch zur in den Workflows gepinnten Version
- **Flutter SDK:** `>= 3.19.x`
- **Dart SDK:** Im Flutter SDK enthalten
- **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten
-6
View File
@@ -9,12 +9,6 @@
# 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`
+39 -34
View File
@@ -1,34 +1,60 @@
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 "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.oggauergemeindetrommler.app"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileSdkVersion flutter.compileSdkVersion
ndkVersion "26.1.10909125"
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"
// 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
// 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
}
buildTypes {
@@ -38,31 +64,10 @@ 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 {}
+2 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.oggauergemeindetrommler.app">
<application
android:label="Oggauer Gemeindetrommler"
android:name="${applicationName}"
@@ -0,0 +1,6 @@
package com.oggauergemeindetrommler.oggauer_gemeindetrommler
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
+18 -7
View File
@@ -1,3 +1,4 @@
allprojects {
repositories {
google()
@@ -5,18 +6,28 @@ allprojects {
}
}
// 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)
rootProject.buildDir = '../build'
subprojects {
project.layout.buildDirectory.value(newBuildDir.dir(project.name))
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
// Inject namespace into plugins that don't have it (required for AGP 8+)
afterEvaluate { project ->
if (project.hasProperty('android')) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
delete rootProject.buildDir
}
+2 -10
View File
@@ -1,11 +1,3 @@
# 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
org.gradle.jvmargs=-Xmx1536M -Djdk.lang.Process.launchMechanism=vfork
android.useAndroidX=true
# 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
android.enableJetifier=true
+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-9.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+3 -6
View File
@@ -20,14 +20,11 @@ pluginManagement {
}
}
// 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
id "com.android.application" version "8.5.2" apply false
id "org.jetbrains.kotlin.android" version "1.9.22" apply false
id "com.google.gms.google-services" version "4.4.1" apply false
}
include ":app"
-5
View File
@@ -32,8 +32,3 @@ 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,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
</dict>
</plist>
-3
View File
@@ -1,5 +1,2 @@
#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=
+2 -10
View File
@@ -1,5 +1,5 @@
# Flutter 3.47+ requires iOS 15.0 as the minimum deployment target.
platform :ios, '15.0'
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -37,13 +37,5 @@ 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
@@ -0,0 +1,842 @@
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 = 1510;
LastUpgradeCheck = 1300;
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 = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.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 = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.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 = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.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 = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.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 = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.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 = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+4 -7
View File
@@ -1,16 +1,13 @@
import Flutter
import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
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)
}
}
@@ -1,23 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# 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,27 +26,6 @@
<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: CardThemeData(
cardTheme: CardTheme(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
+6 -51
View File
@@ -19,20 +19,17 @@ class PostItem {
required this.id,
required this.title,
required this.description,
String imageUrl = '',
this.imageUrl = '',
List<String>? imageUrls,
required this.createdDate,
this.authorUid,
this.street,
this.category,
this.status,
this.status = 'Gemeldet',
this.pdfUrl,
this.webUrl,
this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []),
imageUrl = imageUrl.isNotEmpty
? imageUrl
: (imageUrls != null && imageUrls.isNotEmpty ? imageUrls.first : '');
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
factory PostItem.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
@@ -52,9 +49,6 @@ 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? ?? '',
@@ -65,7 +59,7 @@ class PostItem {
authorUid: data['author_uid'] as String?,
street: data['street'] as String?,
category: data['category'] as String?,
status: status,
status: data['status'] as String? ?? 'Gemeldet',
pdfUrl: data['pdfUrl'] as String?,
webUrl: data['webUrl'] as String?,
isAussendung: data['isAussendung'] as bool? ?? false,
@@ -125,23 +119,17 @@ class PostItem {
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 : ''),
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,
status: map['status'] as String? ?? 'Gemeldet',
pdfUrl: map['pdfUrl'] as String?,
webUrl: map['webUrl'] as String?,
isAussendung: map['isAussendung'] as bool? ?? false,
@@ -248,37 +236,4 @@ class PostItem {
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,
);
}
}
+13 -192
View File
@@ -1,18 +1,13 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddIssueScreen extends StatefulWidget {
final PostItem? issueToEdit;
const AddIssueScreen({super.key, this.issueToEdit});
const AddIssueScreen({super.key});
@override
State<AddIssueScreen> createState() => _AddIssueScreenState();
@@ -27,18 +22,8 @@ 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',
@@ -71,32 +56,9 @@ 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() {
@@ -106,10 +68,9 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
}
Future<void> _pickImage(ImageSource source) async {
if (_existingImageUrls.length + _selectedImages.length >= 2) {
if (_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;
}
@@ -138,7 +99,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
@@ -175,51 +135,6 @@ 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
@@ -256,8 +171,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context, newIssue);
}
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -277,21 +191,12 @@ 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: Text(isEditing ? 'Mangel bearbeiten' : 'Mangel melden'),
title: const Text('Mangel melden'),
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
@@ -310,7 +215,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedStreet,
value: _selectedStreet,
decoration: const InputDecoration(
labelText: 'Straße / Örtlichkeit *',
border: OutlineInputBorder(),
@@ -327,7 +232,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedCategory,
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Grundlage / Kategorie *',
border: OutlineInputBorder(),
@@ -340,26 +245,6 @@ 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,
@@ -380,60 +265,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
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;
@@ -455,14 +288,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
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',
icon: const Icon(Icons.cancel, color: Colors.red),
onPressed: () {
setState(() {
_selectedImages.removeAt(index);
@@ -470,11 +297,10 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
},
),
),
),
],
);
}),
if (totalPhotos < 2)
if (_selectedImages.length < 2)
InkWell(
onTap: _showImageSourceModal,
child: Container(
@@ -501,7 +327,6 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -515,13 +340,9 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(isEditing ? Icons.save : Icons.check_circle_outline),
: const Icon(Icons.check_circle_outline),
label: Text(
_isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Mangel fertigstellen'),
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
style: const TextStyle(fontSize: 16),
),
),
+8 -143
View File
@@ -1,5 +1,4 @@
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';
@@ -10,13 +9,11 @@ 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
@@ -32,25 +29,8 @@ 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();
@@ -83,7 +63,6 @@ class _AddPostScreenState extends State<AddPostScreen> {
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
@@ -120,52 +99,6 @@ 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 = '';
@@ -197,8 +130,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.green,
),
);
Navigator.pop(context, newPost);
}
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -220,15 +152,10 @@ class _AddPostScreenState extends State<AddPostScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Beitrag bearbeiten' : widget.screenTitle),
title: Text(widget.screenTitle),
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
@@ -285,68 +212,12 @@ 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,
@@ -361,11 +232,9 @@ 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)),
],
),
),
@@ -383,13 +252,9 @@ class _AddPostScreenState extends State<AddPostScreen> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(isEditing ? Icons.save : Icons.send),
: const Icon(Icons.send),
label: Text(
_isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Veröffentlichen'),
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
style: const TextStyle(fontSize: 16),
),
),
+6 -16
View File
@@ -89,18 +89,12 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
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(
builder: (ctx, setModalState) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: bottomInset + keyboardInset + 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
@@ -275,11 +269,10 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
],
),
),
);
},
),
),
);
}
}
Widget _buildStatusButton(
String docId,
@@ -291,7 +284,7 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
return Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: isCurrent ? color.withValues(alpha: 0.15) : null,
backgroundColor: isCurrent ? color.withOpacity(0.15) : null,
side: BorderSide(
color: isCurrent ? color : Colors.grey.shade300,
width: isCurrent ? 2 : 1,
@@ -339,7 +332,7 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: fg.withValues(alpha: 0.3)),
border: Border.all(color: fg.withOpacity(0.3)),
),
child: Text(
status,
@@ -502,9 +495,6 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
}
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) {
+1 -1
View File
@@ -291,7 +291,7 @@ class _AuthDialogState extends State<AuthDialog>
child: Image.asset(
'assets/images/logo.png',
height: 28,
errorBuilder: (_, _, _) => const Icon(
errorBuilder: (_, __, ___) => const Icon(
Icons.campaign,
color: Colors.blue,
),
+53 -277
View File
@@ -1,177 +1,42 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_service.dart';
import '../services/storage_service.dart';
import 'add_issue_screen.dart';
import 'add_post_screen.dart';
import 'pdf_viewer_screen.dart';
class DetailScreen extends StatefulWidget {
class DetailScreen extends StatelessWidget {
final PostItem item;
final String? collectionName;
const DetailScreen({
super.key,
required this.item,
this.collectionName,
});
@override
State<DetailScreen> createState() => _DetailScreenState();
}
class _DetailScreenState extends State<DetailScreen> {
late PostItem _item;
int _currentImageIndex = 0;
final PageController _pageController = PageController();
@override
void initState() {
super.initState();
_item = widget.item;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _editItem() async {
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'CitizensForum');
PostItem? updated;
if (collection == 'Issues' || _item.street != null) {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddIssueScreen(issueToEdit: _item),
),
);
} else {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: collection,
screenTitle: 'Beitrag bearbeiten',
postToEdit: _item,
),
),
);
}
if (updated != null && mounted) {
setState(() {
_item = updated!;
});
}
}
Future<void> _deleteItem() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed != true || !mounted) return;
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'News');
try {
final storage = StorageService();
final firestore = FirestoreService();
for (final url in _item.imageUrls) {
try {
await storage.deleteFileByUrl(url);
} catch (_) {}
}
await firestore.deletePost(collection, _item.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Eintrag gelöscht.')),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Löschen: $e')),
);
}
}
const DetailScreen({super.key, required this.item});
@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: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_item.imageUrls.isNotEmpty) ...[
if (_item.imageUrls.length == 1)
if (item.imageUrls.isNotEmpty)
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(
@@ -180,117 +45,15 @@ class _DetailScreenState extends State<DetailScreen> {
),
),
errorWidget: (context, url, error) => Container(
height: 200,
height: 180,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48),
),
),
)
else
Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 260,
width: double.infinity,
child: PageView.builder(
controller: _pageController,
itemCount: _item.imageUrls.length,
onPageChanged: (index) {
setState(() {
_currentImageIndex = index;
});
},
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: _item.imageUrls[index],
width: double.infinity,
height: 260,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 260,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported,
size: 48),
),
);
},
),
),
),
// Page indicator dots
Positioned(
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
_item.imageUrls.length,
(i) => Container(
margin:
const EdgeInsets.symmetric(horizontal: 3),
width: _currentImageIndex == i ? 16 : 6,
height: 6,
decoration: BoxDecoration(
color: _currentImageIndex == i
? Colors.white
: Colors.white54,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
),
// Photo counter badge (e.g. 1 / 2)
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library,
color: Colors.white, size: 14),
const SizedBox(width: 4),
Text(
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
],
Text(
_item.title,
item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -302,63 +65,48 @@ class _DetailScreenState extends State<DetailScreen> {
formattedDate,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
),
if (isIssue && _item.status != null) ...[
if (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: _item.status == 'Erledigt'
? Colors.green.shade50
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _item.status == 'Erledigt'
? Colors.green.shade200
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade200
: Colors.orange.shade200),
),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
_item.status!,
item.status!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _item.status == 'Erledigt'
? Colors.green.shade800
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
color: Colors.blue.shade800),
),
),
],
],
),
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),
),
],
@@ -373,10 +121,10 @@ class _DetailScreenState extends State<DetailScreen> {
),
const SizedBox(height: 8),
Text(
_item.description,
item.description,
style: const TextStyle(fontSize: 15, height: 1.5),
),
if (_item.pdfUrl != null && _item.pdfUrl!.isNotEmpty) ...[
if (item.pdfUrl != null && item.pdfUrl!.isNotEmpty) ...[
const SizedBox(height: 16),
Card(
color: Colors.red.shade50,
@@ -402,8 +150,8 @@ class _DetailScreenState extends State<DetailScreen> {
context,
MaterialPageRoute(
builder: (ctx) => PdfViewerScreen(
title: _item.title,
pdfUrl: _item.pdfUrl!,
title: item.title,
pdfUrl: item.pdfUrl!,
),
),
);
@@ -412,6 +160,34 @@ class _DetailScreenState extends State<DetailScreen> {
),
],
const SizedBox(height: 24),
// Additional images gallery if there are multiple images
if (item.imageUrls.length > 1) ...[
const Text(
'Weitere Fotos',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: item.imageUrls.length - 1,
separatorBuilder: (ctx, i) => const SizedBox(width: 8),
itemBuilder: (ctx, index) {
final url = item.imageUrls[index + 1];
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 120,
height: 120,
fit: BoxFit.cover,
),
);
},
),
),
],
],
),
),
+86 -68
View File
@@ -19,63 +19,37 @@ class PdfViewerScreen extends StatefulWidget {
State<PdfViewerScreen> createState() => _PdfViewerScreenState();
}
class _PdfViewerScreenState extends State<PdfViewerScreen>
with SingleTickerProviderStateMixin {
class _PdfViewerScreenState extends State<PdfViewerScreen> {
final TransformationController _transformationController =
TransformationController();
late AnimationController _animationController;
Animation<Matrix4>? _zoomAnimation;
TapDownDetails? _doubleTapDetails;
double _zoomLevel = 1.0;
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);
void _zoomIn() {
setState(() {
_zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.identity()..scale(_zoomLevel);
});
}
_zoomAnimation = Matrix4Tween(
begin: currentMatrix,
end: targetMatrix,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
_animationController.forward(from: 0);
void _zoomOut() {
setState(() {
_zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.identity()..scale(_zoomLevel);
});
}
void _resetZoom() {
setState(() {
_zoomLevel = 1.0;
_transformationController.value = Matrix4.identity();
});
}
Future<void> _openExternal() async {
@@ -136,9 +110,9 @@ class _PdfViewerScreenState extends State<PdfViewerScreen>
),
],
),
body: SafeArea(
top: false,
child: PdfPreview.builder(
body: Stack(
children: [
PdfPreview.builder(
build: (PdfPageFormat format) async {
if (_cachedBytes != null) return _cachedBytes!;
final response = await http
@@ -192,27 +166,25 @@ class _PdfViewerScreenState extends State<PdfViewerScreen>
),
),
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(
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
minScale: 0.8,
maxScale: 5.0,
panEnabled: true,
scaleEnabled: true,
constrained: false,
boundaryMargin: const EdgeInsets.symmetric(
horizontal: 48, vertical: 80),
child: SizedBox(
width: viewportWidth,
child: Padding(
onInteractionEnd: (details) {
final scale =
_transformationController.value.getMaxScaleOnAxis();
if (scale != _zoomLevel) {
setState(() {
_zoomLevel = scale;
});
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 16.0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -224,8 +196,7 @@ class _PdfViewerScreenState extends State<PdfViewerScreen>
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: 0.15),
color: Colors.black.withOpacity(0.15),
blurRadius: 10,
offset: const Offset(0, 3),
),
@@ -253,12 +224,59 @@ class _PdfViewerScreenState extends State<PdfViewerScreen>
),
),
),
),
);
},
);
},
),
// Floating Zoom Controls Overlay
Positioned(
right: 16,
bottom: 24,
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24)),
color: Colors.white.withOpacity(0.92),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, color: Colors.black87),
tooltip: 'Vergrößern (+)',
onPressed: _zoomIn,
),
InkWell(
onTap: _resetZoom,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 6),
child: Text(
'${(_zoomLevel * 100).round()}%',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
IconButton(
icon: const Icon(Icons.zoom_out, color: Colors.black87),
tooltip: 'Verkleinern (-)',
onPressed: _zoomOut,
),
IconButton(
icon: const Icon(Icons.restart_alt, size: 20, color: Colors.grey),
tooltip: 'Zoom zurücksetzen (1:1)',
onPressed: _resetZoom,
),
],
),
),
),
),
],
),
);
}
+3 -156
View File
@@ -129,30 +129,6 @@ class _PostListScreenState extends State<PostListScreen> {
}
}
void _editPost(PostItem item) {
if (widget.collectionName == 'Issues') {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddIssueScreen(issueToEdit: item),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: widget.collectionName,
screenTitle: widget.collectionName == 'News'
? 'Nachricht bearbeiten'
: 'Idee bearbeiten',
postToEdit: item,
),
),
);
}
}
Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(context, listen: false);
@@ -426,11 +402,8 @@ class _PostListScreenState extends State<PostListScreen> {
final isAuthor =
currentUser != null && item.authorUid == currentUser.uid;
final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
final canEdit = !item.isAussendung && (isAuthor || auth.isAdmin);
final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
final isIssue =
widget.collectionName == 'Issues' || item.street != null;
Widget? leadingWidget;
if (item.imageUrl.isNotEmpty) {
@@ -552,10 +525,7 @@ class _PostListScreenState extends State<PostListScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(
item: item,
collectionName: widget.collectionName,
),
builder: (ctx) => DetailScreen(item: item),
),
);
}
@@ -565,73 +535,7 @@ class _PostListScreenState extends State<PostListScreen> {
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: [
@@ -659,64 +563,7 @@ class _PostListScreenState extends State<PostListScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isIssue && item.status != null && item.status!.isNotEmpty) ...[
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.status == 'Erledigt'
? Colors.green.shade50
: (item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: item.status == 'Erledigt'
? Colors.green.shade300
: (item.status == 'In Bearbeitung'
? Colors.blue.shade300
: Colors.orange.shade300),
),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: item.status == 'Erledigt'
? Colors.green.shade800
: (item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
),
),
if (item.street != null && item.street!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 2),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
),
] else if (item.street != null && item.street!.isNotEmpty) ...[
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
-3
View File
@@ -365,9 +365,6 @@ class _UserManagementScreenState extends State<UserManagementScreen> {
}
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) {
+2 -10
View File
@@ -43,14 +43,6 @@ 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,
@@ -126,8 +118,8 @@ class FirestoreService {
'firstName': firstName,
if (lastName != null && lastName.isNotEmpty)
'lastName': lastName,
'emailVerified': ?emailVerified,
'isAdmin': ?isAdmin,
if (emailVerified != null) 'emailVerified': emailVerified,
if (isAdmin != null) 'isAdmin': isAdmin,
};
if (!doc.exists) {
+14 -102
View File
@@ -35,15 +35,8 @@ 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 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();
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
pdf.addPage(
pw.MultiPage(
@@ -65,8 +58,7 @@ class PdfService {
),
pw.Text(
formattedDate,
style:
const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
),
],
),
@@ -76,53 +68,6 @@ 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(
@@ -139,28 +84,12 @@ class PdfService {
],
pw.Divider(thickness: 1, color: PdfColors.grey400),
pw.SizedBox(height: 10),
if (imageProviders.isNotEmpty) ...[
if (imageProviders.length == 1)
if (imageProvider != null) ...[
pw.Center(
child: pw.Container(
height: 220,
child: pw.Image(imageProviders.first, fit: pw.BoxFit.contain),
child: pw.Image(imageProvider, 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),
],
@@ -194,10 +123,8 @@ class PdfService {
// Preload all images in parallel
final Map<String, pw.ImageProvider?> imageMap = {};
await Future.wait(items.map((item) async {
final url =
item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl;
if (url.isNotEmpty) {
imageMap[item.id] = await _fetchImage(url);
if (item.imageUrl.isNotEmpty) {
imageMap[item.id] = await _fetchImage(item.imageUrl);
}
}));
@@ -210,8 +137,7 @@ 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,
@@ -226,8 +152,7 @@ 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),
),
],
),
@@ -235,8 +160,7 @@ 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: {
@@ -246,32 +170,20 @@ 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',
imageLabel,
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
];
}).toList(),
),
+227 -259
View File
File diff suppressed because it is too large Load Diff
+82 -34
View File
@@ -1,61 +1,109 @@
name: oggauergemeindetrommler
description: Gemeinde Trommler
# Private app – never publish to pub.dev.
publish_to: 'none'
# Version + build number (build-name+build-number). Can be overridden with
# `flutter build ... --build-name --build-number`.
# 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: 0.0.1
environment:
# 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'
sdk: '>=3.0.0 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# 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
# Misc
http: ^1.2.2
shared_preferences: ^2.5.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
url_launcher: ^6.3.1
# Transitive (via flutter_cache_manager / printing), pinned on purpose:
# path_provider_android >= 2.3 is implemented with `jni` and compiles native
# code (CMake + NDK). The ARM64 Gitea runner would have to run the x86_64 NDK
# toolchain through QEMU, so stay on the pure-Java implementation for now.
path_provider_android: '>=2.2.0 <2.3.0'
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^4.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/images/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
-210
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:oggauergemeindetrommler/models/post_item.dart';
@@ -14,7 +13,6 @@ void main() {
createdDate: now,
street: 'Hauptstraße',
category: 'Straßenbeleuchtung',
status: 'Gemeldet',
);
expect(item.id, 'test-123');
@@ -25,46 +23,6 @@ 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(
@@ -209,172 +167,4 @@ void main() {
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);
});
});
}