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

This commit is contained in:
2026-09-13 15:30:36 +02:00
parent 239f399af1
commit e6ce9c5012
24 changed files with 2486 additions and 1375 deletions
+356
View File
@@ -0,0 +1,356 @@
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../models/post_item.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddIssueScreen extends StatefulWidget {
const AddIssueScreen({super.key});
@override
State<AddIssueScreen> createState() => _AddIssueScreenState();
}
class _AddIssueScreenState extends State<AddIssueScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
final List<File> _selectedImages = [];
bool _isSaving = false;
final List<String> _streets = [
'Hauptstraße',
'Seestraße',
'Florianigasse',
'Sebastiansplatz',
'Sportplatzgasse',
'Feldgasse',
'Kirchengasse',
'Quergasse',
'Weinberggasse',
'Hafen / Seebad',
'Flur / Rad- & Wanderwege',
'Sonstige / Außerorts',
];
final List<String> _categories = [
'Fahrbahn / Gehsteig / Radweg',
'Straßenbeleuchtung',
'Grünflächen / Bäume',
'Müll / Verunreinigung',
'Spielplatz / Freizeiteinrichtung',
'Kanal / Entwässerung',
'Verkehrszeichen / Markierung',
'Sonstiges',
];
String? _selectedStreet;
String? _selectedCategory;
@override
void initState() {
super.initState();
_selectedStreet = _streets.first;
_selectedCategory = _categories.first;
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
if (_selectedImages.length >= 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Es können maximal 2 Fotos hinzugefügt werden.')),
);
return;
}
try {
final picked = await ImagePicker().pickImage(
source: source,
maxWidth: 1600,
maxHeight: 1200,
imageQuality: 85,
);
if (picked != null) {
setState(() {
_selectedImages.add(File(picked.path));
});
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
);
}
}
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Foto aus Galerie wählen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Foto mit Kamera aufnehmen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
],
),
),
);
}
Future<void> _submitIssue() async {
if (!_formKey.currentState!.validate() || _isSaving) return;
setState(() {
_isSaving = true;
});
try {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
// Upload selected images to Firebase Storage
final List<String> uploadedUrls = [];
for (final file in _selectedImages) {
final url = await _storageService.uploadFile(
file: file,
collectionName: 'Issues',
optimize: true,
);
uploadedUrls.add(url);
}
final newIssue = PostItem(
id: '',
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: uploadedUrls.isNotEmpty ? uploadedUrls.first : '',
imageUrls: uploadedUrls,
createdDate: DateTime.now(),
authorUid: currentUserId,
street: _selectedStreet,
category: _selectedCategory,
status: 'Gemeldet',
);
await _firestoreService.addPost('Issues', newIssue);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mangel wurde erfolgreich gemeldet!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mangel melden'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Mängelüberschrift *',
border: OutlineInputBorder(),
hintText: 'z.B. Schlagloch, defekte Straßenlaterne',
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Überschrift eingeben'
: null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedStreet,
decoration: const InputDecoration(
labelText: 'Straße / Örtlichkeit *',
border: OutlineInputBorder(),
),
items: _streets
.map((street) => DropdownMenuItem(
value: street,
child: Text(street),
))
.toList(),
onChanged: (val) => setState(() => _selectedStreet = val),
validator: (val) =>
(val == null || val.isEmpty) ? 'Bitte Ort auswählen' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Grundlage / Kategorie *',
border: OutlineInputBorder(),
),
items: _categories
.map((cat) => DropdownMenuItem(
value: cat,
child: Text(cat),
))
.toList(),
onChanged: (val) => setState(() => _selectedCategory = val),
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Mangeltext (Beschreibung) *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
hintText: 'Genaue Beschreibung des Schadens...',
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Beschreibung angeben'
: null,
),
const SizedBox(height: 20),
const Text(
'Fotos (maximal 2 im Querformat)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
Row(
children: [
..._selectedImages.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Stack(
children: [
Container(
margin: const EdgeInsets.only(right: 12),
width: 110,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(file),
fit: BoxFit.cover,
),
),
),
Positioned(
top: -4,
right: 4,
child: IconButton(
icon: const Icon(Icons.cancel, color: Colors.red),
onPressed: () {
setState(() {
_selectedImages.removeAt(index);
});
},
),
),
],
);
}),
if (_selectedImages.length < 2)
InkWell(
onTap: _showImageSourceModal,
child: Container(
width: 110,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.shade400,
style: BorderStyle.solid),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo, color: Colors.grey),
SizedBox(height: 4),
Text('Foto hinzufügen',
style: TextStyle(
fontSize: 11, color: Colors.black54)),
],
),
),
),
],
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _submitIssue,
icon: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check_circle_outline),
label: Text(
_isSaving ? 'Wird gespeichert...' : 'Mangel fertigstellen',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../models/post_item.dart';
import '../services/firestore_service.dart';
import '../services/storage_service.dart';
class AddPostScreen extends StatefulWidget {
final String collectionName;
final String screenTitle;
const AddPostScreen({
super.key,
required this.collectionName,
required this.screenTitle,
});
@override
State<AddPostScreen> createState() => _AddPostScreenState();
}
class _AddPostScreenState extends State<AddPostScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
File? _selectedImage;
bool _isSaving = false;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
try {
final picked = await ImagePicker().pickImage(
source: source,
maxWidth: 1600,
maxHeight: 1200,
imageQuality: 85,
);
if (picked != null) {
setState(() {
_selectedImage = File(picked.path);
});
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler bei der Bildauswahl: $e')),
);
}
}
void _showImageSourceModal() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Aus Galerie wählen'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Kamera verwenden'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
],
),
),
);
}
Future<void> _submitPost() async {
if (!_formKey.currentState!.validate() || _isSaving) return;
setState(() {
_isSaving = true;
});
try {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
String imageUrl = '';
if (_selectedImage != null) {
imageUrl = await _storageService.uploadFile(
file: _selectedImage!,
collectionName: widget.collectionName,
optimize: true,
);
}
final newPost = PostItem(
id: '',
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
imageUrl: imageUrl,
imageUrls: imageUrl.isNotEmpty ? [imageUrl] : [],
createdDate: DateTime.now(),
authorUid: currentUserId,
);
await _firestoreService.addPost(widget.collectionName, newPost);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eintrag erfolgreich veröffentlicht!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.screenTitle),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Überschrift *',
border: OutlineInputBorder(),
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Überschrift eingeben'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Inhalt / Text *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
validator: (val) => (val == null || val.trim().isEmpty)
? 'Bitte Text eingeben'
: null,
),
const SizedBox(height: 20),
const Text(
'Anhang / Beitragsbild',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
if (_selectedImage != null)
Stack(
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade400),
image: DecorationImage(
image: FileImage(_selectedImage!),
fit: BoxFit.cover,
),
),
),
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => setState(() => _selectedImage = null),
),
),
),
],
)
else
InkWell(
onTap: _showImageSourceModal,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade400),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate, size: 36, color: Colors.grey),
SizedBox(height: 6),
Text('Foto hinzufügen', style: TextStyle(color: Colors.black54)),
],
),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _submitPost,
icon: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.send),
label: Text(
_isSaving ? 'Wird gespeichert...' : 'Veröffentlichen',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import '../services/firestore_service.dart';
class ContactScreen extends StatefulWidget {
const ContactScreen({super.key});
@override
State<ContactScreen> createState() => _ContactScreenState();
}
class _ContactScreenState extends State<ContactScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _messageController = TextEditingController();
final FirestoreService _firestoreService = FirestoreService();
bool _isSubmitting = false;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_messageController.dispose();
super.dispose();
}
bool _isValidEmail(String email) {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate() || _isSubmitting) return;
setState(() {
_isSubmitting = true;
});
try {
await _firestoreService.submitContactMessage(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
message: _messageController.text.trim(),
);
if (!mounted) return;
_firstNameController.clear();
_lastNameController.clear();
_emailController.clear();
_phoneController.clear();
_messageController.clear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ihre Nachricht wurde erfolgreich übermittelt!'),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Senden: $e'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Colors.blue.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(14.0),
child: Row(
children: [
Icon(Icons.mail_outline, color: Colors.blue, size: 28),
SizedBox(width: 12),
Expanded(
child: Text(
'Hier können Sie auch ohne Anmeldung direkt mit der Bürgerliste / Gemeindevertretung in Kontakt treten.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Vorname *',
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Vorname eingeben'
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nachname *',
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Nachname eingeben'
: null,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse *',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Bitte E-Mail-Adresse eingeben';
}
if (!_isValidEmail(value.trim())) {
return 'Bitte eine gültige E-Mail eingeben';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Rückrufnummer (optional)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _messageController,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Ihr Anliegen / Ihre Nachricht *',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Bitte Ihre Nachricht eingeben'
: null,
),
const SizedBox(height: 12),
Text(
'Hinweis: Strafrechtlich relevante Kommentare und Verleumdungen werden polizeilich verfolgt.',
style: TextStyle(
fontSize: 11,
color: Colors.red.shade700,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _submitForm,
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(
_isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden',
style: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/post_item.dart';
import '../services/pdf_service.dart';
class DetailScreen extends StatelessWidget {
final PostItem item;
const DetailScreen({super.key, required this.item});
@override
Widget build(BuildContext context) {
final formattedDate =
DateFormat('dd.MM.yyyy HH:mm').format(item.createdDate);
return Scaffold(
appBar: AppBar(
title: Text(item.title, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Als PDF drucken / teilen',
onPressed: () => PdfService.printSinglePost(item),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.imageUrls.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: item.imageUrls.first,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 180,
color: Colors.grey.shade200,
child: const Icon(Icons.image_not_supported, size: 48),
),
),
),
const SizedBox(height: 16),
Text(
item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 6),
Text(
formattedDate,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
),
if (item.status != null) ...[
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
item.status!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800),
),
),
],
],
),
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.redAccent),
const SizedBox(width: 6),
Text(
'Örtlichkeit: ${item.street}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
],
if (item.category != null && item.category!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.category, size: 16, color: Colors.orange),
const SizedBox(width: 6),
Text(
'Kategorie: ${item.category}',
style: TextStyle(color: Colors.grey.shade800),
),
],
),
],
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
const Text(
'Beschreibung',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
item.description,
style: const TextStyle(fontSize: 15, height: 1.5),
),
const SizedBox(height: 24),
// Additional images gallery if there are multiple images
if (item.imageUrls.length > 1) ...[
const Text(
'Weitere Fotos',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: item.imageUrls.length - 1,
separatorBuilder: (ctx, i) => const SizedBox(width: 8),
itemBuilder: (ctx, index) {
final url = item.imageUrls[index + 1];
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 120,
height: 120,
fit: BoxFit.cover,
),
);
},
),
),
],
],
),
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'contact_screen.dart';
import 'info_screen.dart';
import 'post_list_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
final List<Widget> _pages = const [
PostListScreen(
collectionName: 'News',
title: 'Nachrichten & Aktuelles',
),
PostListScreen(
collectionName: 'Issues',
title: 'Mängel & Bürgeranliegen',
),
PostListScreen(
collectionName: 'CitizensForum',
title: 'Ideen für Oggau',
),
ContactScreen(),
InfoScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
selectedItemColor: Colors.blue.shade800,
unselectedItemColor: Colors.grey.shade600,
selectedFontSize: 11,
unselectedFontSize: 11,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.newspaper_outlined),
activeIcon: Icon(Icons.newspaper),
label: 'Nachrichten',
),
BottomNavigationBarItem(
icon: Icon(Icons.report_problem_outlined),
activeIcon: Icon(Icons.report_problem),
label: 'Mängel',
),
BottomNavigationBarItem(
icon: Icon(Icons.lightbulb_outline),
activeIcon: Icon(Icons.lightbulb),
label: 'Ideen',
),
BottomNavigationBarItem(
icon: Icon(Icons.mail_outline),
activeIcon: Icon(Icons.mail),
label: 'Kontakt',
),
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
activeIcon: Icon(Icons.info),
label: 'Informationen',
),
],
),
);
}
}
+258
View File
@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class InfoScreen extends StatefulWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
final TextEditingController _codeController = TextEditingController();
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
void _showUnlockDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Gemeinde-Zugang freischalten'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Geben Sie das Passwort bzw. den Zugangscode der Marktgemeinde Oggau ein, um Mängel zu melden und im Bürgerforum mitzuwirken.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
TextField(
controller: _codeController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Gemeinde-Passwort',
border: OutlineInputBorder(),
hintText: 'z.B. oggau',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () async {
final auth = Provider.of<AuthService>(context, listen: false);
final success =
await auth.verifyCommunityCode(_codeController.text);
if (!dialogCtx.mounted) return;
Navigator.pop(dialogCtx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Zugang erfolgreich freigeschaltet!'
: 'Ungültiges Passwort. Bitte kontaktieren Sie das Gemeindeamt.'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
},
child: const Text('Freischalten'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context);
return Scaffold(
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Community Access Card
Card(
color: auth.isCommunityUnlocked ? Colors.green.shade50 : Colors.blue.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
auth.isCommunityUnlocked
? Icons.verified_user
: Icons.lock_outline,
color: auth.isCommunityUnlocked ? Colors.green : Colors.blue,
size: 36,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isCommunityUnlocked
? 'Bürgerzugang: Aktiv'
: 'Gastmodus (Nur Lesen)',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
auth.isCommunityUnlocked
? 'Sie können Mängel und Ideen einreichen.'
: 'Gemeinde-Passwort eingeben zum Freischalten.',
style: TextStyle(
fontSize: 12, color: Colors.grey.shade700),
),
],
),
),
ElevatedButton(
onPressed: () {
if (auth.isCommunityUnlocked) {
auth.lockCommunityAccess();
} else {
_showUnlockDialog(context);
}
},
child: Text(auth.isCommunityUnlocked ? 'Sperren' : 'Anmelden'),
),
],
),
),
),
const SizedBox(height: 16),
// Gemeinde Info Header
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Marktgemeinde Oggau am Neusiedler See',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Hauptstraße 52, 7063 Oggau'),
Text('Telefon: +43 2685 7201'),
Text('E-Mail: post@oggau.bgld.gv.at'),
Text('Web: www.oggau.at'),
SizedBox(height: 12),
Divider(),
SizedBox(height: 8),
Text(
'Parteienverkehr / Öffnungszeiten:',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text('Montag bis Freitag: 08:00 – 12:00 Uhr'),
Text('Dienstag zusätzlich: 13:00 – 17:00 Uhr'),
],
),
),
),
const SizedBox(height: 16),
// Notrufnummern
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wichtige Notrufnummern',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.red),
),
SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Feuerwehr'),
Text('122', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Polizei'),
Text('133', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Rettung / Notarzt'),
Text('144', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Euronotruf'),
Text('112', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Ärztefunkdienst'),
Text('141', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
),
),
const SizedBox(height: 16),
// Impressum
Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Impressum & Rechtliches',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Medieninhaber & Herausgeber:\n'
'Marktgemeinde Oggau am Neusiedler See\n'
'Hauptstraße 52, A-7063 Oggau\n\n'
'Erstellt als Gemeinde-Informationssystem für Bürgerinnen und Bürger.\n'
'Alle Rechte vorbehalten.',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
],
),
),
),
],
),
);
}
}
+403
View File
@@ -0,0 +1,403 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/post_item.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../services/pdf_service.dart';
import '../services/storage_service.dart';
import 'add_issue_screen.dart';
import 'add_post_screen.dart';
import 'detail_screen.dart';
class PostListScreen extends StatefulWidget {
final String collectionName;
final String title;
const PostListScreen({
super.key,
required this.collectionName,
required this.title,
});
@override
State<PostListScreen> createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
final FirestoreService _firestoreService = FirestoreService();
final StorageService _storageService = StorageService();
final Map<String, PostItem> _selectedItems = {};
String _searchQuery = '';
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleSelection(PostItem item) {
setState(() {
if (_selectedItems.containsKey(item.id)) {
_selectedItems.remove(item.id);
} else {
_selectedItems[item.id] = item;
}
});
}
void _clearSelection() {
setState(() {
_selectedItems.clear();
});
}
Future<void> _deletePost(PostItem item) async {
try {
for (final url in item.imageUrls) {
await _storageService.deleteFileByUrl(url);
}
await _firestoreService.deletePost(widget.collectionName, item.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Eintrag gelöscht.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Löschen: $e')),
);
}
}
void _onAddNewPressed(BuildContext context) {
final auth = Provider.of<AuthService>(context, listen: false);
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Bitte schalten Sie den Zugang unter "Informationen" mit dem Gemeinde-Passwort frei.',
),
backgroundColor: Colors.orange,
),
);
return;
}
_clearSelection();
if (widget.collectionName == 'Issues') {
Navigator.push(
context,
MaterialPageRoute(builder: (ctx) => const AddIssueScreen()),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AddPostScreen(
collectionName: widget.collectionName,
screenTitle: widget.collectionName == 'News'
? 'Neue Nachricht'
: 'Neue Idee einbringen',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final currentUser = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(
title: _isSearchActive
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: Colors.black87),
decoration: InputDecoration(
hintText: 'Suche nach ${widget.title}...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade600),
),
onChanged: (val) => setState(() => _searchQuery = val.trim()),
)
: Text(widget.title),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.close : Icons.search),
tooltip: _isSearchActive ? 'Suche beenden' : 'Suchen',
onPressed: () {
setState(() {
if (_isSearchActive) {
_isSearchActive = false;
_searchQuery = '';
_searchController.clear();
} else {
_isSearchActive = true;
}
});
},
),
if (_selectedItems.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Auswahl als PDF-Tabelle drucken',
onPressed: () => PdfService.printSelectedItemsTable(
items: _selectedItems.values.toList(),
title: widget.title,
),
),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: 'Auswahl aufheben',
onPressed: _clearSelection,
),
],
],
),
body: StreamBuilder<List<PostItem>>(
stream: _firestoreService.getPostsStream(widget.collectionName),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Fehler beim Laden: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
),
);
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final allItems = snapshot.data ?? [];
final filteredItems = allItems.where((item) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
final matchesTitle = item.title.toLowerCase().contains(query);
final matchesDesc = item.description.toLowerCase().contains(query);
final matchesStreet =
item.street?.toLowerCase().contains(query) ?? false;
final matchesCategory =
item.category?.toLowerCase().contains(query) ?? false;
return matchesTitle || matchesDesc || matchesStreet || matchesCategory;
}).toList();
if (filteredItems.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
_searchQuery.isNotEmpty
? 'Keine Treffer für "$_searchQuery"'
: 'Noch keine Einträge vorhanden.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
),
],
),
);
}
return ListView.separated(
itemCount: filteredItems.length,
separatorBuilder: (ctx, idx) => const Divider(
height: 1,
indent: 16,
endIndent: 16,
color: Colors.black12,
),
itemBuilder: (context, index) {
final item = filteredItems[index];
final isSelected = _selectedItems.containsKey(item.id);
final isAuthor =
currentUser != null && item.authorUid == currentUser.uid;
final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate);
Widget itemTile = ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () {
if (_selectedItems.isNotEmpty) {
_toggleSelection(item);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => DetailScreen(item: item),
),
);
}
},
onLongPress: () => _toggleSelection(item),
leading: item.imageUrl.isNotEmpty
? Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.imageUrl,
width: 70,
height: 70,
fit: BoxFit.cover,
placeholder: (ctx, url) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (ctx, url, err) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
if (isSelected)
const Positioned(
top: 2,
left: 2,
child: CircleAvatar(
radius: 12,
backgroundColor: Colors.blue,
child: Icon(Icons.check, size: 16, color: Colors.white),
),
),
],
)
: isSelected
? const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.check, color: Colors.white),
)
: null,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
formattedDate,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.street != null && item.street!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.location_on, size: 12, color: Colors.redAccent),
const SizedBox(width: 4),
Text(
item.street!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
],
const SizedBox(height: 4),
Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade800,
),
),
],
),
);
if (isAuthor) {
return Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Eintrag löschen?'),
content: const Text(
'Möchten Sie diesen Eintrag wirklich unwiderruflich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Abbrechen'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red),
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Löschen'),
),
],
),
);
},
onDismissed: (direction) => _deletePost(item),
child: itemTile,
);
}
return itemTile;
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onAddNewPressed(context),
tooltip: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),
);
}
}