Compare commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:45:30 +02:00
Nizz05 85556dabb4 fix(deps): bump flutter to 3.24.5 across CI and update pdf_widget_wrapper to 1.0.4
Build Android APK / Build Release APKs (push) Successful in 5m1s
2026-09-16 00:13:20 +02:00
Nizz05 230430c91c ci: use Flutter 3.24.5 on macos-15 for Xcode 16 and Firebase 11 compatibility
Build Android APK / Build Release APKs (push) Successful in 13m5s
2026-09-15 23:55:24 +02:00
Nizz05 285ba029f4 fix(ios): align Flutter to 3.19.3 and pin Firebase pods to 10.29 to match project code
Build Android APK / Build Release APKs (push) Successful in 13m18s
2026-09-15 23:47:38 +02:00
Nizz05 2b32b3712f fix(ios): satisfy Flutter development team check and disable signing in Release.xcconfig
Build Android APK / Build Release APKs (push) Successful in 14m34s
2026-09-15 23:27:44 +02:00
Nizz05 7affff6e15 ci: switch to macos-15 runner with Xcode 16 and Swift 6 for Firebase SDK compatibility
Build Android APK / Build Release APKs (push) Successful in 16m51s
2026-09-15 23:09:05 +02:00
Nizz05 098c0105f5 fix(ci): fix bash redirection syntax error in iOS build step
Build Android APK / Build Release APKs (push) Successful in 16m57s
2026-09-15 23:00:49 +02:00
Nizz05 a21d370b91 ci: use Flutter stable, refresh pods, and log build diagnostics to step summary
Build Android APK / Build Release APKs (push) Successful in 16m55s
2026-09-15 22:56:09 +02:00
Nizz05 a2da5cdc89 fix(ios): bump iOS deployment target to 13.0 and disable hardcoded development team for CI builds
Build Android APK / Build Release APKs (push) Successful in 16m51s
2026-09-15 22:53:42 +02:00
Nizz05 362a7da74f ci: let Flutter handle CocoaPods, set iOS platform 13.0, and restrict Android build to Gitea
Build Android APK / Build Release APKs (push) Successful in 17m37s
2026-09-15 22:49:26 +02:00
40 changed files with 2073 additions and 1897 deletions
+91 -37
View File
@@ -14,29 +14,64 @@ on:
- master - master
workflow_dispatch: 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: jobs:
build-android: build-android:
name: Build Release APKs name: Build Release APKs
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
permissions: permissions:
contents: write contents: write
container: container:
image: ghcr.io/cirruslabs/flutter:3.19.3 # Cirrus Labs stopped publishing Flutter images in May 2026 (last tag: 3.44.0,
# multi-arch amd64/arm64, Ubuntu 24.04, OpenJDK 21, Android SDK 36). The image is
# only used for its JDK + Android SDK; the bundled Flutter SDK is switched to
# FLUTTER_VERSION in the first step.
image: ghcr.io/cirruslabs/flutter:3.44.0
options: --privileged options: --privileged
steps: 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 - name: Setup Node.js 20 & ARM64 Flutter Engine
run: | run: |
ARCH=$(uname -m) ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; else NODE_ARCH="x64"; fi 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 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 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. # On Linux ARM64, Flutter engine and Android AAPT2 need x86_64 emulation via QEMU.
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
echo "Setting up ARM64 Flutter environment & QEMU binfmt emulation..." 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 apt-get update -qq && apt-get install -y -qq qemu-user-static binfmt-support libc6-amd64-cross libstdc++6-amd64-cross gcc llvm
# Setup x86_64 dynamic libraries & prefix for QEMU emulation # Setup x86_64 dynamic libraries & prefix for QEMU emulation
mkdir -p /etc/qemu-binfmt mkdir -p /etc/qemu-binfmt
@@ -49,7 +84,22 @@ jobs:
export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu export QEMU_LD_PREFIX=/usr/x86_64-linux-gnu
echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV" echo "QEMU_LD_PREFIX=/usr/x86_64-linux-gnu" >> "$GITHUB_ENV"
QEMU_BIN=$(command -v qemu-x86_64-static || command -v qemu-x86_64) # Ubuntu 24.04 ships QEMU 8.2.2, whose x86_64 user-mode emulation dies with
# "QEMU internal SIGSEGV {code=MAPERR, addr=0x20}" as soon as the guest calls
# pthread_getattr_np() - which Dart's gen_snapshot does since Flutter 3.4x
# (https://gitlab.com/qemu-project/qemu/-/issues/2170, fixed in 8.2.3/9.0).
# Replace it with the static binary from Debian trixie's qemu-user package.
QEMU_DEB="qemu-user_${QEMU_DEB_VERSION}_arm64.deb"
curl -fsSL -o /tmp/qemu-user.deb "https://deb.debian.org/debian/pool/main/q/qemu/${QEMU_DEB}" ||
curl -fsSL -o /tmp/qemu-user.deb "https://snapshot.debian.org/file/${QEMU_DEB_SHA1}"
echo "${QEMU_DEB_SHA1} /tmp/qemu-user.deb" | sha1sum -c -
dpkg-deb -x /tmp/qemu-user.deb /tmp/qemu-user
install -m 0755 /tmp/qemu-user/usr/bin/qemu-x86_64 /usr/bin/qemu-x86_64-static
rm -rf /tmp/qemu-user /tmp/qemu-user.deb
/usr/bin/qemu-x86_64-static --version | head -n 1
# Must match the path hard-coded in scripts/ci/qemu_intercept.c.
QEMU_BIN=/usr/bin/qemu-x86_64-static
# Dummy fallbacks for FEX binaries/sockets # 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 for fex in /usr/bin/FEXInterpreter /usr/bin/FEX /usr/local/bin/FEXInterpreter /usr/bin/FEXServer /usr/local/bin/FEXServer; do
@@ -58,14 +108,31 @@ jobs:
done done
flutter precache --android flutter precache --android
for dir in /sdks/flutter/bin/cache/artifacts/engine/android-*; do # Flutter (still) only ships an x86_64 gen_snapshot for Linux hosts; wrap it
if [ -d "$dir/linux-x64" ]; then # with QEMU under the linux-arm64 path the tool expects.
for dir in "${FLUTTER_ROOT:-/sdks/flutter}"/bin/cache/artifacts/engine/android-*; do
if [ -d "$dir/linux-x64" ] && [ ! -x "$dir/linux-arm64/gen_snapshot" ]; then
mkdir -p "$dir/linux-arm64" 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" printf '#!/bin/sh\nSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec env -u LD_PRELOAD %s -L /usr/x86_64-linux-gnu "$SCRIPT_DIR/../linux-x64/gen_snapshot" "$@"\n' "$QEMU_BIN" > "$dir/linux-arm64/gen_snapshot"
chmod +x "$dir/linux-arm64/gen_snapshot" chmod +x "$dir/linux-arm64/gen_snapshot"
echo "Configured gen_snapshot wrapper for $(basename "$dir")" echo "Configured gen_snapshot wrapper for $(basename "$dir")"
fi fi
done 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 fi
- name: Checkout Repository - name: Checkout Repository
@@ -102,39 +169,22 @@ jobs:
flutter analyze flutter analyze
flutter test flutter test
- name: Build Universal Release APK - name: Build Release APKs (Split-per-ABI + Universal)
run: | # android/app/build.gradle re-enables the universal APK for split builds, so
flutter build apk --release || { # this one Gradle run yields all four APKs in build/app/outputs/flutter-apk/.
echo "Checking if APK was built despite path mismatch..." run: flutter build apk --release --split-per-abi
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 - name: Organize Artifacts
run: | run: |
mkdir -p release-apks mkdir -p release-apks
# Copy universal APK APK_DIR=build/app/outputs/flutter-apk
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then echo "=== Flutter APK outputs ==="
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk ls -lah "$APK_DIR"
elif [ -f android/app/build/outputs/apk/release/app-release.apk ]; then cp "$APK_DIR/app-release.apk" release-apks/oggauer-gemeindetrommler-universal.apk
cp android/app/build/outputs/apk/release/app-release.apk release-apks/oggauer-gemeindetrommler-universal.apk cp "$APK_DIR/app-arm64-v8a-release.apk" release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
fi cp "$APK_DIR/app-armeabi-v7a-release.apk" release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
# Copy split APKs cp "$APK_DIR/app-x86_64-release.apk" release-apks/oggauer-gemeindetrommler-x86_64.apk
for apk in build/app/outputs/flutter-apk/*.apk android/app/build/outputs/apk/release/*.apk; do echo "=== Generated APK Artifacts ==="
[ -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/ ls -lah release-apks/
- name: Upload Universal APK - name: Upload Universal APK
@@ -143,6 +193,7 @@ jobs:
name: oggauer-gemeindetrommler-universal name: oggauer-gemeindetrommler-universal
path: release-apks/oggauer-gemeindetrommler-universal.apk path: release-apks/oggauer-gemeindetrommler-universal.apk
retention-days: 7 retention-days: 7
if-no-files-found: error
- name: Upload ARM64 APK - name: Upload ARM64 APK
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -150,6 +201,7 @@ jobs:
name: oggauer-gemeindetrommler-arm64-v8a name: oggauer-gemeindetrommler-arm64-v8a
path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk path: release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
retention-days: 7 retention-days: 7
if-no-files-found: error
- name: Upload ARMv7 APK - name: Upload ARMv7 APK
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -157,6 +209,7 @@ jobs:
name: oggauer-gemeindetrommler-armeabi-v7a name: oggauer-gemeindetrommler-armeabi-v7a
path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk path: release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
retention-days: 7 retention-days: 7
if-no-files-found: error
- name: Upload x86_64 APK - name: Upload x86_64 APK
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -164,6 +217,7 @@ jobs:
name: oggauer-gemeindetrommler-x86_64 name: oggauer-gemeindetrommler-x86_64
path: release-apks/oggauer-gemeindetrommler-x86_64.apk path: release-apks/oggauer-gemeindetrommler-x86_64.apk
retention-days: 7 retention-days: 7
if-no-files-found: error
- name: Publish Direct APKs to Gitea Releases - name: Publish Direct APKs to Gitea Releases
continue-on-error: true continue-on-error: true
-171
View File
@@ -1,171 +0,0 @@
name: Build Android APK
on:
push:
branches:
- main
- master
- 'refactor/**'
tags:
- 'v*'
pull_request:
branches:
- main
- master
workflow_dispatch:
jobs:
build-android:
name: Build Release APKs
runs-on: ubuntu-latest
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
+61 -16
View File
@@ -1,4 +1,4 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
push: push:
@@ -10,10 +10,24 @@ on:
- 'latest' - 'latest'
workflow_dispatch: 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: jobs:
build-ios: build-ios:
name: Build iOS IPA name: Build iOS IPA
runs-on: macos-14 # 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: steps:
- name: Checkout Repository - name: Checkout Repository
@@ -22,32 +36,39 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.19.3' flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable' channel: stable
cache: true 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 - name: Inspect Environment
run: | run: |
flutter --version
xcodebuild -version xcodebuild -version
pod --version pod --version
flutter --version
flutter doctor -v
- name: Fetch Dependencies - name: Fetch Flutter Dependencies
run: flutter pub get run: flutter pub get
- name: Install CocoaPods Pods - name: Build iOS (Unsigned)
run: | run: flutter build ios --release --no-codesign
cd ios
pod install --repo-update
cd ..
- name: Build iOS (Unsigned IPA) - name: Package IPA
run: | run: |
flutter build ios --release --no-codesign set -euo pipefail
mkdir -p build/ios/iphoneos/Payload
cp -r build/ios/iphoneos/Runner.app build/ios/iphoneos/Payload/
cd build/ios/iphoneos cd build/ios/iphoneos
zip -r oggauer-gemeindetrommler.ipa Payload 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 - name: Upload IPA Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -55,3 +76,27 @@ jobs:
name: oggauer-gemeindetrommler-ios name: oggauer-gemeindetrommler-ios
path: build/ios/iphoneos/oggauer-gemeindetrommler.ipa path: build/ios/iphoneos/oggauer-gemeindetrommler.ipa
retention-days: 7 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
+16 -7
View File
@@ -104,7 +104,7 @@ Die Sicherheit und Integrität der Bürgerdaten wird durch ein mehrstufiges Sich
## 🏗️ Technische Architektur & Tech-Stack ## 🏗️ Technische Architektur & Tech-Stack
### Verwendete Technologien: ### Verwendete Technologien:
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=3.0.0`) - **Framework:** [Flutter](https://flutter.dev) `3.47.x` (Dart SDK `>=3.11`), Android `minSdk 23`, iOS `>= 15.0`
- **Backend & Cloud Services (Google Firebase):** - **Backend & Cloud Services (Google Firebase):**
- `firebase_core` (Initialisierung) - `firebase_core` (Initialisierung)
- `firebase_auth` (E-Mail & Passwort Authentifizierung, Gast-Modus) - `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) - `pdf`, `printing` (PDF-Generierung, In-App Vorschau & Druck)
- `url_launcher` (System-Browser, Telefon- und E-Mail-Intents) - `url_launcher` (System-Browser, Telefon- und E-Mail-Intents)
- **Bildverarbeitung:** - **Bildverarbeitung:**
- `image_picker`, `image_cropper`, `cached_network_image`, `image` - `image_picker`, `cached_network_image`, `image`
- **State Management:** - **State Management:**
- `provider` (`MultiProvider` Architektur) - `provider` (`MultiProvider` Architektur)
@@ -150,25 +150,34 @@ lib/
--- ---
## 🤖 CI/CD Pipeline (Gitea Actions) ## 🤖 CI/CD Pipeline
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`): Die Flutter-Version ist zentral gepinnt (`FLUTTER_VERSION` in beiden Workflows, `environment` in `pubspec.yaml`) und muss bei einem Upgrade an allen drei Stellen gemeinsam angehoben werden.
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.19.3` mit vorkonfiguriertem Android SDK und Java 17. ### Android (Gitea Actions, `.gitea/workflows/build-apk.yaml`)
- **Build-Container:** `ghcr.io/cirruslabs/flutter:3.44.0` (letztes von Cirrus Labs veröffentlichtes Image, liefert JDK 21 + Android SDK 36). Das darin enthaltene Flutter-SDK wird im ersten Schritt per `git checkout` auf `FLUTTER_VERSION` umgeschaltet.
- **Runner:** Oracle Cloud ARM64 – x86_64-Werkzeuge (AAPT2, `gen_snapshot`) laufen über QEMU (`scripts/ci/qemu_intercept.c`).
- **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus. - **Automatisierte Qualitätskontrolle:** Führt vor jedem Build `flutter analyze` und `flutter test` aus.
- **Erzeugte APK-Pakete:** - **Erzeugte APK-Pakete:**
1. `oggauer-gemeindetrommler-universal-release.apk` (Universelle Version für alle Android-Smartphones). 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). 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). 3. `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Geräte).
4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren). 4. `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren).
- **Download:** Die gebauten APKs stehen nach jedem Durchlauf als ZIP-Artefakt (`oggauer-gemeindetrommler-apks`) im Gitea-Reiter **Aktionen** zum Download bereit. - **Download:** Die gebauten APKs stehen nach jedem Durchlauf als Artefakte im Gitea-Reiter **Aktionen** sowie im Release `latest` zum Download bereit.
### iOS (GitHub Actions, `.github/workflows/build-ios.yaml`)
- **Runner:** `macos-26` (Xcode 26), Flutter über `subosito/flutter-action`.
- **Plugins:** ausschließlich über CocoaPods (Swift Package Manager ist im Workflow bewusst deaktiviert); `ios/Podfile.lock` wird nicht versioniert, `pod install` läuft bei jedem Build frisch.
- **Ergebnis:** unsigniertes IPA (`flutter build ios --release --no-codesign`) als Artefakt `oggauer-gemeindetrommler-ios` – für die Verteilung muss es nachträglich signiert werden.
--- ---
## 🛠️ Einrichtung & Lokale Ausführung ## 🛠️ Einrichtung & Lokale Ausführung
### Voraussetzungen: ### Voraussetzungen:
- **Flutter SDK:** `>= 3.19.x` - **Flutter SDK:** `3.47.x` (stable) – identisch zur in den Workflows gepinnten Version
- **Dart SDK:** Im Flutter SDK enthalten - **Dart SDK:** Im Flutter SDK enthalten
- **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten - **Android Studio / VS Code / Xcode** mit entsprechenden Emulatoren oder Testgeräten
+6
View File
@@ -9,6 +9,12 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+34 -39
View File
@@ -1,60 +1,34 @@
plugins { plugins {
id "com.android.application" id "com.android.application"
id "kotlin-android" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
// (With android.builtInKotlin=false the Flutter plugin applies kotlin-android itself.)
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id "com.google.gms.google-services" 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 { android {
namespace "com.oggauergemeindetrommler.app" namespace "com.oggauergemeindetrommler.app"
compileSdkVersion flutter.compileSdkVersion compileSdk flutter.compileSdkVersion
ndkVersion "26.1.10909125" ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = '17'
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.oggauergemeindetrommler.app" applicationId "com.oggauergemeindetrommler.app"
// You can update the following values to match your application needs. // Flutter 3.47 defaults to 24; 23 (Android 6.0) is still supported and
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. // is also the minimum required by the current Firebase Android SDKs.
minSdkVersion 23 minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion targetSdk flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() // Read from pubspec.yaml (via local.properties); defaults to 1 / "1.0".
versionName flutterVersionName versionCode flutter.versionCode
multiDexEnabled true versionName flutter.versionName
} }
buildTypes { buildTypes {
@@ -64,10 +38,31 @@ android {
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
} }
// `flutter build apk --split-per-abi` makes the Flutter Gradle plugin enable ABI
// splits for the requested target platforms but switch the universal APK off.
// Turn it back on so a single Gradle run yields the per-ABI APKs *and* the
// universal one (the plugin copies all of them to build/app/outputs/flutter-apk/,
// the universal one as app-release.apk). Only when splitting: for plain builds
// the plugin sets ndk.abiFilters instead, which AGP does not allow together
// with splits. Do not use AGP's `packageReleaseUniversalApk` task for this -
// it derives the universal APK from an App Bundle, and bundles cannot be
// built while ABI splits are enabled.
if (project.findProperty("split-per-abi")?.toString()?.toBoolean()) {
splits {
abi {
universalApk true
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
flutter { flutter {
source '../..' source '../..'
} }
dependencies {}
+1 -2
View File
@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.oggauergemeindetrommler.app">
<application <application
android:label="Oggauer Gemeindetrommler" android:label="Oggauer Gemeindetrommler"
android:name="${applicationName}" android:name="${applicationName}"
@@ -1,6 +0,0 @@
package com.oggauergemeindetrommler.oggauer_gemeindetrommler
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
+7 -18
View File
@@ -1,4 +1,3 @@
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -6,28 +5,18 @@ allprojects {
} }
} }
rootProject.buildDir = '../build' // Keep all Gradle outputs under <repo>/build so the Flutter CLI finds the APKs.
subprojects { // (Project.buildDir was removed in Gradle 9 -> use layout.buildDirectory.)
project.buildDir = "${rootProject.buildDir}/${project.name}" def newBuildDir = rootProject.layout.buildDirectory.dir("../../build").get()
} rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
// Inject namespace into plugins that don't have it (required for AGP 8+) subprojects {
afterEvaluate { project -> project.layout.buildDirectory.value(newBuildDir.dir(project.name))
if (project.hasProperty('android')) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
} }
subprojects { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.layout.buildDirectory
} }
+10 -2
View File
@@ -1,3 +1,11 @@
org.gradle.jvmargs=-Xmx1536M -Djdk.lang.Process.launchMechanism=vfork # vfork keeps process spawning cheap under QEMU user-mode emulation (ARM64 CI runner).
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Djdk.lang.Process.launchMechanism=vfork
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true # Stay on the legacy AGP DSL / external Kotlin plugin until all Flutter plugins
# 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
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+6 -3
View File
@@ -20,11 +20,14 @@ 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 { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.5.2" apply false id "com.android.application" version "9.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.22" apply false id "org.jetbrains.kotlin.android" version "2.4.0" apply false
id "com.google.gms.google-services" version "4.4.1" apply false id "com.google.gms.google-services" version "4.5.0" apply false
} }
include ":app" include ":app"
+5
View File
@@ -32,3 +32,8 @@ Runner/GeneratedPluginRegistrant.*
!default.mode2v3 !default.mode2v3
!default.pbxuser !default.pbxuser
!default.perspectivev3 !default.perspectivev3
# Podfile.lock is intentionally not tracked: development happens on Windows (no
# CocoaPods), and the iOS CI always runs a fresh `pod install`. A stale lock file
# only causes "sandbox is not in sync" / incompatible pod version errors.
Podfile.lock
-2
View File
@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
</dict> </dict>
</plist> </plist>
+3
View File
@@ -1,2 +1,5 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_REQUIRED=NO
CODE_SIGN_IDENTITY=
+10 -2
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Flutter 3.47+ requires iOS 15.0 as the minimum deployment target.
# platform :ios, '12.0' platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -37,5 +37,13 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Keep every pod on the same deployment target as the app so old
# podspecs do not drag the minimum back below what Flutter supports.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
# Pods never need to be signed; the CI produces an unsigned IPA.
config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end end
end end
-842
View File
@@ -1,842 +0,0 @@
PODS:
- abseil/algorithm (1.20211102.0):
- abseil/algorithm/algorithm (= 1.20211102.0)
- abseil/algorithm/container (= 1.20211102.0)
- abseil/algorithm/algorithm (1.20211102.0):
- abseil/base/config
- abseil/algorithm/container (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/base (1.20211102.0):
- abseil/base/atomic_hook (= 1.20211102.0)
- abseil/base/base (= 1.20211102.0)
- abseil/base/base_internal (= 1.20211102.0)
- abseil/base/config (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/base/dynamic_annotations (= 1.20211102.0)
- abseil/base/endian (= 1.20211102.0)
- abseil/base/errno_saver (= 1.20211102.0)
- abseil/base/fast_type_id (= 1.20211102.0)
- abseil/base/log_severity (= 1.20211102.0)
- abseil/base/malloc_internal (= 1.20211102.0)
- abseil/base/pretty_function (= 1.20211102.0)
- abseil/base/raw_logging_internal (= 1.20211102.0)
- abseil/base/spinlock_wait (= 1.20211102.0)
- abseil/base/strerror (= 1.20211102.0)
- abseil/base/throw_delegate (= 1.20211102.0)
- abseil/base/atomic_hook (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/base (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/log_severity
- abseil/base/raw_logging_internal
- abseil/base/spinlock_wait
- abseil/meta/type_traits
- abseil/base/base_internal (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/base/config (1.20211102.0)
- abseil/base/core_headers (1.20211102.0):
- abseil/base/config
- abseil/base/dynamic_annotations (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/errno_saver (1.20211102.0):
- abseil/base/config
- abseil/base/fast_type_id (1.20211102.0):
- abseil/base/config
- abseil/base/log_severity (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/malloc_internal (1.20211102.0):
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/raw_logging_internal
- abseil/base/pretty_function (1.20211102.0)
- abseil/base/raw_logging_internal (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/config
- abseil/base/core_headers
- abseil/base/log_severity
- abseil/base/spinlock_wait (1.20211102.0):
- abseil/base/base_internal
- abseil/base/core_headers
- abseil/base/errno_saver
- abseil/base/strerror (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/errno_saver
- abseil/base/throw_delegate (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/container/common (1.20211102.0):
- abseil/meta/type_traits
- abseil/types/optional
- abseil/container/compressed_tuple (1.20211102.0):
- abseil/utility/utility
- abseil/container/container_memory (1.20211102.0):
- abseil/base/config
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/utility/utility
- abseil/container/fixed_array (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/throw_delegate
- abseil/container/compressed_tuple
- abseil/memory/memory
- abseil/container/flat_hash_map (1.20211102.0):
- abseil/algorithm/container
- abseil/container/container_memory
- abseil/container/hash_function_defaults
- abseil/container/raw_hash_map
- abseil/memory/memory
- abseil/container/hash_function_defaults (1.20211102.0):
- abseil/base/config
- abseil/hash/hash
- abseil/strings/cord
- abseil/strings/strings
- abseil/container/hash_policy_traits (1.20211102.0):
- abseil/meta/type_traits
- abseil/container/hashtable_debug_hooks (1.20211102.0):
- abseil/base/config
- abseil/container/hashtablez_sampler (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/container/have_sse
- abseil/debugging/stacktrace
- abseil/memory/memory
- abseil/profiling/exponential_biased
- abseil/profiling/sample_recorder
- abseil/synchronization/synchronization
- abseil/utility/utility
- abseil/container/have_sse (1.20211102.0)
- abseil/container/inlined_vector (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/base/throw_delegate
- abseil/container/inlined_vector_internal
- abseil/memory/memory
- abseil/container/inlined_vector_internal (1.20211102.0):
- abseil/base/core_headers
- abseil/container/compressed_tuple
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/types/span
- abseil/container/layout (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/strings/strings
- abseil/types/span
- abseil/utility/utility
- abseil/container/raw_hash_map (1.20211102.0):
- abseil/base/throw_delegate
- abseil/container/container_memory
- abseil/container/raw_hash_set
- abseil/container/raw_hash_set (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/container/common
- abseil/container/compressed_tuple
- abseil/container/container_memory
- abseil/container/hash_policy_traits
- abseil/container/hashtable_debug_hooks
- abseil/container/hashtablez_sampler
- abseil/container/have_sse
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/utility/utility
- abseil/debugging/debugging_internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/errno_saver
- abseil/base/raw_logging_internal
- abseil/debugging/demangle_internal (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/debugging/stacktrace (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/debugging/debugging_internal
- abseil/debugging/symbolize (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/debugging/debugging_internal
- abseil/debugging/demangle_internal
- abseil/strings/strings
- abseil/functional/bind_front (1.20211102.0):
- abseil/base/base_internal
- abseil/container/compressed_tuple
- abseil/meta/type_traits
- abseil/utility/utility
- abseil/functional/function_ref (1.20211102.0):
- abseil/base/base_internal
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/hash/city (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/hash/hash (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/container/fixed_array
- abseil/hash/city
- abseil/hash/low_level_hash
- abseil/meta/type_traits
- abseil/numeric/int128
- abseil/strings/strings
- abseil/types/optional
- abseil/types/variant
- abseil/utility/utility
- abseil/hash/low_level_hash (1.20211102.0):
- abseil/base/config
- abseil/base/endian
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/memory (1.20211102.0):
- abseil/memory/memory (= 1.20211102.0)
- abseil/memory/memory (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/meta (1.20211102.0):
- abseil/meta/type_traits (= 1.20211102.0)
- abseil/meta/type_traits (1.20211102.0):
- abseil/base/config
- abseil/numeric/bits (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/int128 (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/bits
- abseil/numeric/representation (1.20211102.0):
- abseil/base/config
- abseil/profiling/exponential_biased (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/profiling/sample_recorder (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/synchronization/synchronization
- abseil/time/time
- abseil/random/distributions (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/random/internal/distribution_caller
- abseil/random/internal/fast_uniform_bits
- abseil/random/internal/fastmath
- abseil/random/internal/generate_real
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/traits
- abseil/random/internal/uniform_helper
- abseil/random/internal/wide_multiply
- abseil/strings/strings
- abseil/random/internal/distribution_caller (1.20211102.0):
- abseil/base/config
- abseil/base/fast_type_id
- abseil/utility/utility
- abseil/random/internal/fast_uniform_bits (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/random/internal/fastmath (1.20211102.0):
- abseil/numeric/bits
- abseil/random/internal/generate_real (1.20211102.0):
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/random/internal/fastmath
- abseil/random/internal/traits
- abseil/random/internal/iostream_state_saver (1.20211102.0):
- abseil/meta/type_traits
- abseil/numeric/int128
- abseil/random/internal/nonsecure_base (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/random/internal/pool_urbg
- abseil/random/internal/salted_seed_seq
- abseil/random/internal/seed_material
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/pcg_engine (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/random/internal/fastmath
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/platform (1.20211102.0):
- abseil/base/config
- abseil/random/internal/pool_urbg (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/random/internal/randen
- abseil/random/internal/seed_material
- abseil/random/internal/traits
- abseil/random/seed_gen_exception
- abseil/types/span
- abseil/random/internal/randen (1.20211102.0):
- abseil/base/raw_logging_internal
- abseil/random/internal/platform
- abseil/random/internal/randen_hwaes
- abseil/random/internal/randen_slow
- abseil/random/internal/randen_engine (1.20211102.0):
- abseil/base/endian
- abseil/meta/type_traits
- abseil/random/internal/iostream_state_saver
- abseil/random/internal/randen
- abseil/random/internal/randen_hwaes (1.20211102.0):
- abseil/base/config
- abseil/random/internal/platform
- abseil/random/internal/randen_hwaes_impl
- abseil/random/internal/randen_hwaes_impl (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/numeric/int128
- abseil/random/internal/platform
- abseil/random/internal/randen_slow (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/numeric/int128
- abseil/random/internal/platform
- abseil/random/internal/salted_seed_seq (1.20211102.0):
- abseil/container/inlined_vector
- abseil/meta/type_traits
- abseil/random/internal/seed_material
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/seed_material (1.20211102.0):
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/raw_logging_internal
- abseil/random/internal/fast_uniform_bits
- abseil/strings/strings
- abseil/types/optional
- abseil/types/span
- abseil/random/internal/traits (1.20211102.0):
- abseil/base/config
- abseil/random/internal/uniform_helper (1.20211102.0):
- abseil/base/config
- abseil/meta/type_traits
- abseil/random/internal/traits
- abseil/random/internal/wide_multiply (1.20211102.0):
- abseil/base/config
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/random/internal/traits
- abseil/random/random (1.20211102.0):
- abseil/random/distributions
- abseil/random/internal/nonsecure_base
- abseil/random/internal/pcg_engine
- abseil/random/internal/pool_urbg
- abseil/random/internal/randen_engine
- abseil/random/seed_sequences
- abseil/random/seed_gen_exception (1.20211102.0):
- abseil/base/config
- abseil/random/seed_sequences (1.20211102.0):
- abseil/container/inlined_vector
- abseil/random/internal/nonsecure_base
- abseil/random/internal/pool_urbg
- abseil/random/internal/salted_seed_seq
- abseil/random/internal/seed_material
- abseil/random/seed_gen_exception
- abseil/types/span
- abseil/status/status (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/container/inlined_vector
- abseil/debugging/stacktrace
- abseil/debugging/symbolize
- abseil/functional/function_ref
- abseil/strings/cord
- abseil/strings/str_format
- abseil/strings/strings
- abseil/types/optional
- abseil/status/statusor (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/meta/type_traits
- abseil/status/status
- abseil/strings/strings
- abseil/types/variant
- abseil/utility/utility
- abseil/strings/cord (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/container/fixed_array
- abseil/container/inlined_vector
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/strings/cord_internal
- abseil/strings/cordz_functions
- abseil/strings/cordz_info
- abseil/strings/cordz_statistics
- abseil/strings/cordz_update_scope
- abseil/strings/cordz_update_tracker
- abseil/strings/internal
- abseil/strings/str_format
- abseil/strings/strings
- abseil/types/optional
- abseil/strings/cord_internal (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/base/throw_delegate
- abseil/container/compressed_tuple
- abseil/container/inlined_vector
- abseil/container/layout
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/strings/strings
- abseil/types/span
- abseil/strings/cordz_functions (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/profiling/exponential_biased
- abseil/strings/cordz_handle (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/synchronization/synchronization
- abseil/strings/cordz_info (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/container/inlined_vector
- abseil/debugging/stacktrace
- abseil/strings/cord_internal
- abseil/strings/cordz_functions
- abseil/strings/cordz_handle
- abseil/strings/cordz_statistics
- abseil/strings/cordz_update_tracker
- abseil/synchronization/synchronization
- abseil/types/span
- abseil/strings/cordz_statistics (1.20211102.0):
- abseil/base/config
- abseil/strings/cordz_update_tracker
- abseil/strings/cordz_update_scope (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/strings/cord_internal
- abseil/strings/cordz_info
- abseil/strings/cordz_update_tracker
- abseil/strings/cordz_update_tracker (1.20211102.0):
- abseil/base/config
- abseil/strings/internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/meta/type_traits
- abseil/strings/str_format (1.20211102.0):
- abseil/strings/str_format_internal
- abseil/strings/str_format_internal (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/functional/function_ref
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/numeric/representation
- abseil/strings/strings
- abseil/types/optional
- abseil/types/span
- abseil/strings/strings (1.20211102.0):
- abseil/base/base
- abseil/base/config
- abseil/base/core_headers
- abseil/base/endian
- abseil/base/raw_logging_internal
- abseil/base/throw_delegate
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/numeric/bits
- abseil/numeric/int128
- abseil/strings/internal
- abseil/synchronization/graphcycles_internal (1.20211102.0):
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/synchronization/kernel_timeout_internal (1.20211102.0):
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/time/time
- abseil/synchronization/synchronization (1.20211102.0):
- abseil/base/atomic_hook
- abseil/base/base
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/base/dynamic_annotations
- abseil/base/malloc_internal
- abseil/base/raw_logging_internal
- abseil/debugging/stacktrace
- abseil/debugging/symbolize
- abseil/synchronization/graphcycles_internal
- abseil/synchronization/kernel_timeout_internal
- abseil/time/time
- abseil/time (1.20211102.0):
- abseil/time/internal (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/time/internal (1.20211102.0):
- abseil/time/internal/cctz (= 1.20211102.0)
- abseil/time/internal/cctz (1.20211102.0):
- abseil/time/internal/cctz/civil_time (= 1.20211102.0)
- abseil/time/internal/cctz/time_zone (= 1.20211102.0)
- abseil/time/internal/cctz/civil_time (1.20211102.0):
- abseil/base/config
- abseil/time/internal/cctz/time_zone (1.20211102.0):
- abseil/base/config
- abseil/time/internal/cctz/civil_time
- abseil/time/time (1.20211102.0):
- abseil/base/base
- abseil/base/core_headers
- abseil/base/raw_logging_internal
- abseil/numeric/int128
- abseil/strings/strings
- abseil/time/internal/cctz/civil_time
- abseil/time/internal/cctz/time_zone
- abseil/types (1.20211102.0):
- abseil/types/any (= 1.20211102.0)
- abseil/types/bad_any_cast (= 1.20211102.0)
- abseil/types/bad_any_cast_impl (= 1.20211102.0)
- abseil/types/bad_optional_access (= 1.20211102.0)
- abseil/types/bad_variant_access (= 1.20211102.0)
- abseil/types/compare (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/span (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/types/any (1.20211102.0):
- abseil/base/config
- abseil/base/core_headers
- abseil/base/fast_type_id
- abseil/meta/type_traits
- abseil/types/bad_any_cast
- abseil/utility/utility
- abseil/types/bad_any_cast (1.20211102.0):
- abseil/base/config
- abseil/types/bad_any_cast_impl
- abseil/types/bad_any_cast_impl (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/bad_optional_access (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/bad_variant_access (1.20211102.0):
- abseil/base/config
- abseil/base/raw_logging_internal
- abseil/types/compare (1.20211102.0):
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/types/optional (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/memory/memory
- abseil/meta/type_traits
- abseil/types/bad_optional_access
- abseil/utility/utility
- abseil/types/span (1.20211102.0):
- abseil/algorithm/algorithm
- abseil/base/core_headers
- abseil/base/throw_delegate
- abseil/meta/type_traits
- abseil/types/variant (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/base/core_headers
- abseil/meta/type_traits
- abseil/types/bad_variant_access
- abseil/utility/utility
- abseil/utility/utility (1.20211102.0):
- abseil/base/base_internal
- abseil/base/config
- abseil/meta/type_traits
- BoringSSL-GRPC (0.0.24):
- BoringSSL-GRPC/Implementation (= 0.0.24)
- BoringSSL-GRPC/Interface (= 0.0.24)
- BoringSSL-GRPC/Implementation (0.0.24):
- BoringSSL-GRPC/Interface (= 0.0.24)
- BoringSSL-GRPC/Interface (0.0.24)
- cloud_firestore (4.6.0):
- Firebase/Firestore (= 10.7.0)
- firebase_core
- Flutter
- nanopb (< 2.30910.0, >= 2.30908.0)
- Firebase/Auth (10.7.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 10.7.0)
- Firebase/CoreOnly (10.7.0):
- FirebaseCore (= 10.7.0)
- Firebase/Firestore (10.7.0):
- Firebase/CoreOnly
- FirebaseFirestore (~> 10.7.0)
- Firebase/Storage (10.7.0):
- Firebase/CoreOnly
- FirebaseStorage (~> 10.7.0)
- firebase_auth (4.5.0):
- Firebase/Auth (= 10.7.0)
- firebase_core
- Flutter
- firebase_core (2.11.0):
- Firebase/CoreOnly (= 10.7.0)
- Flutter
- firebase_storage (11.1.2):
- Firebase/Storage (= 10.7.0)
- firebase_core
- Flutter
- FirebaseAppCheckInterop (10.9.0)
- FirebaseAuth (10.7.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
- FirebaseAuthInterop (10.9.0)
- FirebaseCore (10.7.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreExtension (10.9.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.9.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseFirestore (10.7.0):
- abseil/algorithm (~> 1.20211102.0)
- abseil/base (~> 1.20211102.0)
- abseil/container/flat_hash_map (~> 1.20211102.0)
- abseil/memory (~> 1.20211102.0)
- abseil/meta (~> 1.20211102.0)
- abseil/strings/strings (~> 1.20211102.0)
- abseil/time (~> 1.20211102.0)
- abseil/types (~> 1.20211102.0)
- FirebaseCore (~> 10.0)
- "gRPC-C++ (~> 1.44.0)"
- leveldb-library (~> 1.22)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseStorage (10.7.0):
- FirebaseAppCheckInterop (~> 10.0)
- FirebaseAuthInterop (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseCoreExtension (~> 10.0)
- GTMSessionFetcher/Core (< 4.0, >= 2.1)
- Flutter (1.0.0)
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.11.1):
- GoogleUtilities/Logger
- "gRPC-C++ (1.44.0)":
- "gRPC-C++/Implementation (= 1.44.0)"
- "gRPC-C++/Interface (= 1.44.0)"
- "gRPC-C++/Implementation (1.44.0)":
- abseil/base/base (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/container/flat_hash_map (= 1.20211102.0)
- abseil/container/inlined_vector (= 1.20211102.0)
- abseil/functional/bind_front (= 1.20211102.0)
- abseil/hash/hash (= 1.20211102.0)
- abseil/memory/memory (= 1.20211102.0)
- abseil/random/random (= 1.20211102.0)
- abseil/status/status (= 1.20211102.0)
- abseil/status/statusor (= 1.20211102.0)
- abseil/strings/cord (= 1.20211102.0)
- abseil/strings/str_format (= 1.20211102.0)
- abseil/strings/strings (= 1.20211102.0)
- abseil/synchronization/synchronization (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/utility/utility (= 1.20211102.0)
- "gRPC-C++/Interface (= 1.44.0)"
- gRPC-Core (= 1.44.0)
- "gRPC-C++/Interface (1.44.0)"
- gRPC-Core (1.44.0):
- gRPC-Core/Implementation (= 1.44.0)
- gRPC-Core/Interface (= 1.44.0)
- gRPC-Core/Implementation (1.44.0):
- abseil/base/base (= 1.20211102.0)
- abseil/base/core_headers (= 1.20211102.0)
- abseil/container/flat_hash_map (= 1.20211102.0)
- abseil/container/inlined_vector (= 1.20211102.0)
- abseil/functional/bind_front (= 1.20211102.0)
- abseil/hash/hash (= 1.20211102.0)
- abseil/memory/memory (= 1.20211102.0)
- abseil/random/random (= 1.20211102.0)
- abseil/status/status (= 1.20211102.0)
- abseil/status/statusor (= 1.20211102.0)
- abseil/strings/cord (= 1.20211102.0)
- abseil/strings/str_format (= 1.20211102.0)
- abseil/strings/strings (= 1.20211102.0)
- abseil/synchronization/synchronization (= 1.20211102.0)
- abseil/time/time (= 1.20211102.0)
- abseil/types/optional (= 1.20211102.0)
- abseil/types/variant (= 1.20211102.0)
- abseil/utility/utility (= 1.20211102.0)
- BoringSSL-GRPC (= 0.0.24)
- gRPC-Core/Interface (= 1.44.0)
- Libuv-gRPC (= 0.0.10)
- gRPC-Core/Interface (1.44.0)
- GTMSessionFetcher/Core (3.1.1)
- image_picker_ios (0.0.1):
- Flutter
- leveldb-library (1.22.2)
- Libuv-gRPC (0.0.10):
- Libuv-gRPC/Implementation (= 0.0.10)
- Libuv-gRPC/Interface (= 0.0.10)
- Libuv-gRPC/Implementation (0.0.10):
- Libuv-gRPC/Interface (= 0.0.10)
- Libuv-gRPC/Interface (0.0.10)
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.2.0)
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
DEPENDENCIES:
- cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`)
- firebase_auth (from `.symlinks/plugins/firebase_auth/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_storage (from `.symlinks/plugins/firebase_storage/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
SPEC REPOS:
trunk:
- abseil
- BoringSSL-GRPC
- Firebase
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseAuthInterop
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseFirestore
- FirebaseStorage
- FMDB
- GoogleUtilities
- "gRPC-C++"
- gRPC-Core
- GTMSessionFetcher
- leveldb-library
- Libuv-gRPC
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
cloud_firestore:
:path: ".symlinks/plugins/cloud_firestore/ios"
firebase_auth:
:path: ".symlinks/plugins/firebase_auth/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_storage:
:path: ".symlinks/plugins/firebase_storage/ios"
Flutter:
:path: Flutter
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
SPEC CHECKSUMS:
abseil: ebe5b5529fb05d93a8bdb7951607be08b7fa71bc
BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33
cloud_firestore: 3809dfc121ab99b81bba51eb84ac6ca3f9033325
Firebase: 0219acf760880eeec8ce479895bd7767466d9f81
firebase_auth: 1eb69de8f57040c5c78bb3267412e9cb395fd08c
firebase_core: dee76ded6c693fdb38b8ea39aef7129e32e587a3
firebase_storage: f6fb3e54e886182b5ab5cf514da1d9ceb196fa2f
FirebaseAppCheckInterop: e69dde5cd51b88ee1b4339d6766b691272256f9b
FirebaseAuth: dd64c01631df724b09f33e584625775c52f7d71f
FirebaseAuthInterop: e53c08e60a02de17d1ab77c5032db8ae22d3a799
FirebaseCore: e317665b9d744727a97e623edbbed009320afdd7
FirebaseCoreExtension: d3e9bba2930a8033042112397cd9f006a1bb203d
FirebaseCoreInternal: d2b4acb827908e72eca47a9fd896767c3053921e
FirebaseFirestore: 3963a6edd1c84b4748dab3e2c62624a29d03eca1
FirebaseStorage: 4841efa304543e1f9e4ca116c559c7a1ea2a9d0f
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
"gRPC-C++": 9675f953ace2b3de7c506039d77be1f2e77a8db2
gRPC-Core: 943e491cb0d45598b0b0eb9e910c88080369290b
GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
leveldb-library: f03246171cce0484482ec291f88b6d563699ee06
Libuv-gRPC: 55e51798e14ef436ad9bc45d12d43b77b49df378
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
PODFILE CHECKSUM: 7d6b9db9243dbc65e9914f56de22b83205a8e90d
COCOAPODS: 1.12.1
+10 -10
View File
@@ -160,7 +160,7 @@
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1300; LastUpgradeCheck = 1510;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
@@ -348,7 +348,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -363,13 +363,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH; DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -436,7 +436,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -486,7 +486,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -503,13 +503,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH; DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -535,13 +535,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4YF7BFHHRH; DEVELOPMENT_TEAM = 4YF7BFHHRH;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
+7 -4
View File
@@ -1,13 +1,16 @@
import UIKit
import Flutter import Flutter
import UIKit
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }
@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
+21
View File
@@ -26,6 +26,27 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
+1 -1
View File
@@ -38,7 +38,7 @@ class MyApp extends StatelessWidget {
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black87, foregroundColor: Colors.black87,
), ),
cardTheme: CardTheme( cardTheme: CardThemeData(
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
+51 -6
View File
@@ -19,17 +19,20 @@ class PostItem {
required this.id, required this.id,
required this.title, required this.title,
required this.description, required this.description,
this.imageUrl = '', String imageUrl = '',
List<String>? imageUrls, List<String>? imageUrls,
required this.createdDate, required this.createdDate,
this.authorUid, this.authorUid,
this.street, this.street,
this.category, this.category,
this.status = 'Gemeldet', this.status,
this.pdfUrl, this.pdfUrl,
this.webUrl, this.webUrl,
this.isAussendung = false, this.isAussendung = false,
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []); }) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []),
imageUrl = imageUrl.isNotEmpty
? imageUrl
: (imageUrls != null && imageUrls.isNotEmpty ? imageUrls.first : '');
factory PostItem.fromFirestore(DocumentSnapshot doc) { factory PostItem.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {}; final data = doc.data() as Map<String, dynamic>? ?? {};
@@ -49,6 +52,9 @@ class PostItem {
images = [legacyImage]; images = [legacyImage];
} }
final hasIssueFields = data['street'] != null || data['category'] != null;
final status = data['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
return PostItem( return PostItem(
id: doc.id, id: doc.id,
title: data['title'] as String? ?? '', title: data['title'] as String? ?? '',
@@ -59,7 +65,7 @@ class PostItem {
authorUid: data['author_uid'] as String?, authorUid: data['author_uid'] as String?,
street: data['street'] as String?, street: data['street'] as String?,
category: data['category'] as String?, category: data['category'] as String?,
status: data['status'] as String? ?? 'Gemeldet', status: status,
pdfUrl: data['pdfUrl'] as String?, pdfUrl: data['pdfUrl'] as String?,
webUrl: data['webUrl'] as String?, webUrl: data['webUrl'] as String?,
isAussendung: data['isAussendung'] as bool? ?? false, isAussendung: data['isAussendung'] as bool? ?? false,
@@ -119,17 +125,23 @@ class PostItem {
images = [legacyImage]; images = [legacyImage];
} }
final hasIssueFields = map['street'] != null || map['category'] != null;
final status =
map['status'] as String? ?? (hasIssueFields ? 'Gemeldet' : null);
return PostItem( return PostItem(
id: map['id'] as String? ?? '', id: map['id'] as String? ?? '',
title: map['title'] as String? ?? '', title: map['title'] as String? ?? '',
description: map['description'] 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, imageUrls: images,
createdDate: date, createdDate: date,
authorUid: map['authorUid'] as String?, authorUid: map['authorUid'] as String?,
street: map['street'] as String?, street: map['street'] as String?,
category: map['category'] as String?, category: map['category'] as String?,
status: map['status'] as String? ?? 'Gemeldet', status: status,
pdfUrl: map['pdfUrl'] as String?, pdfUrl: map['pdfUrl'] as String?,
webUrl: map['webUrl'] as String?, webUrl: map['webUrl'] as String?,
isAussendung: map['isAussendung'] as bool? ?? false, isAussendung: map['isAussendung'] as bool? ?? false,
@@ -236,4 +248,37 @@ class PostItem {
isAussendung: true, isAussendung: true,
); );
} }
/// Create a copy of this PostItem with updated fields
PostItem copyWith({
String? id,
String? title,
String? description,
String? imageUrl,
List<String>? imageUrls,
DateTime? createdDate,
String? authorUid,
String? street,
String? category,
String? status,
String? pdfUrl,
String? webUrl,
bool? isAussendung,
}) {
return PostItem(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
imageUrl: imageUrl ?? this.imageUrl,
imageUrls: imageUrls ?? this.imageUrls,
createdDate: createdDate ?? this.createdDate,
authorUid: authorUid ?? this.authorUid,
street: street ?? this.street,
category: category ?? this.category,
status: status ?? this.status,
pdfUrl: pdfUrl ?? this.pdfUrl,
webUrl: webUrl ?? this.webUrl,
isAussendung: isAussendung ?? this.isAussendung,
);
}
} }
+192 -13
View File
@@ -1,13 +1,18 @@
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart'; import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart'; import '../services/firestore_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
class AddIssueScreen extends StatefulWidget { class AddIssueScreen extends StatefulWidget {
const AddIssueScreen({super.key}); final PostItem? issueToEdit;
const AddIssueScreen({super.key, this.issueToEdit});
@override @override
State<AddIssueScreen> createState() => _AddIssueScreenState(); State<AddIssueScreen> createState() => _AddIssueScreenState();
@@ -22,8 +27,18 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
final StorageService _storageService = StorageService(); final StorageService _storageService = StorageService();
final List<File> _selectedImages = []; final List<File> _selectedImages = [];
List<String> _existingImageUrls = [];
String _selectedStatus = 'Gemeldet';
bool _isSaving = false; bool _isSaving = false;
bool get isEditing => widget.issueToEdit != null;
final List<String> _statuses = [
'Gemeldet',
'In Bearbeitung',
'Erledigt',
];
final List<String> _streets = [ final List<String> _streets = [
'Hauptstraße', 'Hauptstraße',
'Seestraße', 'Seestraße',
@@ -56,9 +71,32 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
@override @override
void initState() { void initState() {
super.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; _selectedStreet = _streets.first;
_selectedCategory = _categories.first; _selectedCategory = _categories.first;
} }
}
@override @override
void dispose() { void dispose() {
@@ -68,9 +106,10 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
} }
Future<void> _pickImage(ImageSource source) async { Future<void> _pickImage(ImageSource source) async {
if (_selectedImages.length >= 2) { if (_existingImageUrls.length + _selectedImages.length >= 2) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@@ -99,6 +138,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
void _showImageSourceModal() { void _showImageSourceModal() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
@@ -135,6 +175,51 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
}); });
try { 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; final currentUserId = FirebaseAuth.instance.currentUser?.uid;
// Upload selected images to Firebase Storage // Upload selected images to Firebase Storage
@@ -171,7 +256,8 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
Navigator.pop(context); Navigator.pop(context, newIssue);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -191,12 +277,21 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context, listen: false);
final isAdmin = auth.isAdmin;
final totalPhotos = _existingImageUrls.length + _selectedImages.length;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Mangel melden'), title: Text(isEditing ? 'Mangel bearbeiten' : 'Mangel melden'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -215,7 +310,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedStreet, initialValue: _selectedStreet,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Straße / Örtlichkeit *', labelText: 'Straße / Örtlichkeit *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -232,7 +327,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedCategory, initialValue: _selectedCategory,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Grundlage / Kategorie *', labelText: 'Grundlage / Kategorie *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -245,6 +340,26 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
.toList(), .toList(),
onChanged: (val) => setState(() => _selectedCategory = val), 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), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _descriptionController, controller: _descriptionController,
@@ -265,8 +380,60 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [ 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) { ..._selectedImages.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final file = entry.value; final file = entry.value;
@@ -288,8 +455,14 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
Positioned( Positioned(
top: -4, top: -4,
right: 4, right: 4,
child: CircleAvatar(
radius: 14,
backgroundColor: Colors.black54,
child: IconButton( child: IconButton(
icon: const Icon(Icons.cancel, color: Colors.red), padding: EdgeInsets.zero,
icon: const Icon(Icons.close,
color: Colors.white, size: 16),
tooltip: 'Foto entfernen',
onPressed: () { onPressed: () {
setState(() { setState(() {
_selectedImages.removeAt(index); _selectedImages.removeAt(index);
@@ -297,10 +470,11 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
}, },
), ),
), ),
),
], ],
); );
}), }),
if (_selectedImages.length < 2) if (totalPhotos < 2)
InkWell( InkWell(
onTap: _showImageSourceModal, onTap: _showImageSourceModal,
child: Container( child: Container(
@@ -327,6 +501,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
), ),
], ],
), ),
),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -340,9 +515,13 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white), strokeWidth: 2, color: Colors.white),
) )
: const Icon(Icons.check_circle_outline), : Icon(isEditing ? Icons.save : Icons.check_circle_outline),
label: Text( label: Text(
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen', _isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Mangel fertigstellen'),
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
), ),
+143 -8
View File
@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -9,11 +10,13 @@ import '../services/storage_service.dart';
class AddPostScreen extends StatefulWidget { class AddPostScreen extends StatefulWidget {
final String collectionName; final String collectionName;
final String screenTitle; final String screenTitle;
final PostItem? postToEdit;
const AddPostScreen({ const AddPostScreen({
super.key, super.key,
required this.collectionName, required this.collectionName,
required this.screenTitle, required this.screenTitle,
this.postToEdit,
}); });
@override @override
@@ -29,8 +32,25 @@ class _AddPostScreenState extends State<AddPostScreen> {
final StorageService _storageService = StorageService(); final StorageService _storageService = StorageService();
File? _selectedImage; File? _selectedImage;
String? _existingImageUrl;
bool _isSaving = false; 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 @override
void dispose() { void dispose() {
_titleController.dispose(); _titleController.dispose();
@@ -63,6 +83,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
void _showImageSourceModal() { void _showImageSourceModal() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
@@ -99,6 +120,52 @@ class _AddPostScreenState extends State<AddPostScreen> {
}); });
try { 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; final currentUserId = FirebaseAuth.instance.currentUser?.uid;
String imageUrl = ''; String imageUrl = '';
@@ -130,7 +197,8 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
Navigator.pop(context); Navigator.pop(context, newPost);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -152,10 +220,15 @@ class _AddPostScreenState extends State<AddPostScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.screenTitle), title: Text(isEditing ? 'Beitrag bearbeiten' : widget.screenTitle),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -212,12 +285,68 @@ class _AddPostScreenState extends State<AddPostScreen> {
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close, color: Colors.white), 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 else
InkWell( InkWell(
onTap: _showImageSourceModal, onTap: _showImageSourceModal,
@@ -232,9 +361,11 @@ class _AddPostScreenState extends State<AddPostScreen> {
child: const Column( child: const Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey), Icon(Icons.add_photo_alternate,
size: 36, color: Colors.grey),
SizedBox(height: 6), SizedBox(height: 6),
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)), Text('Foto hinzufügen',
style: TextStyle(color: Colors.black54)),
], ],
), ),
), ),
@@ -252,9 +383,13 @@ class _AddPostScreenState extends State<AddPostScreen> {
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white), strokeWidth: 2, color: Colors.white),
) )
: const Icon(Icons.send), : Icon(isEditing ? Icons.save : Icons.send),
label: Text( label: Text(
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen', _isSaving
? 'Wird gespeichert...'
: (isEditing
? 'Änderungen speichern'
: 'Veröffentlichen'),
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
), ),
+16 -6
View File
@@ -89,12 +89,18 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (ctx, setModalState) => Padding( builder: (ctx, setModalState) {
final bottomInset = MediaQuery.viewPaddingOf(ctx).bottom > 0
? MediaQuery.viewPaddingOf(ctx).bottom
: MediaQuery.paddingOf(ctx).bottom;
final keyboardInset = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 20, left: 20,
right: 20, right: 20,
top: 20, top: 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, bottom: bottomInset + keyboardInset + 20,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
@@ -269,10 +275,11 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
], ],
), ),
), ),
), );
},
), ),
); );
} }
Widget _buildStatusButton( Widget _buildStatusButton(
String docId, String docId,
@@ -284,7 +291,7 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
return Expanded( return Expanded(
child: OutlinedButton( child: OutlinedButton(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
backgroundColor: isCurrent ? color.withOpacity(0.15) : null, backgroundColor: isCurrent ? color.withValues(alpha: 0.15) : null,
side: BorderSide( side: BorderSide(
color: isCurrent ? color : Colors.grey.shade300, color: isCurrent ? color : Colors.grey.shade300,
width: isCurrent ? 2 : 1, width: isCurrent ? 2 : 1,
@@ -332,7 +339,7 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: fg.withOpacity(0.3)), border: Border.all(color: fg.withValues(alpha: 0.3)),
), ),
child: Text( child: Text(
status, status,
@@ -495,6 +502,9 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
} }
return ListView.separated( return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredDocs.length, itemCount: filteredDocs.length,
separatorBuilder: (ctx, i) => const Divider(height: 1), separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
+1 -1
View File
@@ -291,7 +291,7 @@ class _AuthDialogState extends State<AuthDialog>
child: Image.asset( child: Image.asset(
'assets/images/logo.png', 'assets/images/logo.png',
height: 28, height: 28,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, _, _) => const Icon(
Icons.campaign, Icons.campaign,
color: Colors.blue, color: Colors.blue,
), ),
+277 -53
View File
@@ -1,42 +1,177 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart'; import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_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'; import 'pdf_viewer_screen.dart';
class DetailScreen extends StatelessWidget { class DetailScreen extends StatefulWidget {
final PostItem item; final PostItem item;
final String? collectionName;
const DetailScreen({super.key, required this.item}); const DetailScreen({
super.key,
required this.item,
this.collectionName,
});
@override
State<DetailScreen> createState() => _DetailScreenState();
}
class _DetailScreenState extends State<DetailScreen> {
late PostItem _item;
int _currentImageIndex = 0;
final PageController _pageController = PageController();
@override
void initState() {
super.initState();
_item = widget.item;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _editItem() async {
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'CitizensForum');
PostItem? updated;
if (collection == 'Issues' || _item.street != null) {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddIssueScreen(issueToEdit: _item),
),
);
} else {
updated = await Navigator.push<PostItem>(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: collection,
screenTitle: 'Beitrag bearbeiten',
postToEdit: _item,
),
),
);
}
if (updated != null && mounted) {
setState(() {
_item = updated!;
});
}
}
Future<void> _deleteItem() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed != true || !mounted) return;
final collection = widget.collectionName ??
(_item.street != null ? 'Issues' : 'News');
try {
final storage = StorageService();
final firestore = FirestoreService();
for (final url in _item.imageUrls) {
try {
await storage.deleteFileByUrl(url);
} catch (_) {}
}
await firestore.deletePost(collection, _item.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Eintrag gelöscht.')),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Löschen: $e')),
);
}
}
@override @override
Widget build(BuildContext context) { 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 = final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate); DateFormat('dd.MM.yyyy HH:mm').format(_item.createdDate);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(item.title, overflow: TextOverflow.ellipsis), title: Text(_item.title, overflow: TextOverflow.ellipsis),
actions: [ 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( IconButton(
icon: const Icon(Icons.picture_as_pdf), icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Als PDF drucken / teilen', tooltip: 'Als PDF drucken / teilen',
onPressed: () => PdfService.printSinglePost(item), onPressed: () => PdfService.printSinglePost(_item),
), ),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (item.imageUrls.isNotEmpty) if (_item.imageUrls.isNotEmpty) ...[
if (_item.imageUrls.length == 1)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: item.imageUrls.first, imageUrl: _item.imageUrls.first,
width: double.infinity, width: double.infinity,
height: 240,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => const Center( placeholder: (context, url) => const Center(
child: Padding( child: Padding(
@@ -45,15 +180,117 @@ class DetailScreen extends StatelessWidget {
), ),
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
height: 180, height: 200,
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48), child: const Icon(Icons.image_not_supported, size: 48),
), ),
), ),
)
else
Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 260,
width: double.infinity,
child: PageView.builder(
controller: _pageController,
itemCount: _item.imageUrls.length,
onPageChanged: (index) {
setState(() {
_currentImageIndex = index;
});
},
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: _item.imageUrls[index],
width: double.infinity,
height: 260,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 260,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported,
size: 48),
),
);
},
),
),
),
// Page indicator dots
Positioned(
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
_item.imageUrls.length,
(i) => Container(
margin:
const EdgeInsets.symmetric(horizontal: 3),
width: _currentImageIndex == i ? 16 : 6,
height: 6,
decoration: BoxDecoration(
color: _currentImageIndex == i
? Colors.white
: Colors.white54,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
),
// Photo counter badge (e.g. 1 / 2)
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library,
color: Colors.white, size: 14),
const SizedBox(width: 4),
Text(
'${_currentImageIndex + 1} / ${_item.imageUrls.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
],
Text( Text(
item.title, _item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -65,48 +302,63 @@ class DetailScreen extends StatelessWidget {
formattedDate, formattedDate,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700), style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
), ),
if (item.status != null) ...[ if (isIssue && _item.status != null) ...[
const Spacer(), const Spacer(),
Container( Container(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 10, vertical: 4), horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.shade50, color: _item.status == 'Erledigt'
? Colors.green.shade50
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200), border: Border.all(
color: _item.status == 'Erledigt'
? Colors.green.shade200
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade200
: Colors.orange.shade200),
),
), ),
child: Text( child: Text(
item.status!, _item.status!,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.blue.shade800), color: _item.status == 'Erledigt'
? Colors.green.shade800
: (_item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
), ),
), ),
], ],
], ],
), ),
if (item.street != null && item.street!.isNotEmpty) ...[ if (_item.street != null && _item.street!.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
children: [ children: [
const Icon(Icons.location_on, size: 16, color: Colors.redAccent), const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Örtlichkeit: ${item.street}', 'Örtlichkeit: ${_item.street}',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
], ],
), ),
], ],
if (item.category != null && item.category!.isNotEmpty) ...[ if (_item.category != null && _item.category!.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
children: [ children: [
const Icon(Icons.category, size: 16, color: Colors.orange), const Icon(Icons.category, size: 16, color: Colors.orange),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Kategorie: ${item.category}', 'Kategorie: ${_item.category}',
style: TextStyle(color: Colors.grey.shade800), style: TextStyle(color: Colors.grey.shade800),
), ),
], ],
@@ -121,10 +373,10 @@ class DetailScreen extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item.description, _item.description,
style: const TextStyle(fontSize: 15, height: 1.5), 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), const SizedBox(height: 16),
Card( Card(
color: Colors.red.shade50, color: Colors.red.shade50,
@@ -150,8 +402,8 @@ class DetailScreen extends StatelessWidget {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (ctx) => PdfViewerScreen( builder: (ctx) => PdfViewerScreen(
title: item.title, title: _item.title,
pdfUrl: item.pdfUrl!, pdfUrl: _item.pdfUrl!,
), ),
), ),
); );
@@ -160,34 +412,6 @@ class DetailScreen extends StatelessWidget {
), ),
], ],
const SizedBox(height: 24), 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,
),
);
},
),
),
],
], ],
), ),
), ),
+68 -86
View File
@@ -19,37 +19,63 @@ class PdfViewerScreen extends StatefulWidget {
State<PdfViewerScreen> createState() => _PdfViewerScreenState(); State<PdfViewerScreen> createState() => _PdfViewerScreenState();
} }
class _PdfViewerScreenState extends State<PdfViewerScreen> { class _PdfViewerScreenState extends State<PdfViewerScreen>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController = final TransformationController _transformationController =
TransformationController(); TransformationController();
double _zoomLevel = 1.0; late AnimationController _animationController;
Animation<Matrix4>? _zoomAnimation;
TapDownDetails? _doubleTapDetails;
Uint8List? _cachedBytes; 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 @override
void dispose() { void dispose() {
_animationController.dispose();
_transformationController.dispose(); _transformationController.dispose();
super.dispose(); super.dispose();
} }
void _zoomIn() { void _handleDoubleTap() {
setState(() { if (_animationController.isAnimating) return;
_zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.identity()..scale(_zoomLevel); 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 _zoomOut() { _zoomAnimation = Matrix4Tween(
setState(() { begin: currentMatrix,
_zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0); end: targetMatrix,
_transformationController.value = Matrix4.identity()..scale(_zoomLevel); ).animate(CurvedAnimation(
}); parent: _animationController,
} curve: Curves.easeOutCubic,
));
void _resetZoom() { _animationController.forward(from: 0);
setState(() {
_zoomLevel = 1.0;
_transformationController.value = Matrix4.identity();
});
} }
Future<void> _openExternal() async { Future<void> _openExternal() async {
@@ -110,9 +136,9 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
), ),
], ],
), ),
body: Stack( body: SafeArea(
children: [ top: false,
PdfPreview.builder( child: PdfPreview.builder(
build: (PdfPageFormat format) async { build: (PdfPageFormat format) async {
if (_cachedBytes != null) return _cachedBytes!; if (_cachedBytes != null) return _cachedBytes!;
final response = await http final response = await http
@@ -166,25 +192,27 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
), ),
), ),
pagesBuilder: (context, pages) { pagesBuilder: (context, pages) {
return InteractiveViewer( return LayoutBuilder(
builder: (context, constraints) {
final viewportWidth = constraints.maxWidth;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 0.8, minScale: 1.0,
maxScale: 5.0, maxScale: 5.0,
panEnabled: true, panEnabled: true,
scaleEnabled: true, scaleEnabled: true,
onInteractionEnd: (details) { constrained: false,
final scale = boundaryMargin: const EdgeInsets.symmetric(
_transformationController.value.getMaxScaleOnAxis(); horizontal: 48, vertical: 80),
if (scale != _zoomLevel) { child: SizedBox(
setState(() { width: viewportWidth,
_zoomLevel = scale; child: Padding(
});
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 16.0), horizontal: 8.0, vertical: 16.0),
child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -196,7 +224,8 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.15), color:
Colors.black.withValues(alpha: 0.15),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 3), offset: const Offset(0, 3),
), ),
@@ -224,59 +253,12 @@ 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,
),
],
),
),
),
),
],
), ),
); );
} }
+156 -3
View File
@@ -129,6 +129,30 @@ 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 { Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(context, listen: false); final auth = Provider.of<AuthService>(context, listen: false);
@@ -402,8 +426,11 @@ class _PostListScreenState extends State<PostListScreen> {
final isAuthor = final isAuthor =
currentUser != null && item.authorUid == currentUser.uid; currentUser != null && item.authorUid == currentUser.uid;
final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin); final canDelete = !item.isAussendung && (isAuthor || auth.isAdmin);
final canEdit = !item.isAussendung && (isAuthor || auth.isAdmin);
final formattedDate = final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate); DateFormat('dd.MM.yyyy').format(item.createdDate);
final isIssue =
widget.collectionName == 'Issues' || item.street != null;
Widget? leadingWidget; Widget? leadingWidget;
if (item.imageUrl.isNotEmpty) { if (item.imageUrl.isNotEmpty) {
@@ -525,7 +552,10 @@ class _PostListScreenState extends State<PostListScreen> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (ctx) => DetailScreen(item: item), builder: (ctx) => DetailScreen(
item: item,
collectionName: widget.collectionName,
),
), ),
); );
} }
@@ -535,7 +565,73 @@ class _PostListScreenState extends State<PostListScreen> {
trailing: item.isAussendung trailing: item.isAussendung
? Icon(Icons.arrow_forward_ios, ? Icon(Icons.arrow_forward_ios,
size: 14, color: Colors.grey.shade400) size: 14, color: Colors.grey.shade400)
: null, : (_selectedItems.isNotEmpty
? null
: ((canEdit || canDelete)
? PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black54),
tooltip: 'Optionen',
onSelected: (value) async {
if (value == 'edit') {
_editPost(item);
} else if (value == 'delete') {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red),
onPressed: () =>
Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed == true) {
_deletePost(item);
}
}
},
itemBuilder: (ctx) => [
if (canEdit)
const PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 20),
SizedBox(width: 8),
Text('Bearbeiten'),
],
),
),
if (canDelete)
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 20, color: Colors.red),
SizedBox(width: 8),
Text('Löschen',
style:
TextStyle(color: Colors.red)),
],
),
),
],
)
: null)),
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -563,7 +659,64 @@ class _PostListScreenState extends State<PostListScreen> {
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (item.street != null && item.street!.isNotEmpty) ...[ if (isIssue && item.status != null && item.status!.isNotEmpty) ...[
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.status == 'Erledigt'
? Colors.green.shade50
: (item.status == 'In Bearbeitung'
? Colors.blue.shade50
: Colors.orange.shade50),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: item.status == 'Erledigt'
? Colors.green.shade300
: (item.status == 'In Bearbeitung'
? Colors.blue.shade300
: Colors.orange.shade300),
),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: item.status == 'Erledigt'
? Colors.green.shade800
: (item.status == 'In Bearbeitung'
? Colors.blue.shade800
: Colors.orange.shade900),
),
),
),
if (item.street != null && item.street!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 12, color: Colors.redAccent),
const SizedBox(width: 2),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
),
] else if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Row(
children: [ children: [
+3
View File
@@ -365,6 +365,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> {
} }
return ListView.separated( return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredUsers.length, itemCount: filteredUsers.length,
separatorBuilder: (ctx, i) => const Divider(height: 1), separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
+10 -2
View File
@@ -43,6 +43,14 @@ class FirestoreService {
await _firestore.collection(collectionName).doc(documentId).delete(); 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 /// Send a contact form submission to Firestore
Future<void> submitContactMessage({ Future<void> submitContactMessage({
required String firstName, required String firstName,
@@ -118,8 +126,8 @@ class FirestoreService {
'firstName': firstName, 'firstName': firstName,
if (lastName != null && lastName.isNotEmpty) if (lastName != null && lastName.isNotEmpty)
'lastName': lastName, 'lastName': lastName,
if (emailVerified != null) 'emailVerified': emailVerified, 'emailVerified': ?emailVerified,
if (isAdmin != null) 'isAdmin': isAdmin, 'isAdmin': ?isAdmin,
}; };
if (!doc.exists) { if (!doc.exists) {
+102 -14
View File
@@ -35,8 +35,15 @@ class PdfService {
/// Print single post item as PDF /// Print single post item as PDF
static Future<void> printSinglePost(PostItem item) async { static Future<void> printSinglePost(PostItem item) async {
final pdf = pw.Document(); final pdf = pw.Document();
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate); final formattedDate =
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl); DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
final List<String> urlsToFetch = item.imageUrls.isNotEmpty
? item.imageUrls
: (item.imageUrl.isNotEmpty ? [item.imageUrl] : []);
final results =
await Future.wait(urlsToFetch.map((url) => _fetchImage(url)));
final imageProviders = results.whereType<pw.ImageProvider>().toList();
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
@@ -58,7 +65,8 @@ class PdfService {
), ),
pw.Text( pw.Text(
formattedDate, formattedDate,
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700), style:
const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
), ),
], ],
), ),
@@ -68,6 +76,53 @@ class PdfService {
item.title, item.title,
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold), 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) ...[ if (item.street != null && item.street!.isNotEmpty) ...[
pw.SizedBox(height: 6), pw.SizedBox(height: 6),
pw.Text( pw.Text(
@@ -84,12 +139,28 @@ class PdfService {
], ],
pw.Divider(thickness: 1, color: PdfColors.grey400), pw.Divider(thickness: 1, color: PdfColors.grey400),
pw.SizedBox(height: 10), pw.SizedBox(height: 10),
if (imageProvider != null) ...[ if (imageProviders.isNotEmpty) ...[
if (imageProviders.length == 1)
pw.Center( pw.Center(
child: pw.Container( child: pw.Container(
height: 220, height: 220,
child: pw.Image(imageProvider, fit: pw.BoxFit.contain), child: pw.Image(imageProviders.first, fit: pw.BoxFit.contain),
), ),
)
else
pw.Row(
children: [
for (int i = 0; i < imageProviders.length; i++) ...[
if (i > 0) pw.SizedBox(width: 12),
pw.Expanded(
child: pw.Container(
height: 180,
child: pw.Image(imageProviders[i],
fit: pw.BoxFit.contain),
),
),
],
],
), ),
pw.SizedBox(height: 14), pw.SizedBox(height: 14),
], ],
@@ -123,8 +194,10 @@ class PdfService {
// Preload all images in parallel // Preload all images in parallel
final Map<String, pw.ImageProvider?> imageMap = {}; final Map<String, pw.ImageProvider?> imageMap = {};
await Future.wait(items.map((item) async { await Future.wait(items.map((item) async {
if (item.imageUrl.isNotEmpty) { final url =
imageMap[item.id] = await _fetchImage(item.imageUrl); item.imageUrls.isNotEmpty ? item.imageUrls.first : item.imageUrl;
if (url.isNotEmpty) {
imageMap[item.id] = await _fetchImage(url);
} }
})); }));
@@ -137,7 +210,8 @@ class PdfService {
margin: const pw.EdgeInsets.only(bottom: 12), margin: const pw.EdgeInsets.only(bottom: 12),
padding: const pw.EdgeInsets.only(bottom: 6), padding: const pw.EdgeInsets.only(bottom: 6),
decoration: const pw.BoxDecoration( 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( child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
@@ -152,7 +226,8 @@ class PdfService {
), ),
pw.Text( pw.Text(
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}', 'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), style:
const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
), ),
], ],
), ),
@@ -160,7 +235,8 @@ class PdfService {
build: (context) => [ build: (context) => [
pw.TableHelper.fromTextArray( pw.TableHelper.fromTextArray(
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), 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), headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellHeight: 65, cellHeight: 65,
cellAlignments: { cellAlignments: {
@@ -170,20 +246,32 @@ class PdfService {
3: pw.Alignment.centerLeft, 3: pw.Alignment.centerLeft,
4: pw.Alignment.center, 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) { data: items.map((item) {
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate); final formattedDate =
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})'; DateFormat('dd.MM.yyyy').format(item.createdDate);
final locInfo =
'${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
final descSnippet = item.description.length > 120 final descSnippet = item.description.length > 120
? '${item.description.substring(0, 120)}...' ? '${item.description.substring(0, 120)}...'
: item.description; : item.description;
final imageCount = item.imageUrls.length;
final imageLabel = imageMap[item.id] != null
? (imageCount > 1 ? '$imageCount Fotos' : 'Foto vorhanden')
: '-';
return [ return [
formattedDate, formattedDate,
locInfo, locInfo,
'${item.title}\n$descSnippet', '${item.title}\n$descSnippet',
item.status ?? 'Gemeldet', item.status ?? 'Gemeldet',
imageMap[item.id] != null ? 'Foto vorhanden' : '-', imageLabel,
]; ];
}).toList(), }).toList(),
), ),
+259 -227
View File
File diff suppressed because it is too large Load Diff
+34 -82
View File
@@ -1,109 +1,61 @@
name: oggauergemeindetrommler name: oggauergemeindetrommler
description: Gemeinde Trommler description: Gemeinde Trommler
# Private app – never publish to pub.dev.
publish_to: 'none'
# The following line prevents the package from being accidentally published to # Version + build number (build-name+build-number). Can be overridden with
# pub.dev using `flutter pub publish`. This is preferred for private packages. # `flutter build ... --build-name --build-number`.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.0.1 version: 0.0.1
environment: environment:
sdk: '>=3.0.0 <4.0.0' # Flutter 3.47.x ships Dart 3.13; keep the constraint in sync with the
# Flutter version pinned in the CI workflows (FLUTTER_VERSION).
sdk: ^3.11.0
flutter: '>=3.47.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# Firebase (Auth, Firestore, Storage)
firebase_core: ^4.15.0
firebase_auth: ^6.7.0
cloud_firestore: ^6.10.0
firebase_storage: ^13.6.0
# UI / state
provider: ^6.1.5
cupertino_icons: ^1.0.8
cached_network_image: ^3.4.1
intl: ^0.20.2 intl: ^0.20.2
# Media
image_picker: ^1.2.3
image: ^4.5.4
# PDF export & sharing
pdf: ^3.11.3
printing: ^5.14.3
# The following adds the Cupertino Icons font to your application. # Misc
# Use with the CupertinoIcons class for iOS style icons. http: ^1.2.2
cupertino_icons: ^1.0.2 shared_preferences: ^2.5.3
image_picker: ^1.1.2
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 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^4.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true uses-material-design: true
assets: assets:
- assets/images/ - 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,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:oggauergemeindetrommler/models/post_item.dart'; import 'package:oggauergemeindetrommler/models/post_item.dart';
@@ -13,6 +14,7 @@ void main() {
createdDate: now, createdDate: now,
street: 'Hauptstraße', street: 'Hauptstraße',
category: 'Straßenbeleuchtung', category: 'Straßenbeleuchtung',
status: 'Gemeldet',
); );
expect(item.id, 'test-123'); expect(item.id, 'test-123');
@@ -23,6 +25,46 @@ void main() {
expect(item.status, 'Gemeldet'); 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', () { test('serializes to Firestore map accurately', () {
final now = DateTime.now(); final now = DateTime.now();
final item = PostItem( final item = PostItem(
@@ -167,4 +209,172 @@ void main() {
expect(canDemote('current_admin', 'current_admin'), isFalse); 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);
});
});
} }