Refactor architecture, fix all linter issues, and implement specification features (5 tabs, issue dropdowns, contact, info, search, pdf)

This commit is contained in:
2026-09-13 15:30:36 +02:00
parent 239f399af1
commit e6ce9c5012
24 changed files with 2486 additions and 1375 deletions
-358
View File
@@ -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,
),
),
),
),
),
],
),
),
),
);
}
}
-130
View File
@@ -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,
),
),
),
],
),
),
);
}
}
-100
View File
@@ -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'),
),
],
),
],
),
),
],
),
);
}
}
-469
View File
@@ -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();
}
}
-55
View File
@@ -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'),
);
},
),
),
);
}
}
-63
View File
@@ -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
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'anonym_aut.dart';
import 'home.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'screens/home_screen.dart';
import 'services/auth_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -12,24 +12,40 @@ void main() async {
);
runApp(
ChangeNotifierProvider(
create: (context) => AuthState(),
create: (context) => AuthService(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(
return MaterialApp(
title: 'Oggauer Gemeindetrommler',
home: MyHomePage(title: 'Oggauer Gemeindetrommler'),
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(),
);
}
}
+73
View File
@@ -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,
};
}
}
-98
View File
@@ -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'),
),
],
),
),
],
),
);
}
}
+356
View File
@@ -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),
),
),
),
],
),
),
),
);
}
}
+268
View File
@@ -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),
),
),
),
],
),
),
),
);
}
}
+220
View File
@@ -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),
),
),
),
],
),
),
),
);
}
}
+160
View File
@@ -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,
),
);
},
),
),
],
],
),
),
);
}
}
+84
View File
@@ -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',
),
],
),
);
}
}
+258
View File
@@ -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),
),
],
),
),
),
],
),
);
}
}
+403
View File
@@ -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),
),
);
}
}
+77
View File
@@ -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');
}
}
}
}
+64
View File
@@ -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',
});
}
}
+197
View File
@@ -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);
}
}
+89
View File
@@ -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');
}
}
}
}