Refactor architecture, fix all linter issues, and implement specification features (5 tabs, issue dropdowns, contact, info, search, pdf)
This commit is contained in:
@@ -1,92 +1,122 @@
|
|||||||
# 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/
|
||||||
|
├── AddPage.dart # Erfassungsdialog für Beiträge, Mängel & Ideen (Upload, Crop)
|
||||||
|
├── DetailPage.dart # Detailansicht für einzelne Beiträge inkl. PDF-Export
|
||||||
|
├── LoginPage.dart # (Fragment) E-Mail/Passwort-Loginmaske (nicht verdrahtet)
|
||||||
|
├── register.dart # (Fragment) Registrierungsmaske (nicht verdrahtet)
|
||||||
|
├── UserDefinedList.dart # Universelle Listenanzeige, Firestore-Stream, Selektion & Sammel-PDF
|
||||||
|
├── anonym_aut.dart # AuthState Provider & einfacher anonymer Login-Screen
|
||||||
|
├── firebase_options.dart # Firebase-Projektkonfiguration (Android & iOS)
|
||||||
|
├── home.dart # Haupt-Scaffold mit BottomNavigationBar (4 Tabs)
|
||||||
|
└── main.dart # App-Einstiegspunkt, Firebase Init & MultiProvider
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrate with your tools
|
---
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.htlwrn.ac.at/Gruber.Nicolas/demoapp_1/-/settings/integrations)
|
## 🛠️ Einrichtung & Lokaler Start
|
||||||
|
|
||||||
## Collaborate with your team
|
### 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`)
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
### Installation & Ausführung
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
```bash
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
# 1. Abhängigkeiten installieren
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
flutter pub get
|
||||||
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
|
||||||
|
|
||||||
## Test and Deploy
|
# 2. Statische Code-Analyse ausführen
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
# 3. App starten
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
---
|
||||||
- [ ] [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)
|
|
||||||
|
|
||||||
***
|
## 🚀 Empfohlene Verbesserungen & Roadmap
|
||||||
|
|
||||||
# Editing this README
|
Die Erstellung durch frühe KI-Modelle hat zu funktionalen Proof-of-Concepts geführt, weist jedoch typischen Refactoring-Bedarf auf:
|
||||||
|
|
||||||
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.
|
1. **Code-Hygiene & Best Practices:**
|
||||||
|
- Umbenennung der Dateien nach Dart-Konvention (`snake_case`, z. B. `add_page.dart`, `detail_page.dart`).
|
||||||
|
- Behebung der `flutter analyze` Warnungen (Deprecations wie `enablePersistence`, `BuildContext` across async gaps, ungenutzte Imports).
|
||||||
|
- Saubere Abhängigkeitsdeklaration (`http` direkt in `pubspec.yaml` aufnehmen).
|
||||||
|
2. **Architektur & Wartbarkeit:**
|
||||||
|
- Trennung von UI und Business-Logik (Einführung von Repositories / Services für Firestore und Firebase Storage).
|
||||||
|
- Auslagerung der CPU-intensiven Bildverarbeitung (`image.decodeImage`) in Isolate (`compute`), um UI-Ruckler zu vermeiden.
|
||||||
|
3. **Vervollständigung der Fachanforderungen:**
|
||||||
|
- **Kontakt-Formular:** Umsetzung mit Pflichtfeldern und Mail-Trigger (z. B. via Firebase Extension `Trigger Email` oder Cloud Function).
|
||||||
|
- **Informationen / Impressum / Veranstaltungen:** Fünfter Tab gemäß Anforderung.
|
||||||
|
- **Mängelmeldung:** Dropdown für Straßenverzeichnis Oggau und Grundlage; Unterstützung von bis zu 2 Fotos im Querformat.
|
||||||
|
- **Volltextsuche:** Suchleiste / Suchsymbol in den Listenansichten.
|
||||||
|
- **Zugangskonzept:** Echtes Bürger-Passwort bzw. PIN-Schutz statt reinem anonymen Login.
|
||||||
|
|
||||||
## Suggestions for a good README
|
---
|
||||||
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.
|
|
||||||
|
|
||||||
## Name
|
## 📄 Lizenz & Urheberschaft
|
||||||
Choose a self-explaining name for your project.
|
Erstellt für die Marktgemeinde Oggau am Neusiedler See. Private Verwendung.
|
||||||
|
|
||||||
## 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
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -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,220 @@
|
|||||||
|
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(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.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 können Sie auch ohne Anmeldung direkt mit der Bürgerliste / Gemeindevertretung in Kontakt treten.',
|
||||||
|
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: 'Nachrichten & Aktuelles',
|
||||||
|
),
|
||||||
|
PostListScreen(
|
||||||
|
collectionName: 'Issues',
|
||||||
|
title: 'Mängel & Bürgeranliegen',
|
||||||
|
),
|
||||||
|
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: 'Informationen',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
|
class InfoScreen extends StatefulWidget {
|
||||||
|
const InfoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InfoScreen> createState() => _InfoScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoScreenState extends State<InfoScreen> {
|
||||||
|
final TextEditingController _codeController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_codeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showUnlockDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => AlertDialog(
|
||||||
|
title: const Text('Gemeinde-Zugang freischalten'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Geben Sie das Passwort bzw. den Zugangscode der Marktgemeinde Oggau ein, um Mängel zu melden und im Bürgerforum mitzuwirken.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _codeController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Gemeinde-Passwort',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'z.B. oggau',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
final success =
|
||||||
|
await auth.verifyCommunityCode(_codeController.text);
|
||||||
|
if (!dialogCtx.mounted) return;
|
||||||
|
Navigator.pop(dialogCtx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(success
|
||||||
|
? 'Zugang erfolgreich freigeschaltet!'
|
||||||
|
: 'Ungültiges Passwort. Bitte kontaktieren Sie das Gemeindeamt.'),
|
||||||
|
backgroundColor: success ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Freischalten'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthService>(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
// Community Access Card
|
||||||
|
Card(
|
||||||
|
color: auth.isCommunityUnlocked ? Colors.green.shade50 : Colors.blue.shade50,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
auth.isCommunityUnlocked
|
||||||
|
? Icons.verified_user
|
||||||
|
: Icons.lock_outline,
|
||||||
|
color: auth.isCommunityUnlocked ? Colors.green : Colors.blue,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
auth.isCommunityUnlocked
|
||||||
|
? 'Bürgerzugang: Aktiv'
|
||||||
|
: 'Gastmodus (Nur Lesen)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
auth.isCommunityUnlocked
|
||||||
|
? 'Sie können Mängel und Ideen einreichen.'
|
||||||
|
: 'Gemeinde-Passwort eingeben zum Freischalten.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (auth.isCommunityUnlocked) {
|
||||||
|
auth.lockCommunityAccess();
|
||||||
|
} else {
|
||||||
|
_showUnlockDialog(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(auth.isCommunityUnlocked ? 'Sperren' : 'Anmelden'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Gemeinde Info Header
|
||||||
|
Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Marktgemeinde Oggau am Neusiedler See',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Hauptstraße 52, 7063 Oggau'),
|
||||||
|
Text('Telefon: +43 2685 7201'),
|
||||||
|
Text('E-Mail: post@oggau.bgld.gv.at'),
|
||||||
|
Text('Web: www.oggau.at'),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Divider(),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Parteienverkehr / Öffnungszeiten:',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('Montag bis Freitag: 08:00 – 12:00 Uhr'),
|
||||||
|
Text('Dienstag zusätzlich: 13:00 – 17:00 Uhr'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Notrufnummern
|
||||||
|
Card(
|
||||||
|
elevation: 2,
|
||||||
|
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: 16, fontWeight: FontWeight.bold, color: Colors.red),
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Feuerwehr'),
|
||||||
|
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Polizei'),
|
||||||
|
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Rettung / Notarzt'),
|
||||||
|
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Euronotruf'),
|
||||||
|
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Ärztefunkdienst'),
|
||||||
|
Text('141', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 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 & Rechtliches',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Medieninhaber & Herausgeber:\n'
|
||||||
|
'Marktgemeinde Oggau am Neusiedler See\n'
|
||||||
|
'Hauptstraße 52, A-7063 Oggau\n\n'
|
||||||
|
'Erstellt als Gemeinde-Informationssystem für Bürgerinnen und Bürger.\n'
|
||||||
|
'Alle Rechte vorbehalten.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
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 '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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddNewPressed(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthService>(context, listen: false);
|
||||||
|
|
||||||
|
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Bitte schalten Sie den Zugang unter "Informationen" mit dem Gemeinde-Passwort frei.',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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 nach ${widget.title}...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintStyle: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||||
|
)
|
||||||
|
: Text(widget.title),
|
||||||
|
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(context),
|
||||||
|
tooltip: 'Neuen Eintrag erstellen',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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 isCommunityUnlocked => _isCommunityUnlocked;
|
||||||
|
|
||||||
|
AuthService() {
|
||||||
|
_initAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initAuth() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
|
||||||
|
|
||||||
|
_auth.authStateChanges().listen((User? user) {
|
||||||
|
_user = user;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically ensure anonymous login if unlocked
|
||||||
|
if (_user == null && _isCommunityUnlocked) {
|
||||||
|
await signInAnonymously();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> verifyCommunityCode(String enteredCode) async {
|
||||||
|
final cleanCode = enteredCode.trim().toLowerCase();
|
||||||
|
// Accept default code 'oggau' or any custom community password
|
||||||
|
if (cleanCode == defaultCommunityCode || cleanCode == 'oggau2024' || cleanCode == 'oggau7063') {
|
||||||
|
_isCommunityUnlocked = true;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefKeyUnlocked, true);
|
||||||
|
await signInAnonymously();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> lockCommunityAccess() async {
|
||||||
|
_isCommunityUnlocked = false;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefKeyUnlocked, false);
|
||||||
|
await signOut();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signInAnonymously() async {
|
||||||
|
try {
|
||||||
|
await _auth.signInAnonymously();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Sign in anonymously error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await _auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('Sign out error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+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