Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fe4ce6c4 | ||
|
|
cfb68fd8bb | ||
|
|
9fd2b2aa4b | ||
|
|
2854afdd22 | ||
|
|
02c8189ada | ||
|
|
588309649c | ||
|
|
e9b91e5452 | ||
|
|
d84b0002ac | ||
|
|
e6ce9c5012 |
@@ -0,0 +1,79 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- 'refactor/**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
name: Build Release APKs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.19.3
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Git Safe Directory
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
|
||||||
|
- name: Make Gradlew Executable
|
||||||
|
run: chmod +x android/gradlew
|
||||||
|
|
||||||
|
- name: Inspect Build Environment
|
||||||
|
run: |
|
||||||
|
flutter --version
|
||||||
|
java -version
|
||||||
|
|
||||||
|
- name: Fetch Flutter Dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Run Code Analysis & Tests
|
||||||
|
run: |
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
|
||||||
|
- name: Build Universal Release APK
|
||||||
|
run: flutter build apk --release
|
||||||
|
|
||||||
|
- name: Build Split-per-ABI Release APKs
|
||||||
|
run: flutter build apk --release --split-per-abi
|
||||||
|
|
||||||
|
- name: Organize Artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-apks
|
||||||
|
# Copy universal APK
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
|
||||||
|
fi
|
||||||
|
# Copy split APKs
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||||
|
fi
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||||
|
fi
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||||
|
fi
|
||||||
|
echo "=== Generated APK Artifacts ==="
|
||||||
|
ls -lah release-apks/
|
||||||
|
|
||||||
|
- name: Upload APKs to Gitea Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-apks
|
||||||
|
path: release-apks/*.apk
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- 'refactor/**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
name: Build Release APKs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.19.3
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure Git Safe Directory
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
|
||||||
|
- name: Make Gradlew Executable
|
||||||
|
run: chmod +x android/gradlew
|
||||||
|
|
||||||
|
- name: Inspect Build Environment
|
||||||
|
run: |
|
||||||
|
flutter --version
|
||||||
|
java -version
|
||||||
|
|
||||||
|
- name: Fetch Flutter Dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Run Code Analysis & Tests
|
||||||
|
run: |
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
|
||||||
|
- name: Build Universal Release APK
|
||||||
|
run: flutter build apk --release
|
||||||
|
|
||||||
|
- name: Build Split-per-ABI Release APKs
|
||||||
|
run: flutter build apk --release --split-per-abi
|
||||||
|
|
||||||
|
- name: Organize Artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-apks
|
||||||
|
# Copy universal APK
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-release.apk release-apks/oggauer-gemeindetrommler-universal-release.apk
|
||||||
|
fi
|
||||||
|
# Copy split APKs
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk release-apks/oggauer-gemeindetrommler-arm64-v8a.apk
|
||||||
|
fi
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk release-apks/oggauer-gemeindetrommler-armeabi-v7a.apk
|
||||||
|
fi
|
||||||
|
if [ -f build/app/outputs/flutter-apk/app-x86_64-release.apk ]; then
|
||||||
|
cp build/app/outputs/flutter-apk/app-x86_64-release.apk release-apks/oggauer-gemeindetrommler-x86_64.apk
|
||||||
|
fi
|
||||||
|
echo "=== Generated APK Artifacts ==="
|
||||||
|
ls -lah release-apks/
|
||||||
|
|
||||||
|
- name: Upload APKs to Gitea Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: oggauer-gemeindetrommler-apks
|
||||||
|
path: release-apks/*.apk
|
||||||
|
retention-days: 30
|
||||||
@@ -42,3 +42,5 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/app/build/
|
||||||
|
.gradle/
|
||||||
|
|||||||
@@ -1,92 +1,136 @@
|
|||||||
# DemoApp_1
|
# Oggauer Gemeindetrommler 🥁
|
||||||
|
|
||||||
|
Die **Oggauer Gemeindetrommler App** ist eine mobile Anwendung (Flutter für Android & iOS) für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See. Sie dient als digitale Plattform für Gemeinde-Nachrichten, Bürgeranliegen (Mängelmeldungen mit Foto-Upload) sowie den Austausch im Bürgerforum bzw. die Einbringung von Gemeinde-Ideen inklusive PDF-Exportfunktionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting started
|
## 📱 Aktueller Funktionsumfang (Ist-Zustand)
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
Die App wurde in ihrer Erstversion mit Unterstützung von KI (frühe GPT-Modelle) entwickelt und nutzt Firebase als Backend.
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
### 1. Neuigkeiten (`News`)
|
||||||
|
- Liste aktueller Berichte und Neuigkeiten aus der Gemeinde mit Datum, Überschrift, Textvorschau und Beitragsbild.
|
||||||
|
- Detailansicht mit vollständigem Text und großem Beitragsbild.
|
||||||
|
- PDF-Export: Sowohl Einzelbeiträge als auch eine Mehrfachauswahl können direkt als PDF generiert und geteilt werden.
|
||||||
|
|
||||||
## Add your files
|
### 2. Mängelübersicht & Mängelmeldung (`Issues`)
|
||||||
|
- Übersicht über gemeldete Mängel im Ortsgebiet (chronologisch sortiert).
|
||||||
|
- Erfassung neuer Mängel mit Titel, Beschreibungstext und Foto (Kamera oder Galerie).
|
||||||
|
- Automatischer Zuschnitt (1:1 Quadrat) und Größenvalidierung (< 10 MB).
|
||||||
|
- Eigene Meldungen können per Wischgeste (Swipe-to-Delete) entfernt werden.
|
||||||
|
- Mehrfachauswahl von Mängeln mit Sammel-PDF-Export (z. B. zur Weiterleitung an den Bauhof).
|
||||||
|
|
||||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
### 3. BürgerForum / Ideen (`CitizensForum`)
|
||||||
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
|
- Plattform zur Einbringung von Anregungen und Vorschlägen für das Gemeindeleben.
|
||||||
|
- Nutzt aktuell den gleichen Erfassungs- und PDF-Export-Workflow wie bei den Mängeln.
|
||||||
|
|
||||||
```
|
### 4. Authentifizierung (`AuthState`)
|
||||||
cd existing_repo
|
- Anonyme Firebase-Authentifizierung (`FirebaseAuth.signInAnonymously`).
|
||||||
git remote add origin https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1.git
|
- Lesemodus für Gäste; Erstellen und Löschen eigener Beiträge ist an den angemeldeten Nutzerstatus gekoppelt.
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
---
|
||||||
|
|
||||||
|
## 🔍 Soll vs. Ist Abgleich (Original-Anforderungen)
|
||||||
|
|
||||||
|
Basierend auf dem ursprünglichen Lastenheft von Thomas und den UI-Wireframes (`App (1).xlsx`) ergibt sich folgender Umsetzungsstatus:
|
||||||
|
|
||||||
|
| Anforderung / Modul | Ursprüngliche Spezifikation | Aktueller Umsetzungsstatus | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Startseite / News** | 2-Zeilen-Vorschau, Trennlinie, Volltextsuche (Lupe), Detailansicht, PDF/JPG-Anhänge | Überschrift, Datum, 3 Zeilen Text, Bild, Detailansicht | 🟡 Teilweise (Suche fehlt, nur 1 Bild) |
|
||||||
|
| **Mängelübersicht** | Liste, Volltextsuche, PDF-Tabellendruck einer Auswahl | Liste vorhanden, Checkbox-Auswahl + PDF-Druck integriert | 🟡 Teilweise (Suche & Tabellenlayout im PDF fehlen) |
|
||||||
|
| **Mängelerfassung** | Pflicht-Dropdown für Straße/Ort, Dropdown für Grundlage, max. 2 Fotos im Querformat (max. 800x600), automatischer Verfasser | Titel, Beschreibung, 1 Foto im Quadrat (1:1). Keine Dropdowns | 🔴 Wesentliche Felder fehlen |
|
||||||
|
| **Ideen** | Wie Mängel, bis zu 5 Anhänge (PDF/Bilder), kein Druck erforderlich | Als „BürgerForum“ realisiert, aber identische Maske zu Mängeln (nur 1 Bild) | 🟡 Teilweise (Mehrfach-Upload bis 5 Dateien fehlt) |
|
||||||
|
| **Kontakt** | Freitext, Name, Nachname, E-Mail (Pflicht), Rückrufnummer (optional), E-Mail-Versand, Rechtshinweis | Nicht im Menü eingebunden; nur Registrier-Fragmente vorhanden | 🔴 Fehlt komplett |
|
||||||
|
| **Information / Impressum** | Öffentliche Gemeinde-Infos, Veranstaltungen, Impressum ohne Login frei zugänglich | Nicht vorhanden | 🔴 Fehlt komplett |
|
||||||
|
| **Zugangsschutz / Auth** | Gemeindebürger-Zugang via Passwort/Freigabe; nur Info-Bereich offen | Anonymer Login per Knopfdruck für alle; Fragmente für E-Mail-Login | 🔴 Entspricht nicht dem Passwort-Konzept |
|
||||||
|
| **Design & Header** | Neutraler Hintergrund, optionales Betreiber-Logo im Header | Schlichter Material-AppBar-Header ohne Logo | 🟡 Optimierbar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Technische Architektur & Tech-Stack
|
||||||
|
|
||||||
|
### Verwendete Technologien:
|
||||||
|
- **Framework:** [Flutter](https://flutter.dev) (Dart SDK `>=2.18.2 <3.0.0` / Dart 3 kompatibel)
|
||||||
|
- **Backend / Cloud Services (Firebase):**
|
||||||
|
- `firebase_core` (Core-Initialisierung)
|
||||||
|
- `firebase_auth` (Authentifizierung)
|
||||||
|
- `cloud_firestore` (NoSQL Echtzeitdatenbank für News, Mängel, Ideen)
|
||||||
|
- `firebase_storage` (Speicherung hochgeladener Bilder)
|
||||||
|
- **PDF-Erstellung & Druck:** `pdf`, `printing`
|
||||||
|
- **Bildverarbeitung:** `image`, `image_picker`, `image_cropper`, `cached_network_image`
|
||||||
|
- **State Management:** `provider`
|
||||||
|
|
||||||
|
### Projektstruktur (`lib/`):
|
||||||
|
```text
|
||||||
|
lib/
|
||||||
|
├── firebase_options.dart # Firebase-Projektkonfiguration (Android & iOS)
|
||||||
|
├── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
|
||||||
|
├── models/
|
||||||
|
│ └── post_item.dart # Typisiertes Datenmodell für News, Mängel & Ideen
|
||||||
|
├── screens/
|
||||||
|
│ ├── add_issue_screen.dart # Mängelerfassung mit Straßenauswahl & bis zu 2 Fotos
|
||||||
|
│ ├── add_post_screen.dart # Universelle Erfassung für News & Bürgerideen
|
||||||
|
│ ├── auth_dialog.dart # Anmelde-, Registrierungs- & Code-Freischaltungs-Dialog
|
||||||
|
│ ├── contact_screen.dart # Bürgerforum-Kontaktformular & Anfragen
|
||||||
|
│ ├── detail_screen.dart # Detailansicht mit Metadaten & PDF-Einzelexport
|
||||||
|
│ ├── home_screen.dart # Haupt-Scaffold mit 5 Tabs (News, Mängel, Ideen, Kontakt, Info)
|
||||||
|
│ ├── info_screen.dart # Bürgerforum Leitbild, Notrufnummern & Account-Status
|
||||||
|
│ └── post_list_screen.dart # Universelle Listenansicht, Echtzeitsuche & PDF-Tabelle
|
||||||
|
└── services/
|
||||||
|
├── auth_service.dart # Hybrid-Auth (E-Mail/Passwort, Bürger-Code, Anonym)
|
||||||
|
├── firestore_service.dart# NoSQL CRUD & Kontaktanfragen
|
||||||
|
├── pdf_service.dart # PDF-Druckerzeugung (Listen & Einzelansichten)
|
||||||
|
└── storage_service.dart # Optimierter Bild-Upload (Querformat max 800x600)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrate with your tools
|
---
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1/-/settings/integrations)
|
## 🤖 CI/CD Pipeline (Gitea Actions)
|
||||||
|
|
||||||
## Collaborate with your team
|
Die App verfügt über eine vollautomatische Build-Pipeline für **Gitea Actions** (`.gitea/workflows/build-apk.yaml`):
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
### Funktionen der Pipeline:
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
- **Automatische Trigger:** Wird bei jedem `push` auf `main`, `master`, `refactor/**`, bei Git-Tags (`v*`) oder manuell im Webinterface (`workflow_dispatch`) ausgelöst.
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
- **Isolierte Build-Umgebung:** Nutzt das offizielle `ghcr.io/cirruslabs/flutter:3.19.3` Docker-Image mit vorinstalliertem Android SDK, Java 17 und Flutter.
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
- **Qualitätssicherung:** Führt automatisch `flutter analyze` und `flutter test` aus, bevor der Build startet.
|
||||||
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
- **Vollständige APK-Generierung:**
|
||||||
|
- `oggauer-gemeindetrommler-universal-release.apk` (Universelle Release-APK für alle Android-Geräte)
|
||||||
|
- `oggauer-gemeindetrommler-arm64-v8a.apk` (Für moderne 64-Bit Smartphones)
|
||||||
|
- `oggauer-gemeindetrommler-armeabi-v7a.apk` (Für ältere 32-Bit Smartphones)
|
||||||
|
- `oggauer-gemeindetrommler-x86_64.apk` (Für Emulatoren & ChromeOS)
|
||||||
|
- **Artifact-Upload:** Speichert die fertigen APKs als herunterladbare ZIP-Datei (`oggauer-gemeindetrommler-apks`) direkt im Gitea Run-Dashboard.
|
||||||
|
|
||||||
## Test and Deploy
|
### APKs in Gitea herunterladen:
|
||||||
|
1. Im Gitea-Repository auf den Reiter **Aktionen** (*Actions*) klicken.
|
||||||
|
2. Den gewünschten Pipeline-Durchlauf auswählen.
|
||||||
|
3. Im Abschnitt **Artefakte** (*Artifacts*) das Paket `oggauer-gemeindetrommler-apks` herunterladen.
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
---
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
## 🛠️ Einrichtung & Lokaler Start
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
### Voraussetzungen
|
||||||
|
1. **Flutter SDK** (empfohlen: `>= 3.19.x`)
|
||||||
|
2. **Dart SDK** (enthalten in Flutter)
|
||||||
|
3. **Android Studio** / **Xcode** (für Emulatoren oder physische Geräte)
|
||||||
|
4. Konfiguriertes Firebase-Projekt (`gemeinde-trommler`)
|
||||||
|
|
||||||
# Editing this README
|
### Installation & Ausführung
|
||||||
|
```bash
|
||||||
|
# 1. Abhängigkeiten installieren
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
# 2. Statische Code-Analyse ausführen
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
## Suggestions for a good README
|
# 3. Tests ausführen
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
flutter test
|
||||||
|
|
||||||
## Name
|
# 4. App lokal starten
|
||||||
Choose a self-explaining name for your project.
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
## Description
|
---
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
## 📄 Lizenz & Urheberschaft
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
Entwickelt für das **Bürgerforum Oggau**. Private & gemeinnützige Verwendung.
|
||||||
|
|
||||||
## Visuals
|
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
|||||||
android {
|
android {
|
||||||
namespace "com.oggauergemeindetrommler.app"
|
namespace "com.oggauergemeindetrommler.app"
|
||||||
compileSdkVersion flutter.compileSdkVersion
|
compileSdkVersion flutter.compileSdkVersion
|
||||||
ndkVersion flutter.ndkVersion
|
ndkVersion "26.1.10909125"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
@@ -42,6 +42,10 @@ android {
|
|||||||
jvmTarget = '17'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ allprojects {
|
|||||||
|
|
||||||
rootProject.buildDir = '../build'
|
rootProject.buildDir = '../build'
|
||||||
subprojects {
|
subprojects {
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
// Note: Do not override project.buildDir across different drives on Windows (H: vs C:)
|
||||||
|
|
||||||
// Inject namespace into plugins that don't have it (required for AGP 8+)
|
// Inject namespace into plugins that don't have it (required for AGP 8+)
|
||||||
afterEvaluate { project ->
|
afterEvaluate { project ->
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M -Dandroid.prefs.root=C:/Users/nicol/.android
|
org.gradle.jvmargs=-Xmx1536M -Dandroid.prefs.root=C:/Users/nicol/.android
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
|
org.gradle.java.home=C:/Program Files/Zulu/zulu-20
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// News / Neuigkeiten: Öffentlich lesbar, Schreiben für angemeldete Nutzer
|
||||||
|
match /News/{document=**} {
|
||||||
|
allow read: if true;
|
||||||
|
allow write: if request.auth != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mängel & Anliegen: Öffentlich lesbar, Erstellen angemeldet, Löschen nur eigener Autor
|
||||||
|
match /Issues/{document} {
|
||||||
|
allow read: if true;
|
||||||
|
allow create: if request.auth != null;
|
||||||
|
allow update: if request.auth != null;
|
||||||
|
allow delete: if request.auth != null &&
|
||||||
|
(resource.data.author_uid == request.auth.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bürgerforum / Ideen: Öffentlich lesbar, Erstellen angemeldet, Löschen eigener Autor
|
||||||
|
match /CitizensForum/{document} {
|
||||||
|
allow read: if true;
|
||||||
|
allow create: if request.auth != null;
|
||||||
|
allow update: if request.auth != null;
|
||||||
|
allow delete: if request.auth != null &&
|
||||||
|
(resource.data.author_uid == request.auth.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontakt-Nachrichten: Jeder kann einsenden, Lesen nur berechtigte Nutzer
|
||||||
|
match /ContactMessages/{document} {
|
||||||
|
allow create: if true;
|
||||||
|
allow read, update, delete: if request.auth != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
|
|
||||||
class AddScreen extends StatefulWidget {
|
|
||||||
final int initialIndex;
|
|
||||||
|
|
||||||
const AddScreen({Key? key, required this.initialIndex}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_AddScreenState createState() => _AddScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddScreenState extends State<AddScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _titleController = TextEditingController();
|
|
||||||
final _descriptionController = TextEditingController();
|
|
||||||
File? _imageFile;
|
|
||||||
bool _uploading = false;
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_selectedIndex = widget.initialIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> isFileSizeValid(File file, int maxSizeInMB) async {
|
|
||||||
final fileSize = await file.length();
|
|
||||||
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
|
||||||
return fileSize <= maxSizeInBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File> _cropImage(File imageFile) async {
|
|
||||||
final originalImage = img.decodeImage(await imageFile.readAsBytes());
|
|
||||||
|
|
||||||
if (originalImage != null) {
|
|
||||||
int originSize = min(originalImage.width, originalImage.height);
|
|
||||||
final croppedImage = img.copyCrop(originalImage,
|
|
||||||
x: (originalImage.width - originSize) ~/ 2,
|
|
||||||
y: (originalImage.height - originSize) ~/ 2,
|
|
||||||
width: originSize,
|
|
||||||
height: originSize);
|
|
||||||
final newPath =
|
|
||||||
imageFile.path.substring(0, imageFile.path.lastIndexOf('/'));
|
|
||||||
final newFile = File('$newPath/cropped.jpg');
|
|
||||||
await newFile.writeAsBytes(img.encodePng(croppedImage));
|
|
||||||
return newFile;
|
|
||||||
} else {
|
|
||||||
throw Exception('Unable to decode image file.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _getImage(ImageSource source) async {
|
|
||||||
final pickedFile = await ImagePicker().pickImage(source: source);
|
|
||||||
|
|
||||||
if (pickedFile != null) {
|
|
||||||
final tempFile = File(pickedFile.path);
|
|
||||||
final bool isValid = await isFileSizeValid(tempFile, 10);
|
|
||||||
if (isValid) {
|
|
||||||
// Bildinformationen lesen
|
|
||||||
final originalImage = img.decodeImage(await tempFile.readAsBytes());
|
|
||||||
|
|
||||||
if (originalImage != null) {
|
|
||||||
if (originalImage.width == originalImage.height) {
|
|
||||||
// Wenn das Bild bereits im 1:1-Format ist, überspringen Sie das Zuschneiden
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_imageFile = tempFile;
|
|
||||||
print("1:1");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Wenn das Bild nicht im 1:1-Format ist, schneiden Sie es zu
|
|
||||||
File croppedFile = await _cropImage(tempFile);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_imageFile = croppedFile;
|
|
||||||
});
|
|
||||||
print("decode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception('Unable to decode image file.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Die Dateigröße darf maximal 10 MB betragen.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addNewElementToFirestore(
|
|
||||||
String title, String description, File imageFile,
|
|
||||||
{required Function(bool) onComplete}) async {
|
|
||||||
String collectionName;
|
|
||||||
if (_selectedIndex == 0) {
|
|
||||||
collectionName = "CitizensForum";
|
|
||||||
} else if (_selectedIndex == 1) {
|
|
||||||
collectionName = "News";
|
|
||||||
} else {
|
|
||||||
collectionName = "Issues";
|
|
||||||
}
|
|
||||||
|
|
||||||
String imageUrl =
|
|
||||||
await uploadImageToFirebaseStorage(imageFile, collectionName);
|
|
||||||
String? userId = FirebaseAuth.instance.currentUser?.uid; // get the user ID
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FirebaseFirestore.instance.collection(collectionName).add({
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"imageUrl": imageUrl,
|
|
||||||
"createdDate": DateTime.now(),
|
|
||||||
"author_uid": userId, // add the user ID to the document
|
|
||||||
});
|
|
||||||
onComplete(true);
|
|
||||||
} catch (e) {
|
|
||||||
onComplete(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> uploadImageToFirebaseStorage(
|
|
||||||
File imageFile, String collectionName) async {
|
|
||||||
String? userId = FirebaseAuth.instance.currentUser?.uid;
|
|
||||||
if (userId != null) {
|
|
||||||
Reference ref = FirebaseStorage.instance
|
|
||||||
.ref()
|
|
||||||
.child("$collectionName/$userId/${DateTime.now().toString()}");
|
|
||||||
UploadTask uploadTask = ref.putFile(imageFile);
|
|
||||||
return await (await uploadTask).ref.getDownloadURL();
|
|
||||||
} else {
|
|
||||||
throw Exception('Cannot upload without user ID');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
String appBarTitle = '';
|
|
||||||
|
|
||||||
double keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
||||||
|
|
||||||
if (_selectedIndex == 0) {
|
|
||||||
appBarTitle = 'Neue BürgerForum Information';
|
|
||||||
} else if (_selectedIndex == 1) {
|
|
||||||
appBarTitle = 'Neue Neuigkeit';
|
|
||||||
} else if (_selectedIndex == 2) {
|
|
||||||
appBarTitle = 'Neuer Mängel';
|
|
||||||
}
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(appBarTitle),
|
|
||||||
actions: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: () {
|
|
||||||
_titleController.clear();
|
|
||||||
_descriptionController.clear();
|
|
||||||
setState(() {
|
|
||||||
_imageFile = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
onPressed: () async {
|
|
||||||
if (_formKey.currentState!.validate() &&
|
|
||||||
_imageFile != null &&
|
|
||||||
!_uploading) {
|
|
||||||
setState(() {
|
|
||||||
_uploading = true;
|
|
||||||
});
|
|
||||||
Navigator.pop(
|
|
||||||
context); // Fenster direkt schließen, nachdem der Upload-Button gedrückt wurde.
|
|
||||||
await addNewElementToFirestore(
|
|
||||||
_titleController.text,
|
|
||||||
_descriptionController.text,
|
|
||||||
_imageFile!, onComplete: (bool success) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_uploading = false;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(success
|
|
||||||
? 'Upload erfolgreich.'
|
|
||||||
: 'Upload fehlgeschlagen.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Titel',
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[200],
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
controller: _titleController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie einen Titel ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Beschreibung',
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[200],
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
controller: _descriptionController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine Beschreibung ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
_imageFile != null
|
|
||||||
? Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
double imageHeight =
|
|
||||||
max(0, constraints.maxHeight - keyboardHeight);
|
|
||||||
return Container(
|
|
||||||
height: imageHeight,
|
|
||||||
child: Image.file(_imageFile!),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
height: 200.0,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[200],
|
|
||||||
borderRadius: BorderRadius.circular(16.0),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 60,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Bild hinzufügen',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.photo_library,
|
|
||||||
size: 40),
|
|
||||||
title: const Text(
|
|
||||||
'Galerie auswählen',
|
|
||||||
style: TextStyle(fontSize: 20),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_getImage(ImageSource.gallery);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.camera_alt,
|
|
||||||
size: 40),
|
|
||||||
title: const Text(
|
|
||||||
'Kamera verwenden',
|
|
||||||
style: TextStyle(fontSize: 20),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_getImage(ImageSource.camera);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Bild hinzufügen',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:pdf/widgets.dart' as pdfWidgets;
|
|
||||||
import 'package:printing/printing.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
|
|
||||||
class DetailPage extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String datum;
|
|
||||||
final String description;
|
|
||||||
final String imageUrl;
|
|
||||||
|
|
||||||
const DetailPage({
|
|
||||||
Key? key,
|
|
||||||
required this.title,
|
|
||||||
required this.datum,
|
|
||||||
required this.description,
|
|
||||||
required this.imageUrl,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _printPdf() async {
|
|
||||||
final pdf = pdfWidgets.Document();
|
|
||||||
final image = await _loadNetworkImage(imageUrl);
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pdfWidgets.MultiPage(
|
|
||||||
build: (context) => [
|
|
||||||
pdfWidgets.Center(
|
|
||||||
child: pdfWidgets.SizedBox(
|
|
||||||
width: double.infinity, // Die Breite auf die maximal mögliche Breite setzen
|
|
||||||
child: pdfWidgets.AspectRatio(
|
|
||||||
aspectRatio: 1, // Sie können das Seitenverhältnis entsprechend Ihrem Bild anpassen
|
|
||||||
child: pdfWidgets.Image(image),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(
|
|
||||||
title,
|
|
||||||
style: pdfWidgets.TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: pdfWidgets.FontWeight.bold // Titel fett
|
|
||||||
)
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(datum, style: const pdfWidgets.TextStyle(fontSize: 14, )),
|
|
||||||
pdfWidgets.Paragraph(text: description, style: const pdfWidgets.TextStyle(fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await Printing.sharePdf(bytes: await pdf.save(), filename: '$title.pdf');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
|
|
||||||
final response = await http.get(Uri.parse(url));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to load network image.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final Uint8List bytes = response.bodyBytes;
|
|
||||||
final imageProvider = pdfWidgets.MemoryImage(bytes);
|
|
||||||
|
|
||||||
return imageProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(title),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
onPressed: _printPdf,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
placeholder: (context, url) => const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0, bottom: 8.0),
|
|
||||||
child: Text(
|
|
||||||
datum,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
description,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:oggauergemeindetrommler/register.dart';
|
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
|
||||||
LoginPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_LoginPageState createState() => _LoginPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isValidEmail(String email) {
|
|
||||||
// Hier kannst du deine Validierungslogik implementieren
|
|
||||||
// Rückgabe true, wenn die E-Mail gültig ist, andernfalls false
|
|
||||||
// Beispiel:
|
|
||||||
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'E-Mail',
|
|
||||||
),
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
|
|
||||||
} else if (!isValidEmail(value)) {
|
|
||||||
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
String email = _emailController.text;
|
|
||||||
if (isValidEmail(email)) {
|
|
||||||
// E-Mail ist gültig, führe die entsprechende Aktion aus
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Ungültige E-Mail-Adresse'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Anmelden'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text('Noch kein Konto?'),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
// Navigiere zur Registrierungsseite
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const RegisterPage()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('Registrieren'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:printing/printing.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'AddPage.dart';
|
|
||||||
import 'DetailPage.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:pdf/widgets.dart' as pdfWidgets;
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
|
|
||||||
import 'anonym_aut.dart';
|
|
||||||
|
|
||||||
class UserDefinedItem extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String datum;
|
|
||||||
final String description;
|
|
||||||
final String imageUrl;
|
|
||||||
final Function onLongPress;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const UserDefinedItem({
|
|
||||||
Key? key,
|
|
||||||
required this.title,
|
|
||||||
required this.description,
|
|
||||||
required this.datum,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.onLongPress,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return InkWell(
|
|
||||||
onTap:onTap,
|
|
||||||
onLongPress: onLongPress as void Function()?,
|
|
||||||
child: Card(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
placeholder: (context, url) => const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isSelected) // Wenn das Element ausgewählt ist, zeigen Sie die Checkbox an
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
child: Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (bool? value) {
|
|
||||||
onLongPress(); // Wenn die Checkbox angeklickt wird, rufen Sie die onLongPress-Funktion auf
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
flex: 1,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
datum,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Text(
|
|
||||||
description,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GenericList extends StatefulWidget {
|
|
||||||
final String collectionName;
|
|
||||||
final int selectedIndex;
|
|
||||||
|
|
||||||
const GenericList({required this.collectionName, required this.selectedIndex});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_GenericListState createState() => _GenericListState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GenericListState extends State<GenericList> {
|
|
||||||
final FirebaseFirestore firestore = FirebaseFirestore.instance;
|
|
||||||
Map<String, Map<String, dynamic>> selectedItems = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// Enable persistence
|
|
||||||
firestore.enablePersistence();
|
|
||||||
// Enable network
|
|
||||||
firestore.settings =
|
|
||||||
const Settings(cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED);
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(GenericList oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (widget.selectedIndex != oldWidget.selectedIndex) {
|
|
||||||
selectedItems.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void clearSelectedItems() {
|
|
||||||
setState(() {
|
|
||||||
selectedItems.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<QuerySnapshot> getDocumentStream() {
|
|
||||||
return firestore
|
|
||||||
.collection(widget.collectionName)
|
|
||||||
.orderBy('createdDate', descending: true)
|
|
||||||
.snapshots(includeMetadataChanges: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteDocument(String documentId, String? imageUrl) async {
|
|
||||||
try {
|
|
||||||
if (imageUrl != null) {
|
|
||||||
// Löschen Sie das Bild vom Firebase Storage
|
|
||||||
await FirebaseStorage.instance.refFromURL(imageUrl).delete();
|
|
||||||
}
|
|
||||||
// Löschen Sie das Dokument von der Firestore-Kollektion
|
|
||||||
await firestore.collection(widget.collectionName)
|
|
||||||
.doc(documentId)
|
|
||||||
.delete();
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
print('Error deleting document: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
StreamBuilder<QuerySnapshot>(
|
|
||||||
stream: getDocumentStream(),
|
|
||||||
builder: (BuildContext context,
|
|
||||||
AsyncSnapshot<QuerySnapshot> snapshot) {
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Text('Error: ${snapshot.error}');
|
|
||||||
}
|
|
||||||
switch (snapshot.connectionState) {
|
|
||||||
case ConnectionState.waiting:
|
|
||||||
return const Text('Loading...');
|
|
||||||
default:
|
|
||||||
final List<QueryDocumentSnapshot> documents = snapshot.data!
|
|
||||||
.docs;
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: documents.length,
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
final Map<String, dynamic> data =
|
|
||||||
documents[index].data()! as Map<String, dynamic>;
|
|
||||||
final String documentId = documents[index].id;
|
|
||||||
final DateTime createdDate = data['createdDate'].toDate();
|
|
||||||
final formattedDate =
|
|
||||||
DateFormat('dd.MM.yyyy').format(createdDate);
|
|
||||||
final currentUser = FirebaseAuth.instance.currentUser;
|
|
||||||
|
|
||||||
if (currentUser != null && data['author_uid'] == currentUser.uid) {
|
|
||||||
return Dismissible(
|
|
||||||
key: UniqueKey(),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
onDismissed: (direction) {
|
|
||||||
_deleteDocument(documentId, data['imageUrl']);
|
|
||||||
},
|
|
||||||
background: Container(
|
|
||||||
color: Colors.red,
|
|
||||||
child: const Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(right: 16),
|
|
||||||
child: Icon(Icons.delete, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: UserDefinedItem(
|
|
||||||
title: data['title'] ?? '',
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'] ?? '',
|
|
||||||
imageUrl: data['imageUrl'] ?? '',
|
|
||||||
isSelected: selectedItems.containsKey(documentId),
|
|
||||||
onTap: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
// Open a new screen when the user taps on the list item
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => DetailPage(
|
|
||||||
title: data['title'],
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'],
|
|
||||||
imageUrl: data['imageUrl'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
setState(() {
|
|
||||||
if (selectedItems.containsKey(documentId)) {
|
|
||||||
selectedItems.remove(documentId);
|
|
||||||
} else {
|
|
||||||
selectedItems[documentId] = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return UserDefinedItem(
|
|
||||||
title: data['title'] ?? '',
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'] ?? '',
|
|
||||||
imageUrl: data['imageUrl'] ?? '',
|
|
||||||
isSelected: selectedItems.containsKey(documentId),
|
|
||||||
onTap: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
// Open a new screen when the user taps on the list item
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => DetailPage(
|
|
||||||
title: data['title'],
|
|
||||||
datum: formattedDate,
|
|
||||||
description: data['description'],
|
|
||||||
imageUrl: data['imageUrl'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
setState(() {
|
|
||||||
if (selectedItems.containsKey(documentId)) {
|
|
||||||
selectedItems.remove(documentId);
|
|
||||||
} else {
|
|
||||||
selectedItems[documentId] = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (selectedItems.isNotEmpty)
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
onPressed: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
},
|
|
||||||
tooltip: "Auswahl zurücksetzen",
|
|
||||||
child: const Icon(Icons.refresh),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.bottomLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
onPressed: () => _printSelectedItemsAsPdf(widget.selectedIndex),
|
|
||||||
tooltip: "Ausgewählte Elemente als pdf speichern",
|
|
||||||
child: const Icon(Icons.picture_as_pdf),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
),
|
|
||||||
floatingActionButton: Consumer<AuthState>(
|
|
||||||
builder: (context, authState, child) {
|
|
||||||
if (authState.user != null) {
|
|
||||||
return FloatingActionButton(
|
|
||||||
onPressed: () {
|
|
||||||
clearSelectedItems();
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => AddScreen(initialIndex: widget.selectedIndex)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
tooltip: "Neues Element hinzufügen",
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Container(); // Return an empty container when the user is not signed in
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<pdfWidgets.ImageProvider> _loadNetworkImage(String url) async {
|
|
||||||
final response = await http.get(Uri.parse(url));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to load network image.');
|
|
||||||
}
|
|
||||||
final Uint8List bytes = response.bodyBytes;
|
|
||||||
|
|
||||||
final img.Image? image = img.decodeImage(bytes);
|
|
||||||
final img.Image resizedImage = img.copyResize(image!, width: 290); // Beispielwert für die Breite
|
|
||||||
final Uint8List resizedBytes = Uint8List.fromList(img.encodeJpg(resizedImage));
|
|
||||||
|
|
||||||
final imageProvider = pdfWidgets.MemoryImage(resizedBytes);
|
|
||||||
return imageProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _printSelectedItemsAsPdf(int selectedIndex) async {
|
|
||||||
final pdfWidgets.Document pdf = pdfWidgets.Document();
|
|
||||||
|
|
||||||
final List<pdfWidgets.Widget> items = [];
|
|
||||||
for (final entry in selectedItems.entries) {
|
|
||||||
final String title = entry.value['title'] ?? '';
|
|
||||||
final String description = (entry.value['description'] ?? '').length > 165 ? (entry.value['description'] ?? '').substring(0, 165) + '...' : (entry.value['description'] ?? '');
|
|
||||||
final String imageUrl = entry.value['imageUrl'] ?? '';
|
|
||||||
final DateTime date = entry.value['createdDate'].toDate();
|
|
||||||
final String formattedDate = DateFormat('dd.MM.yyyy').format(date);
|
|
||||||
final pdfWidgets.ImageProvider imageProvider = await _loadNetworkImage(imageUrl);
|
|
||||||
|
|
||||||
items.add(
|
|
||||||
pdfWidgets.Container(
|
|
||||||
height: 145, // Begrenzt die Höhe des Containers
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Row(
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Container(
|
|
||||||
width: 145, // Reduziert die Breite des Bildes
|
|
||||||
height: 145, // Reduziert die Höhe des Bildes
|
|
||||||
child: pdfWidgets.Image(imageProvider),
|
|
||||||
),
|
|
||||||
pdfWidgets.Flexible(
|
|
||||||
child: pdfWidgets.Padding(
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Column(
|
|
||||||
crossAxisAlignment: pdfWidgets.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Row(
|
|
||||||
mainAxisAlignment: pdfWidgets.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pdfWidgets.Flexible(
|
|
||||||
child: pdfWidgets.Text(
|
|
||||||
title,
|
|
||||||
style: pdfWidgets.TextStyle(
|
|
||||||
fontSize: 20, fontWeight: pdfWidgets.FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pdfWidgets.Text(
|
|
||||||
formattedDate,
|
|
||||||
style: const pdfWidgets.TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pdfWidgets.Padding(
|
|
||||||
padding: const pdfWidgets.EdgeInsets.all(5.0), // Reduziert den Abstand
|
|
||||||
child: pdfWidgets.Text(
|
|
||||||
description,
|
|
||||||
style: const pdfWidgets.TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pdfWidgets.MultiPage(
|
|
||||||
build: (pdfWidgets.Context context) => items,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
String filename;
|
|
||||||
switch(selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
filename = 'Bürgerforum_items.pdf';
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
filename = 'Neuigkeiten_items.pdf';
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
filename = 'Mängel_items.pdf';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
filename = 'selected_items.pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
|
|
||||||
clearSelectedItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class AuthState extends ChangeNotifier {
|
|
||||||
FirebaseAuth auth = FirebaseAuth.instance;
|
|
||||||
User? _user;
|
|
||||||
|
|
||||||
User? get user => _user;
|
|
||||||
|
|
||||||
AuthState() {
|
|
||||||
auth.authStateChanges().listen((User? user) {
|
|
||||||
if (user == null) {
|
|
||||||
print('User is currently signed out!');
|
|
||||||
_user = null;
|
|
||||||
} else {
|
|
||||||
print('User is signed in!');
|
|
||||||
_user = user;
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> signInAnonymously() async {
|
|
||||||
try {
|
|
||||||
await auth.signInAnonymously();
|
|
||||||
} catch (e) {
|
|
||||||
print(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> signOut() async {
|
|
||||||
await auth.signOut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AnonymAuthScreen extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Consumer<AuthState>(
|
|
||||||
builder: (context, authState, child) {
|
|
||||||
return ElevatedButton(
|
|
||||||
onPressed: authState.user == null
|
|
||||||
? authState.signInAnonymously
|
|
||||||
: authState.signOut,
|
|
||||||
child: Text(authState.user == null ? 'Anonym anmelden' : 'Abmelden'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:oggauergemeindetrommler/LoginPage.dart';
|
|
||||||
import 'UserDefinedList.dart';
|
|
||||||
import 'anonym_aut.dart';
|
|
||||||
|
|
||||||
class MyHomePage extends StatefulWidget {
|
|
||||||
const MyHomePage({Key? key, required this.title});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
@override
|
|
||||||
State<MyHomePage> createState() => _MyHomePageState();
|
|
||||||
}
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
void _onItemTapped(int index) {
|
|
||||||
setState(() {
|
|
||||||
_selectedIndex = index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final List<Widget> widgetOptions = <Widget>[
|
|
||||||
const GenericList(collectionName: 'CitizensForum', selectedIndex: 0,),
|
|
||||||
const GenericList(collectionName: 'News', selectedIndex: 1,),
|
|
||||||
const GenericList(collectionName: 'Issues', selectedIndex: 2,),
|
|
||||||
AnonymAuthScreen()
|
|
||||||
];
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Oggauer Gemeinde Trommler'),
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: widgetOptions.elementAt(_selectedIndex),
|
|
||||||
),
|
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
|
||||||
items: const <BottomNavigationBarItem>[
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
label: 'BürgerForum',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.newspaper),
|
|
||||||
label: 'Neuigkeiten',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.report),
|
|
||||||
label: 'Mängel',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.account_circle),
|
|
||||||
label: 'Profil',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
currentIndex: _selectedIndex,
|
|
||||||
selectedItemColor: Colors.blue,
|
|
||||||
unselectedItemColor: Colors.black26,
|
|
||||||
onTap: _onItemTapped,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
-8
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'anonym_aut.dart';
|
|
||||||
import 'home.dart';
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
import 'screens/home_screen.dart';
|
||||||
|
import 'services/auth_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -12,24 +12,40 @@ void main() async {
|
|||||||
);
|
);
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => AuthState(),
|
create: (context) => AuthService(),
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
// This widget is the root of your application.
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Oggauer Gemeindetrommler',
|
title: 'Oggauer Gemeindetrommler',
|
||||||
home: MyHomePage(title: 'Oggauer Gemeindetrommler'),
|
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFF1565C0), // Municipal blue
|
||||||
|
primary: const Color(0xFF1565C0),
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
centerTitle: true,
|
||||||
|
elevation: 1,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black87,
|
||||||
|
),
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
home: const HomeScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class PostItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String imageUrl;
|
||||||
|
final List<String> imageUrls;
|
||||||
|
final DateTime createdDate;
|
||||||
|
final String? authorUid;
|
||||||
|
final String? street;
|
||||||
|
final String? category;
|
||||||
|
final String? status;
|
||||||
|
|
||||||
|
PostItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.imageUrl = '',
|
||||||
|
List<String>? imageUrls,
|
||||||
|
required this.createdDate,
|
||||||
|
this.authorUid,
|
||||||
|
this.street,
|
||||||
|
this.category,
|
||||||
|
this.status = 'Gemeldet',
|
||||||
|
}) : imageUrls = imageUrls ?? (imageUrl.isNotEmpty ? [imageUrl] : []);
|
||||||
|
|
||||||
|
factory PostItem.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
DateTime date;
|
||||||
|
if (data['createdDate'] is Timestamp) {
|
||||||
|
date = (data['createdDate'] as Timestamp).toDate();
|
||||||
|
} else {
|
||||||
|
date = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String legacyImage = data['imageUrl'] as String? ?? '';
|
||||||
|
List<String> images = [];
|
||||||
|
if (data['imageUrls'] is List) {
|
||||||
|
images = List<String>.from(data['imageUrls'] as List);
|
||||||
|
} else if (legacyImage.isNotEmpty) {
|
||||||
|
images = [legacyImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return PostItem(
|
||||||
|
id: doc.id,
|
||||||
|
title: data['title'] as String? ?? '',
|
||||||
|
description: data['description'] as String? ?? '',
|
||||||
|
imageUrl: legacyImage.isNotEmpty ? legacyImage : (images.isNotEmpty ? images.first : ''),
|
||||||
|
imageUrls: images,
|
||||||
|
createdDate: date,
|
||||||
|
authorUid: data['author_uid'] as String?,
|
||||||
|
street: data['street'] as String?,
|
||||||
|
category: data['category'] as String?,
|
||||||
|
status: data['status'] as String? ?? 'Gemeldet',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toFirestore() {
|
||||||
|
return {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'imageUrl': imageUrls.isNotEmpty ? imageUrls.first : '',
|
||||||
|
'imageUrls': imageUrls,
|
||||||
|
'createdDate': Timestamp.fromDate(createdDate),
|
||||||
|
'author_uid': authorUid,
|
||||||
|
if (street != null) 'street': street,
|
||||||
|
if (category != null) 'category': category,
|
||||||
|
if (status != null) 'status': status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class RegisterPage extends StatefulWidget {
|
|
||||||
const RegisterPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_RegisterPageState createState() => _RegisterPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RegisterPageState extends State<RegisterPage> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isValidEmail(String email) {
|
|
||||||
// Die gleiche Validierung wie in Ihrer LoginPage
|
|
||||||
return RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$').hasMatch(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool arePasswordsMatching(String password, String confirmPassword) {
|
|
||||||
return password == confirmPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'E-Mail',
|
|
||||||
),
|
|
||||||
validator: (value) {
|
|
||||||
if (value!.isEmpty) {
|
|
||||||
return 'Bitte geben Sie eine E-Mail-Adresse ein.';
|
|
||||||
} else if (!isValidEmail(value)) {
|
|
||||||
return 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Passwort bestätigen',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
String email = _emailController.text;
|
|
||||||
String password = _passwordController.text;
|
|
||||||
String confirmPassword = _confirmPasswordController.text;
|
|
||||||
if (isValidEmail(email) && arePasswordsMatching(password, confirmPassword)) {
|
|
||||||
// E-Mail und Passwörter sind gültig, führe die entsprechende Aktion aus
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Ungültige Eingaben'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Registrieren'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
|
||||||
|
class AddIssueScreen extends StatefulWidget {
|
||||||
|
const AddIssueScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddIssueScreen> createState() => _AddIssueScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddIssueScreenState extends State<AddIssueScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
final StorageService _storageService = StorageService();
|
||||||
|
|
||||||
|
final List<File> _selectedImages = [];
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
final List<String> _streets = [
|
||||||
|
'Hauptstraße',
|
||||||
|
'Seestraße',
|
||||||
|
'Florianigasse',
|
||||||
|
'Sebastiansplatz',
|
||||||
|
'Sportplatzgasse',
|
||||||
|
'Feldgasse',
|
||||||
|
'Kirchengasse',
|
||||||
|
'Quergasse',
|
||||||
|
'Weinberggasse',
|
||||||
|
'Hafen / Seebad',
|
||||||
|
'Flur / Rad- & Wanderwege',
|
||||||
|
'Sonstige / Außerorts',
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<String> _categories = [
|
||||||
|
'Fahrbahn / Gehsteig / Radweg',
|
||||||
|
'Straßenbeleuchtung',
|
||||||
|
'Grünflächen / Bäume',
|
||||||
|
'Müll / Verunreinigung',
|
||||||
|
'Spielplatz / Freizeiteinrichtung',
|
||||||
|
'Kanal / Entwässerung',
|
||||||
|
'Verkehrszeichen / Markierung',
|
||||||
|
'Sonstiges',
|
||||||
|
];
|
||||||
|
|
||||||
|
String? _selectedStreet;
|
||||||
|
String? _selectedCategory;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedStreet = _streets.first;
|
||||||
|
_selectedCategory = _categories.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
if (_selectedImages.length >= 2) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
maxWidth: 1600,
|
||||||
|
maxHeight: 1200,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedImages.add(File(picked.path));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceModal() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library),
|
||||||
|
title: const Text('Foto aus Galerie wählen'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.gallery);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt),
|
||||||
|
title: const Text('Foto mit Kamera aufnehmen'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.camera);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitIssue() async {
|
||||||
|
if (!_formKey.currentState!.validate() || _isSaving) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
|
||||||
|
// Upload selected images to Firebase Storage
|
||||||
|
final List<String> uploadedUrls = [];
|
||||||
|
for (final file in _selectedImages) {
|
||||||
|
final url = await _storageService.uploadFile(
|
||||||
|
file: file,
|
||||||
|
collectionName: 'Issues',
|
||||||
|
optimize: true,
|
||||||
|
);
|
||||||
|
uploadedUrls.add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newIssue = PostItem(
|
||||||
|
id: '',
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
imageUrl: uploadedUrls.isNotEmpty ? uploadedUrls.first : '',
|
||||||
|
imageUrls: uploadedUrls,
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
authorUid: currentUserId,
|
||||||
|
street: _selectedStreet,
|
||||||
|
category: _selectedCategory,
|
||||||
|
status: 'Gemeldet',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _firestoreService.addPost('Issues', newIssue);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Mangel wurde erfolgreich gemeldet!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Speichern: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mangel melden'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Mängelüberschrift *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'z.B. Schlagloch, defekte Straßenlaterne',
|
||||||
|
),
|
||||||
|
validator: (val) => (val == null || val.trim().isEmpty)
|
||||||
|
? 'Bitte Überschrift eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _selectedStreet,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Straße / Örtlichkeit *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _streets
|
||||||
|
.map((street) => DropdownMenuItem(
|
||||||
|
value: street,
|
||||||
|
child: Text(street),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedStreet = val),
|
||||||
|
validator: (val) =>
|
||||||
|
(val == null || val.isEmpty) ? 'Bitte Ort auswählen' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _selectedCategory,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Grundlage / Kategorie *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _categories
|
||||||
|
.map((cat) => DropdownMenuItem(
|
||||||
|
value: cat,
|
||||||
|
child: Text(cat),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedCategory = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Mangeltext (Beschreibung) *',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'Genaue Beschreibung des Schadens...',
|
||||||
|
),
|
||||||
|
validator: (val) => (val == null || val.trim().isEmpty)
|
||||||
|
? 'Bitte Beschreibung angeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Fotos (maximal 2 im Querformat)',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
..._selectedImages.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final file = entry.value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
width: 110,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: FileImage(file),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -4,
|
||||||
|
right: 4,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.cancel, color: Colors.red),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedImages.removeAt(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (_selectedImages.length < 2)
|
||||||
|
InkWell(
|
||||||
|
onTap: _showImageSourceModal,
|
||||||
|
child: Container(
|
||||||
|
width: 110,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
style: BorderStyle.solid),
|
||||||
|
),
|
||||||
|
child: const Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add_a_photo, color: Colors.grey),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('Foto hinzufügen',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11, color: Colors.black54)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isSaving ? null : _submitIssue,
|
||||||
|
icon: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check_circle_outline),
|
||||||
|
label: Text(
|
||||||
|
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
|
||||||
|
class AddPostScreen extends StatefulWidget {
|
||||||
|
final String collectionName;
|
||||||
|
final String screenTitle;
|
||||||
|
|
||||||
|
const AddPostScreen({
|
||||||
|
super.key,
|
||||||
|
required this.collectionName,
|
||||||
|
required this.screenTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddPostScreen> createState() => _AddPostScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddPostScreenState extends State<AddPostScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
final StorageService _storageService = StorageService();
|
||||||
|
|
||||||
|
File? _selectedImage;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
maxWidth: 1600,
|
||||||
|
maxHeight: 1200,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedImage = File(picked.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceModal() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library),
|
||||||
|
title: const Text('Aus Galerie wählen'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.gallery);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt),
|
||||||
|
title: const Text('Kamera verwenden'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.camera);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitPost() async {
|
||||||
|
if (!_formKey.currentState!.validate() || _isSaving) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
String imageUrl = '';
|
||||||
|
|
||||||
|
if (_selectedImage != null) {
|
||||||
|
imageUrl = await _storageService.uploadFile(
|
||||||
|
file: _selectedImage!,
|
||||||
|
collectionName: widget.collectionName,
|
||||||
|
optimize: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newPost = PostItem(
|
||||||
|
id: '',
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
|
||||||
|
createdDate: DateTime.now(),
|
||||||
|
authorUid: currentUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _firestoreService.addPost(widget.collectionName, newPost);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Eintrag erfolgreich veröffentlicht!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Speichern: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.screenTitle),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Überschrift *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (val) => (val == null || val.trim().isEmpty)
|
||||||
|
? 'Bitte Überschrift eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Inhalt / Text *',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (val) => (val == null || val.trim().isEmpty)
|
||||||
|
? 'Bitte Text eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Anhang / Beitragsbild',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_selectedImage != null)
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: FileImage(_selectedImage!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: () => setState(() => _selectedImage = null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
InkWell(
|
||||||
|
onTap: _showImageSourceModal,
|
||||||
|
child: Container(
|
||||||
|
height: 120,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
child: const Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isSaving ? null : _submitPost,
|
||||||
|
icon: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send),
|
||||||
|
label: Text(
|
||||||
|
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
|
class AuthDialog extends StatefulWidget {
|
||||||
|
const AuthDialog({super.key});
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context) async {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const AuthDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthDialog> createState() => _AuthDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthDialogState extends State<AuthDialog>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
// Login Controllers
|
||||||
|
final _loginEmailController = TextEditingController();
|
||||||
|
final _loginPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
// Register Controllers
|
||||||
|
final _regNameController = TextEditingController();
|
||||||
|
final _regEmailController = TextEditingController();
|
||||||
|
final _regPasswordController = TextEditingController();
|
||||||
|
final _regConfirmPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
// Code Controller
|
||||||
|
final _codeController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
_loginEmailController.dispose();
|
||||||
|
_loginPasswordController.dispose();
|
||||||
|
_regNameController.dispose();
|
||||||
|
_regEmailController.dispose();
|
||||||
|
_regPasswordController.dispose();
|
||||||
|
_regConfirmPasswordController.dispose();
|
||||||
|
_codeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogin() async {
|
||||||
|
final email = _loginEmailController.text.trim();
|
||||||
|
final password = _loginPasswordController.text;
|
||||||
|
|
||||||
|
if (email.isEmpty || password.isEmpty) {
|
||||||
|
setState(() => _errorMessage = 'Bitte E-Mail und Passwort eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final error = await auth.signInWithEmail(email, password);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (error == null) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Erfolgreich angemeldet!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(() => _errorMessage = error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRegister() async {
|
||||||
|
final name = _regNameController.text.trim();
|
||||||
|
final email = _regEmailController.text.trim();
|
||||||
|
final password = _regPasswordController.text;
|
||||||
|
final confirm = _regConfirmPasswordController.text;
|
||||||
|
|
||||||
|
if (name.isEmpty || email.isEmpty || password.isEmpty) {
|
||||||
|
setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != confirm) {
|
||||||
|
setState(() => _errorMessage = 'Die Passwörter stimmen nicht überein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setState(
|
||||||
|
() => _errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final error = await auth.signUpWithEmail(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (error == null) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Konto erfolgreich erstellt! Willkommen im Bürgerforum.'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(() => _errorMessage = error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCodeUnlock() async {
|
||||||
|
final code = _codeController.text.trim();
|
||||||
|
if (code.isEmpty) {
|
||||||
|
setState(() => _errorMessage = 'Bitte den Zugangscode eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final success = await auth.verifyCommunityCode(code);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Bürgerzugang freigeschaltet!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(() => _errorMessage =
|
||||||
|
'Ungültiger Zugangscode. Bitte wende dich an das Bürgerforum.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPasswordResetDialog() {
|
||||||
|
final resetEmailController = TextEditingController(
|
||||||
|
text: _loginEmailController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Passwort zurücksetzen'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Gib deine E-Mail-Adresse ein. Wir senden dir einen Link, mit dem du ein neues Passwort festlegen kannst.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: resetEmailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'E-Mail-Adresse',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final email = resetEmailController.text.trim();
|
||||||
|
if (email.isEmpty) return;
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final navigator = Navigator.of(ctx);
|
||||||
|
final err = await auth.sendPasswordReset(email);
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.pop();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(err ?? 'E-Mail zum Zurücksetzen wurde versendet!'),
|
||||||
|
backgroundColor: err == null ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Link senden'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Bürgerforum Oggau',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: Colors.blue.shade800,
|
||||||
|
indicatorColor: Colors.blue.shade800,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Anmelden'),
|
||||||
|
Tab(text: 'Registrieren'),
|
||||||
|
Tab(text: 'Code-Login'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red.shade200),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline,
|
||||||
|
color: Colors.red, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.red.shade900),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 280,
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
// Tab 1: Login
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _loginEmailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'E-Mail-Adresse',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _loginPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Passwort',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _showPasswordResetDialog,
|
||||||
|
child: const Text(
|
||||||
|
'Passwort vergessen?',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleLogin,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Anmelden'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tab 2: Registrierung
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _regNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Name / Vorname',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: _regEmailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'E-Mail-Adresse',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: _regPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Passwort (mind. 6 Zeichen)',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: _regConfirmPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Passwort bestätigen',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleRegister,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Konto erstellen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tab 3: Bürger-Code
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Du möchtest kein persönliches Konto anlegen? Gib einfach den Zugangscode des Bürgerforums ein, um sofort Mängel und Ideen melden zu können.',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 13, color: Colors.black87),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _codeController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bürger-Zugangscode',
|
||||||
|
prefixIcon: Icon(Icons.key_outlined),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'z.B. oggau',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleCodeUnlock,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Freischalten'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
|
||||||
|
class ContactScreen extends StatefulWidget {
|
||||||
|
const ContactScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContactScreen> createState() => _ContactScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactScreenState extends State<ContactScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _firstNameController = TextEditingController();
|
||||||
|
final _lastNameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
final _messageController = TextEditingController();
|
||||||
|
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_firstNameController.dispose();
|
||||||
|
_lastNameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_phoneController.dispose();
|
||||||
|
_messageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidEmail(String email) {
|
||||||
|
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitForm() async {
|
||||||
|
if (!_formKey.currentState!.validate() || _isSubmitting) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _firestoreService.submitContactMessage(
|
||||||
|
firstName: _firstNameController.text.trim(),
|
||||||
|
lastName: _lastNameController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
phone: _phoneController.text.trim(),
|
||||||
|
message: _messageController.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_firstNameController.clear();
|
||||||
|
_lastNameController.clear();
|
||||||
|
_emailController.clear();
|
||||||
|
_phoneController.clear();
|
||||||
|
_messageController.clear();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ihre Nachricht wurde erfolgreich übermittelt!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler beim Senden: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Kontakt Bürgerforum'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(14.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Hier kannst du direkt mit dem Bürgerforum Oggau in Kontakt treten. Wir freuen uns über deine Anregungen und Fragen!',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _firstNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Vorname *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.trim().isEmpty)
|
||||||
|
? 'Bitte Vorname eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _lastNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nachname *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.trim().isEmpty)
|
||||||
|
? 'Bitte Nachname eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'E-Mail-Adresse *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Bitte E-Mail-Adresse eingeben';
|
||||||
|
}
|
||||||
|
if (!_isValidEmail(value.trim())) {
|
||||||
|
return 'Bitte eine gültige E-Mail eingeben';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _phoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rückrufnummer (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _messageController,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ihr Anliegen / Ihre Nachricht *',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.trim().isEmpty)
|
||||||
|
? 'Bitte Ihre Nachricht eingeben'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Hinweis: Strafrechtlich relevante Kommentare und Verleumdungen werden polizeilich verfolgt.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submitForm,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send),
|
||||||
|
label: Text(
|
||||||
|
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/pdf_service.dart';
|
||||||
|
|
||||||
|
class DetailScreen extends StatelessWidget {
|
||||||
|
final PostItem item;
|
||||||
|
|
||||||
|
const DetailScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(item.title, overflow: TextOverflow.ellipsis),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
|
tooltip: 'Als PDF drucken / teilen',
|
||||||
|
onPressed: () => PdfService.printSinglePost(item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (item.imageUrls.isNotEmpty)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.imageUrls.first,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
height: 180,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.image_not_supported, size: 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
formattedDate,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
if (item.status != null) ...[
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.blue.shade200),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.status!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue.shade800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Örtlichkeit: ${item.street}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.category != null && item.category!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.category, size: 16, color: Colors.orange),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Kategorie: ${item.category}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Beschreibung',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'contact_screen.dart';
|
||||||
|
import 'info_screen.dart';
|
||||||
|
import 'post_list_screen.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends StatefulWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _pages = const [
|
||||||
|
PostListScreen(
|
||||||
|
collectionName: 'News',
|
||||||
|
title: 'Bürgerforum Aktuelles',
|
||||||
|
),
|
||||||
|
PostListScreen(
|
||||||
|
collectionName: 'Issues',
|
||||||
|
title: 'Mängel & Anliegen',
|
||||||
|
),
|
||||||
|
PostListScreen(
|
||||||
|
collectionName: 'CitizensForum',
|
||||||
|
title: 'Ideen für Oggau',
|
||||||
|
),
|
||||||
|
ContactScreen(),
|
||||||
|
InfoScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
void _onItemTapped(int index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIndex = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _selectedIndex,
|
||||||
|
children: _pages,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
selectedItemColor: Colors.blue.shade800,
|
||||||
|
unselectedItemColor: Colors.grey.shade600,
|
||||||
|
selectedFontSize: 11,
|
||||||
|
unselectedFontSize: 11,
|
||||||
|
currentIndex: _selectedIndex,
|
||||||
|
onTap: _onItemTapped,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.newspaper_outlined),
|
||||||
|
activeIcon: Icon(Icons.newspaper),
|
||||||
|
label: 'Nachrichten',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.report_problem_outlined),
|
||||||
|
activeIcon: Icon(Icons.report_problem),
|
||||||
|
label: 'Mängel',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.lightbulb_outline),
|
||||||
|
activeIcon: Icon(Icons.lightbulb),
|
||||||
|
label: 'Ideen',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.mail_outline),
|
||||||
|
activeIcon: Icon(Icons.mail),
|
||||||
|
label: 'Kontakt',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
activeIcon: Icon(Icons.info),
|
||||||
|
label: 'Bürgerforum',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import 'auth_dialog.dart';
|
||||||
|
|
||||||
|
class InfoScreen extends StatelessWidget {
|
||||||
|
const InfoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthService>(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Bürgerforum Oggau'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
children: [
|
||||||
|
// Bürgerforum Logo & Branding Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
height: 72,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Bürgerforum Oggau',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Unabhängige Bürgerliste für Oggau am Neusiedler See',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Bürgerforum Access & Account Card
|
||||||
|
Card(
|
||||||
|
color: auth.isRegisteredUser
|
||||||
|
? Colors.green.shade50
|
||||||
|
: auth.isCommunityUnlocked
|
||||||
|
? Colors.teal.shade50
|
||||||
|
: Colors.blue.shade50,
|
||||||
|
shape:
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
auth.isRegisteredUser
|
||||||
|
? Icons.account_circle
|
||||||
|
: auth.isCommunityUnlocked
|
||||||
|
? Icons.verified_user
|
||||||
|
: Icons.lock_outline,
|
||||||
|
color: auth.isRegisteredUser
|
||||||
|
? Colors.green.shade700
|
||||||
|
: auth.isCommunityUnlocked
|
||||||
|
? Colors.teal.shade800
|
||||||
|
: Colors.blue.shade800,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
auth.isRegisteredUser
|
||||||
|
? (auth.displayName?.isNotEmpty == true
|
||||||
|
? auth.displayName!
|
||||||
|
: 'Bürger-Konto')
|
||||||
|
: auth.isCommunityUnlocked
|
||||||
|
? 'Bürgerzugang: Aktiv (Code)'
|
||||||
|
: 'Gastmodus (Nur Lesen)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
auth.isRegisteredUser
|
||||||
|
? auth.email ?? 'Angemeldet'
|
||||||
|
: auth.isCommunityUnlocked
|
||||||
|
? 'Mängel & Ideen freigeschaltet'
|
||||||
|
: 'Anmelden oder Code eingeben zum Mitwirken.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (auth.isRegisteredUser || auth.isCommunityUnlocked) {
|
||||||
|
auth.lockCommunityAccess();
|
||||||
|
} else {
|
||||||
|
AuthDialog.show(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
(auth.isRegisteredUser || auth.isCommunityUnlocked)
|
||||||
|
? 'Abmelden'
|
||||||
|
: 'Anmelden',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[
|
||||||
|
const Divider(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => AuthDialog.show(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_add,
|
||||||
|
size: 16, color: Colors.blue.shade800),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Persönliches Konto anlegen für dauerhaften Login',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.blue.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// About Bürgerforum Oggau
|
||||||
|
Card(
|
||||||
|
elevation: 1.5,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.campaign, color: Colors.blue.shade800, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Über das Bürgerforum Oggau',
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Das Bürgerforum Oggau ist eine unabhängige Bürgerliste für unsere Marktgemeinde Oggau am Neusiedler See.\n\n'
|
||||||
|
'Wir stehen für Bürgernähe, Transparenz und eine zukunftsorientierte Gemeindepolitik. '
|
||||||
|
'Mit dieser App (unserem digitalen „Gemeindetrommler“) wollen wir allen Bürgerinnen und Bürgern eine unkomplizierte Möglichkeit geben, Mängel im Ort aufzuzeigen, Ideen einzubringen und sich zu informieren.',
|
||||||
|
style: TextStyle(fontSize: 13, height: 1.45),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.group, size: 16, color: Colors.grey),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Gemeindevertretung & Team Bürgerforum',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Gemeinde Bürgerservice (Nützliche Kontakte)
|
||||||
|
Card(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Gemeindeamt & Bürgerservice Oggau',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Adresse: Hauptstraße 52, 7063 Oggau', style: TextStyle(fontSize: 13)),
|
||||||
|
Text('Telefon: +43 2685 7201', style: TextStyle(fontSize: 13)),
|
||||||
|
Text('Web: www.oggau.at', style: TextStyle(fontSize: 13)),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Öffnungszeiten Gemeindeamt:',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text('Mo – Fr: 08:00 – 12:00 Uhr | Di zusätzlich: 13:00 – 17:00 Uhr',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Wichtige Notrufnummern
|
||||||
|
Card(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Wichtige Notrufnummern',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.red),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Feuerwehr'),
|
||||||
|
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Polizei'),
|
||||||
|
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Rettung / Notarzt'),
|
||||||
|
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Euronotruf'),
|
||||||
|
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Impressum
|
||||||
|
Card(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Impressum',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Medieninhaber & Herausgeber:\n'
|
||||||
|
'Bürgerforum Oggau (Unabhängige Bürgerliste Oggau)\n'
|
||||||
|
'7063 Oggau am Neusiedler See\n\n'
|
||||||
|
'Zweck der App:\n'
|
||||||
|
'Informations- und Mitmach-Plattform für Bürgerinnen und Bürger der Marktgemeinde Oggau am Neusiedler See.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.black87, height: 1.4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/firestore_service.dart';
|
||||||
|
import '../services/pdf_service.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
|
import 'add_issue_screen.dart';
|
||||||
|
import 'add_post_screen.dart';
|
||||||
|
import 'auth_dialog.dart';
|
||||||
|
import 'detail_screen.dart';
|
||||||
|
|
||||||
|
class PostListScreen extends StatefulWidget {
|
||||||
|
final String collectionName;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const PostListScreen({
|
||||||
|
super.key,
|
||||||
|
required this.collectionName,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostListScreen> createState() => _PostListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostListScreenState extends State<PostListScreen> {
|
||||||
|
final FirestoreService _firestoreService = FirestoreService();
|
||||||
|
final StorageService _storageService = StorageService();
|
||||||
|
|
||||||
|
final Map<String, PostItem> _selectedItems = {};
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isSearchActive = false;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(PostItem item) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedItems.containsKey(item.id)) {
|
||||||
|
_selectedItems.remove(item.id);
|
||||||
|
} else {
|
||||||
|
_selectedItems[item.id] = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deletePost(PostItem item) async {
|
||||||
|
try {
|
||||||
|
for (final url in item.imageUrls) {
|
||||||
|
await _storageService.deleteFileByUrl(url);
|
||||||
|
}
|
||||||
|
await _firestoreService.deletePost(widget.collectionName, item.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Eintrag gelöscht.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Fehler beim Löschen: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAddNewPressed() async {
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
|
||||||
|
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||||
|
await AuthDialog.show(context);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
|
||||||
|
if (widget.collectionName == 'Issues') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (ctx) => const AddIssueScreen()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => AddPostScreen(
|
||||||
|
collectionName: widget.collectionName,
|
||||||
|
screenTitle: widget.collectionName == 'News'
|
||||||
|
? 'Neue Nachricht'
|
||||||
|
: 'Neue Idee einbringen',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final currentUser = FirebaseAuth.instance.currentUser;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: _isSearchActive
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Suche in ${widget.title}...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintStyle: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
height: 26,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
|
||||||
|
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_isSearchActive) {
|
||||||
|
_isSearchActive = false;
|
||||||
|
_searchQuery = '';
|
||||||
|
_searchController.clear();
|
||||||
|
} else {
|
||||||
|
_isSearchActive = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_selectedItems.isNotEmpty) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
|
tooltip: 'Auswahl als PDF-Tabelle drucken',
|
||||||
|
onPressed: () => PdfService.printSelectedItemsTable(
|
||||||
|
items: _selectedItems.values.toList(),
|
||||||
|
title: widget.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.deselect),
|
||||||
|
tooltip: 'Auswahl aufheben',
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: StreamBuilder<List<PostItem>>(
|
||||||
|
stream: _firestoreService.getPostsStream(widget.collectionName),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Fehler beim Laden: ${snapshot.error}',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final allItems = snapshot.data ?? [];
|
||||||
|
final filteredItems = allItems.where((item) {
|
||||||
|
if (_searchQuery.isEmpty) return true;
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
final matchesTitle = item.title.toLowerCase().contains(query);
|
||||||
|
final matchesDesc = item.description.toLowerCase().contains(query);
|
||||||
|
final matchesStreet =
|
||||||
|
item.street?.toLowerCase().contains(query) ?? false;
|
||||||
|
final matchesCategory =
|
||||||
|
item.category?.toLowerCase().contains(query) ?? false;
|
||||||
|
return matchesTitle || matchesDesc || matchesStreet || matchesCategory;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filteredItems.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Keine Treffer für "$_searchQuery"'
|
||||||
|
: 'Noch keine Einträge vorhanden.',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: filteredItems.length,
|
||||||
|
separatorBuilder: (ctx, idx) => const Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 16,
|
||||||
|
endIndent: 16,
|
||||||
|
color: Colors.black12,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = filteredItems[index];
|
||||||
|
final isSelected = _selectedItems.containsKey(item.id);
|
||||||
|
final isAuthor =
|
||||||
|
currentUser != null && item.authorUid == currentUser.uid;
|
||||||
|
final formattedDate =
|
||||||
|
DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||||
|
|
||||||
|
Widget itemTile = ListTile(
|
||||||
|
selected: isSelected,
|
||||||
|
selectedTileColor: Colors.blue.shade50,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
onTap: () {
|
||||||
|
if (_selectedItems.isNotEmpty) {
|
||||||
|
_toggleSelection(item);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (ctx) => DetailScreen(item: item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggleSelection(item),
|
||||||
|
leading: item.imageUrl.isNotEmpty
|
||||||
|
? Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (ctx, url) => Container(
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (ctx, url, err) => Container(
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.image, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
const Positioned(
|
||||||
|
top: 2,
|
||||||
|
left: 2,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Icon(Icons.check, size: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: isSelected
|
||||||
|
? const CircleAvatar(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Icon(Icons.check, color: Colors.white),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
formattedDate,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
item.street!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAuthor) {
|
||||||
|
return Dismissible(
|
||||||
|
key: Key(item.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
color: Colors.red,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 20),
|
||||||
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (direction) async {
|
||||||
|
return await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => AlertDialog(
|
||||||
|
title: const Text('Eintrag löschen?'),
|
||||||
|
content: const Text(
|
||||||
|
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||||
|
child: const Text('Löschen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDismissed: (direction) => _deletePost(item),
|
||||||
|
child: itemTile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemTile;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _onAddNewPressed,
|
||||||
|
tooltip: 'Neuen Eintrag erstellen',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AuthService extends ChangeNotifier {
|
||||||
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
|
User? _user;
|
||||||
|
bool _isCommunityUnlocked = false;
|
||||||
|
|
||||||
|
static const String _prefKeyUnlocked = 'community_unlocked';
|
||||||
|
static const String defaultCommunityCode = 'oggau';
|
||||||
|
|
||||||
|
User? get user => _user;
|
||||||
|
bool get isAuthenticated => _user != null;
|
||||||
|
bool get isAnonymous => _user?.isAnonymous ?? true;
|
||||||
|
bool get isRegisteredUser => _user != null && !_user!.isAnonymous;
|
||||||
|
bool get isCommunityUnlocked => _isCommunityUnlocked || isRegisteredUser;
|
||||||
|
String? get email => _user?.email;
|
||||||
|
String? get displayName => _user?.displayName;
|
||||||
|
|
||||||
|
AuthService() {
|
||||||
|
_initAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initAuth() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
|
||||||
|
|
||||||
|
_auth.authStateChanges().listen((User? user) {
|
||||||
|
_user = user;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_user == null && _isCommunityUnlocked) {
|
||||||
|
await signInAnonymously();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies community access code
|
||||||
|
Future<bool> verifyCommunityCode(String enteredCode) async {
|
||||||
|
final cleanCode = enteredCode.trim().toLowerCase();
|
||||||
|
if (cleanCode == defaultCommunityCode ||
|
||||||
|
cleanCode == 'oggau2024' ||
|
||||||
|
cleanCode == 'oggau7063') {
|
||||||
|
_isCommunityUnlocked = true;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefKeyUnlocked, true);
|
||||||
|
if (_user == null) {
|
||||||
|
await signInAnonymously();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock community access and log out
|
||||||
|
Future<void> lockCommunityAccess() async {
|
||||||
|
_isCommunityUnlocked = false;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefKeyUnlocked, false);
|
||||||
|
await signOut();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign in with Email and Password
|
||||||
|
Future<String?> signInWithEmail(String email, String password) async {
|
||||||
|
try {
|
||||||
|
final credential = await _auth.signInWithEmailAndPassword(
|
||||||
|
email: email.trim(),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
_user = credential.user;
|
||||||
|
notifyListeners();
|
||||||
|
return null; // success
|
||||||
|
} on FirebaseAuthException catch (e) {
|
||||||
|
return _getLocalizedErrorMessage(e.code);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign up with Email, Password, and Full Name
|
||||||
|
Future<String?> signUpWithEmail({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String name,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final credential = await _auth.createUserWithEmailAndPassword(
|
||||||
|
email: email.trim(),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
_user = credential.user;
|
||||||
|
if (_user != null && name.isNotEmpty) {
|
||||||
|
await _user!.updateDisplayName(name.trim());
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return null; // success
|
||||||
|
} on FirebaseAuthException catch (e) {
|
||||||
|
return _getLocalizedErrorMessage(e.code);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Ein unerwarteter Fehler ist aufgetreten: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send password reset email
|
||||||
|
Future<String?> sendPasswordReset(String email) async {
|
||||||
|
try {
|
||||||
|
await _auth.sendPasswordResetEmail(email: email.trim());
|
||||||
|
return null; // success
|
||||||
|
} on FirebaseAuthException catch (e) {
|
||||||
|
return _getLocalizedErrorMessage(e.code);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Fehler beim Senden der E-Mail: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anonymous login
|
||||||
|
Future<void> signInAnonymously() async {
|
||||||
|
try {
|
||||||
|
await _auth.signInAnonymously();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Sign in anonymously error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await _auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Sign out error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getLocalizedErrorMessage(String code) {
|
||||||
|
switch (code) {
|
||||||
|
case 'user-not-found':
|
||||||
|
return 'Kein Benutzer mit dieser E-Mail-Adresse gefunden.';
|
||||||
|
case 'wrong-password':
|
||||||
|
case 'invalid-credential':
|
||||||
|
return 'Falsches Passwort oder ungültige Anmeldedaten.';
|
||||||
|
case 'email-already-in-use':
|
||||||
|
return 'Diese E-Mail-Adresse wird bereits für ein Konto verwendet.';
|
||||||
|
case 'invalid-email':
|
||||||
|
return 'Bitte gib eine gültige E-Mail-Adresse ein.';
|
||||||
|
case 'weak-password':
|
||||||
|
return 'Das Passwort ist zu schwach (mindestens 6 Zeichen erforderlich).';
|
||||||
|
case 'too-many-requests':
|
||||||
|
return 'Zu viele fehlgeschlagene Versuche. Bitte warte kurz.';
|
||||||
|
case 'network-request-failed':
|
||||||
|
return 'Keine Internetverbindung vorhanden.';
|
||||||
|
default:
|
||||||
|
return 'Authentifizierungsfehler ($code).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
|
||||||
|
class FirestoreService {
|
||||||
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
|
||||||
|
FirestoreService() {
|
||||||
|
_initSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initSettings() {
|
||||||
|
try {
|
||||||
|
_firestore.settings = const Settings(
|
||||||
|
persistenceEnabled: true,
|
||||||
|
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Firestore settings initialization error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream of posts ordered by creation date descending
|
||||||
|
Stream<List<PostItem>> getPostsStream(String collectionName) {
|
||||||
|
return _firestore
|
||||||
|
.collection(collectionName)
|
||||||
|
.orderBy('createdDate', descending: true)
|
||||||
|
.snapshots(includeMetadataChanges: true)
|
||||||
|
.map((snapshot) =>
|
||||||
|
snapshot.docs.map((doc) => PostItem.fromFirestore(doc)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new post / issue / idea
|
||||||
|
Future<DocumentReference> addPost(
|
||||||
|
String collectionName, PostItem item) async {
|
||||||
|
return await _firestore.collection(collectionName).add(item.toFirestore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a post by ID
|
||||||
|
Future<void> deletePost(String collectionName, String documentId) async {
|
||||||
|
await _firestore.collection(collectionName).doc(documentId).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a contact form submission to Firestore
|
||||||
|
Future<void> submitContactMessage({
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
required String email,
|
||||||
|
String? phone,
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
await _firestore.collection('ContactMessages').add({
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone ?? '',
|
||||||
|
'message': message,
|
||||||
|
'createdDate': Timestamp.now(),
|
||||||
|
'status': 'Neu',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import '../models/post_item.dart';
|
||||||
|
|
||||||
|
class PdfService {
|
||||||
|
static Future<pw.ImageProvider?> _fetchImage(String url) async {
|
||||||
|
if (url.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final Uint8List bytes = response.bodyBytes;
|
||||||
|
// Optionally resize image to save PDF memory
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded != null) {
|
||||||
|
final resized = img.copyResize(decoded, width: 300);
|
||||||
|
final encoded = Uint8List.fromList(img.encodeJpg(resized, quality: 80));
|
||||||
|
return pw.MemoryImage(encoded);
|
||||||
|
}
|
||||||
|
return pw.MemoryImage(bytes);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Failed to load image for PDF: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print single post item as PDF
|
||||||
|
static Future<void> printSinglePost(PostItem item) async {
|
||||||
|
final pdf = pw.Document();
|
||||||
|
final formattedDate = DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
|
||||||
|
final pw.ImageProvider? imageProvider = await _fetchImage(item.imageUrl);
|
||||||
|
|
||||||
|
pdf.addPage(
|
||||||
|
pw.MultiPage(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: const pw.EdgeInsets.all(32),
|
||||||
|
build: (context) => [
|
||||||
|
pw.Header(
|
||||||
|
level: 0,
|
||||||
|
child: pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Marktgemeinde Oggau',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: PdfColors.blue800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
formattedDate,
|
||||||
|
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
pw.Text(
|
||||||
|
item.title,
|
||||||
|
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (item.street != null && item.street!.isNotEmpty) ...[
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Text(
|
||||||
|
'Örtlichkeit / Straße: ${item.street}',
|
||||||
|
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.category != null && item.category!.isNotEmpty) ...[
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text(
|
||||||
|
'Kategorie: ${item.category}',
|
||||||
|
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pw.Divider(thickness: 1, color: PdfColors.grey400),
|
||||||
|
pw.SizedBox(height: 10),
|
||||||
|
if (imageProvider != null) ...[
|
||||||
|
pw.Center(
|
||||||
|
child: pw.Container(
|
||||||
|
height: 220,
|
||||||
|
child: pw.Image(imageProvider, fit: pw.BoxFit.contain),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 14),
|
||||||
|
],
|
||||||
|
pw.Text(
|
||||||
|
'Beschreibung:',
|
||||||
|
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Paragraph(
|
||||||
|
text: item.description,
|
||||||
|
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final cleanTitle = item.title.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||||
|
await Printing.sharePdf(
|
||||||
|
bytes: await pdf.save(),
|
||||||
|
filename: '$cleanTitle.pdf',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a list of selected items in table format for Bauhof / Gemeinde
|
||||||
|
static Future<void> printSelectedItemsTable({
|
||||||
|
required List<PostItem> items,
|
||||||
|
required String title,
|
||||||
|
}) async {
|
||||||
|
final pdf = pw.Document();
|
||||||
|
|
||||||
|
// Preload all images in parallel
|
||||||
|
final Map<String, pw.ImageProvider?> imageMap = {};
|
||||||
|
await Future.wait(items.map((item) async {
|
||||||
|
if (item.imageUrl.isNotEmpty) {
|
||||||
|
imageMap[item.id] = await _fetchImage(item.imageUrl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
pdf.addPage(
|
||||||
|
pw.MultiPage(
|
||||||
|
pageFormat: PdfPageFormat.a4.landscape,
|
||||||
|
margin: const pw.EdgeInsets.all(24),
|
||||||
|
header: (context) => pw.Container(
|
||||||
|
alignment: pw.Alignment.centerLeft,
|
||||||
|
margin: const pw.EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const pw.EdgeInsets.only(bottom: 6),
|
||||||
|
decoration: const pw.BoxDecoration(
|
||||||
|
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.blue800, width: 2)),
|
||||||
|
),
|
||||||
|
child: pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Oggauer Gemeindetrommler - $title',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: PdfColors.blue800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
'Erstellt am: ${DateFormat('dd.MM.yyyy HH:mm').format(DateTime.now())}',
|
||||||
|
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
build: (context) => [
|
||||||
|
pw.TableHelper.fromTextArray(
|
||||||
|
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
|
||||||
|
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||||
|
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||||
|
cellHeight: 65,
|
||||||
|
cellAlignments: {
|
||||||
|
0: pw.Alignment.centerLeft,
|
||||||
|
1: pw.Alignment.centerLeft,
|
||||||
|
2: pw.Alignment.centerLeft,
|
||||||
|
3: pw.Alignment.centerLeft,
|
||||||
|
4: pw.Alignment.center,
|
||||||
|
},
|
||||||
|
headers: <String>['Datum', 'Örtlichkeit / Kat.', 'Titel & Mangel', 'Status', 'Foto'],
|
||||||
|
data: items.map((item) {
|
||||||
|
final formattedDate = DateFormat('dd.MM.yyyy').format(item.createdDate);
|
||||||
|
final locInfo = '${item.street ?? "Keine Angabe"}\n(${item.category ?? "-"})';
|
||||||
|
final descSnippet = item.description.length > 120
|
||||||
|
? '${item.description.substring(0, 120)}...'
|
||||||
|
: item.description;
|
||||||
|
|
||||||
|
return [
|
||||||
|
formattedDate,
|
||||||
|
locInfo,
|
||||||
|
'${item.title}\n$descSnippet',
|
||||||
|
item.status ?? 'Gemeldet',
|
||||||
|
imageMap[item.id] != null ? 'Foto vorhanden' : '-',
|
||||||
|
];
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final filename = '${title.toLowerCase().replaceAll(' ', '_')}_tabelle.pdf';
|
||||||
|
await Printing.sharePdf(bytes: await pdf.save(), filename: filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||||
|
|
||||||
|
/// Compresses and resizes an image to maximum 800x600 landscape bounds
|
||||||
|
Future<File> optimizeImage(File originalFile) async {
|
||||||
|
try {
|
||||||
|
final bytes = await originalFile.readAsBytes();
|
||||||
|
final decoded = img.decodeImage(bytes);
|
||||||
|
if (decoded == null) return originalFile;
|
||||||
|
|
||||||
|
// Ensure landscape orientation or maximum 800x600 resolution
|
||||||
|
int targetWidth = decoded.width;
|
||||||
|
int targetHeight = decoded.height;
|
||||||
|
|
||||||
|
const int maxW = 800;
|
||||||
|
const int maxH = 600;
|
||||||
|
|
||||||
|
if (targetWidth > maxW || targetHeight > maxH) {
|
||||||
|
double ratioW = maxW / targetWidth;
|
||||||
|
double ratioH = maxH / targetHeight;
|
||||||
|
double scale = min(ratioW, ratioH);
|
||||||
|
|
||||||
|
targetWidth = (targetWidth * scale).round();
|
||||||
|
targetHeight = (targetHeight * scale).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
final resized = img.copyResize(
|
||||||
|
decoded,
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
|
||||||
|
final tempDir = originalFile.parent.path;
|
||||||
|
final optimizedFile = File(
|
||||||
|
'$tempDir/optimized_${DateTime.now().millisecondsSinceEpoch}.jpg');
|
||||||
|
await optimizedFile.writeAsBytes(img.encodeJpg(resized, quality: 85));
|
||||||
|
|
||||||
|
return optimizedFile;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Image optimization fallback: $e');
|
||||||
|
}
|
||||||
|
return originalFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads a file to Firebase Storage under $collectionName/$userId/$timestamp.jpg
|
||||||
|
Future<String> uploadFile({
|
||||||
|
required File file,
|
||||||
|
required String collectionName,
|
||||||
|
bool optimize = true,
|
||||||
|
}) async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
final userId = user?.uid ?? 'anonymous';
|
||||||
|
|
||||||
|
File fileToUpload = file;
|
||||||
|
if (optimize) {
|
||||||
|
fileToUpload = await optimizeImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
final filename = '${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final ref = _storage.ref().child('$collectionName/$userId/$filename');
|
||||||
|
|
||||||
|
final uploadTask = ref.putFile(fileToUpload);
|
||||||
|
final snapshot = await uploadTask;
|
||||||
|
return await snapshot.ref.getDownloadURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a file by download URL
|
||||||
|
Future<void> deleteFileByUrl(String url) async {
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final ref = _storage.refFromURL(url);
|
||||||
|
await ref.delete();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Error deleting storage file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-1
@@ -329,7 +329,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
|
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
|
||||||
@@ -528,6 +528,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.6"
|
version: "1.0.6"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -656,6 +664,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.12.0"
|
version: "5.12.0"
|
||||||
|
provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: provider
|
||||||
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5+1"
|
||||||
qr:
|
qr:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -672,6 +688,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.3"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.3"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
+4
-4
@@ -52,6 +52,8 @@ dependencies:
|
|||||||
image_cropper: ^8.0.1
|
image_cropper: ^8.0.1
|
||||||
logging: ^1.1.1
|
logging: ^1.1.1
|
||||||
provider: ^6.0.5
|
provider: ^6.0.5
|
||||||
|
http: ^1.2.0
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@@ -76,10 +78,8 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/images/
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service firebase.storage {
|
||||||
|
match /b/{bucket}/o {
|
||||||
|
|
||||||
|
// Fotos von News, Mängeln und Ideen
|
||||||
|
match /{collection}/{userId}/{fileName} {
|
||||||
|
// Jeder darf Bilder in der App laden/ansehen
|
||||||
|
allow read: if true;
|
||||||
|
|
||||||
|
// Nur angemeldete Nutzer dürfen Fotos hochladen (max. 10 MB, nur Bilder)
|
||||||
|
allow create, update: if request.auth != null
|
||||||
|
&& request.resource.size < 10 * 1024 * 1024
|
||||||
|
&& request.resource.contentType.matches('image/.*');
|
||||||
|
|
||||||
|
// Nur der Verfasser (oder berechtigte Nutzer) darf sein Foto löschen
|
||||||
|
allow delete: if request.auth != null && request.auth.uid == userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-22
@@ -1,30 +1,45 @@
|
|||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:oggauergemeindetrommler/models/post_item.dart';
|
||||||
import 'package:oggauergemeindetrommler/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
group('PostItem Model Tests', () {
|
||||||
// Build our app and trigger a frame.
|
test('creates PostItem with default values', () {
|
||||||
await tester.pumpWidget(const MyApp());
|
final now = DateTime.now();
|
||||||
|
final item = PostItem(
|
||||||
|
id: 'test-123',
|
||||||
|
title: 'Defekte Straßenlaterne',
|
||||||
|
description: 'Laterne vor Haus Nr. 12 leuchtet nicht mehr.',
|
||||||
|
imageUrl: 'https://example.com/test.jpg',
|
||||||
|
createdDate: now,
|
||||||
|
street: 'Hauptstraße',
|
||||||
|
category: 'Straßenbeleuchtung',
|
||||||
|
);
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
expect(item.id, 'test-123');
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(item.title, 'Defekte Straßenlaterne');
|
||||||
expect(find.text('1'), findsNothing);
|
expect(item.street, 'Hauptstraße');
|
||||||
|
expect(item.category, 'Straßenbeleuchtung');
|
||||||
|
expect(item.imageUrls.length, 1);
|
||||||
|
expect(item.status, 'Gemeldet');
|
||||||
|
});
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
test('serializes to Firestore map accurately', () {
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
final now = DateTime.now();
|
||||||
await tester.pump();
|
final item = PostItem(
|
||||||
|
id: 'test-456',
|
||||||
|
title: 'Müllablagerung am See',
|
||||||
|
description: 'Illegale Ablagerung am Parkplatz',
|
||||||
|
createdDate: now,
|
||||||
|
street: 'Hafen / Seebad',
|
||||||
|
category: 'Müll / Verunreinigung',
|
||||||
|
status: 'In Bearbeitung',
|
||||||
|
);
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
final map = item.toFirestore();
|
||||||
expect(find.text('0'), findsNothing);
|
expect(map['title'], 'Müllablagerung am See');
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(map['street'], 'Hafen / Seebad');
|
||||||
|
expect(map['category'], 'Müll / Verunreinigung');
|
||||||
|
expect(map['status'], 'In Bearbeitung');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user