Refactor architecture, fix all linter issues, and implement specification features (5 tabs, issue dropdowns, contact, info, search, pdf)
This commit is contained in:
@@ -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: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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user