diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 72e7bf4..bc3f3f1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -35,5 +35,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firestore.rules b/firestore.rules
index 3654914..a01def2 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -23,6 +23,13 @@ service cloud.firestore {
);
}
+ // Benutzerprofile: Nutzer verwaltet eigenes Profil, Admins haben Vollzugriff
+ match /Users/{userId} {
+ allow read: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
+ allow create, update: if isAuthenticated() && (request.auth.uid == userId || isAdmin());
+ allow delete: if isAdmin();
+ }
+
// Admins-Sammlung: Angemeldete Nutzer können prüfen, nur Admins schreiben
match /Admins/{adminDoc} {
allow read: if isAuthenticated();
diff --git a/lib/screens/admin_messages_screen.dart b/lib/screens/admin_messages_screen.dart
new file mode 100644
index 0000000..34c9ccb
--- /dev/null
+++ b/lib/screens/admin_messages_screen.dart
@@ -0,0 +1,604 @@
+import 'package:cloud_firestore/cloud_firestore.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:url_launcher/url_launcher.dart';
+import '../services/firestore_service.dart';
+
+class AdminMessagesScreen extends StatefulWidget {
+ const AdminMessagesScreen({super.key});
+
+ @override
+ State createState() => _AdminMessagesScreenState();
+}
+
+class _AdminMessagesScreenState extends State {
+ final FirestoreService _firestoreService = FirestoreService();
+ String _selectedFilter = 'all'; // 'all', 'Neu', 'In Bearbeitung', 'Erledigt'
+ String _searchQuery = '';
+ bool _isSearchActive = false;
+ final TextEditingController _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ Future _sendMail(String email) async {
+ final uri = Uri(scheme: 'mailto', path: email);
+ try {
+ if (await canLaunchUrl(uri)) {
+ await launchUrl(uri);
+ } else {
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('E-Mail-App konnte nicht geöffnet werden: $e')),
+ );
+ }
+ }
+ }
+
+ Future _callPhone(String phone) async {
+ final cleanPhone = phone.replaceAll(RegExp(r'\s+'), '');
+ final uri = Uri(scheme: 'tel', path: cleanPhone);
+ try {
+ if (await canLaunchUrl(uri)) {
+ await launchUrl(uri);
+ } else {
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Telefon-App konnte nicht geöffnet werden: $e')),
+ );
+ }
+ }
+ }
+
+ void _showMessageDetail(
+ BuildContext context,
+ String docId,
+ Map data,
+ ) {
+ final firstName = data['firstName'] as String? ?? '';
+ final lastName = data['lastName'] as String? ?? '';
+ final rawName = '$firstName $lastName'.trim();
+ final fullName = rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
+ final email = data['email'] as String? ?? '';
+ final phone = data['phone'] as String? ?? '';
+ final message = data['message'] as String? ?? '';
+ final currentStatus = data['status'] as String? ?? 'Neu';
+
+ DateTime createdDate;
+ if (data['createdDate'] is Timestamp) {
+ createdDate = (data['createdDate'] as Timestamp).toDate();
+ } else {
+ createdDate = DateTime.now();
+ }
+ final formattedDate =
+ DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
+
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (ctx) => StatefulBuilder(
+ builder: (ctx, setModalState) => Padding(
+ padding: EdgeInsets.only(
+ left: 20,
+ right: 20,
+ top: 20,
+ bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
+ ),
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 40,
+ height: 4,
+ decoration: BoxDecoration(
+ color: Colors.grey.shade300,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Text(
+ fullName,
+ style: const TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ _buildStatusBadge(currentStatus),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Eingegangen: $formattedDate Uhr',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
+ ),
+ const Divider(height: 24),
+ if (email.isNotEmpty) ...[
+ Row(
+ children: [
+ const Icon(Icons.email_outlined,
+ size: 18, color: Colors.blue),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ email,
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ TextButton.icon(
+ onPressed: () => _sendMail(email),
+ icon: const Icon(Icons.send, size: 16),
+ label: const Text('Antworten'),
+ ),
+ ],
+ ),
+ ],
+ if (phone.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ const Icon(Icons.phone_outlined,
+ size: 18, color: Colors.green),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ phone,
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ TextButton.icon(
+ onPressed: () => _callPhone(phone),
+ icon: const Icon(Icons.call, size: 16),
+ label: const Text('Anrufen'),
+ ),
+ ],
+ ),
+ ],
+ const SizedBox(height: 14),
+ const Text(
+ 'Nachricht / Anliegen:',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.bold,
+ color: Colors.black87,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: Colors.grey.shade300),
+ ),
+ child: SelectableText(
+ message,
+ style: const TextStyle(fontSize: 14, height: 1.45),
+ ),
+ ),
+ const SizedBox(height: 20),
+ const Text(
+ 'Status ändern:',
+ style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ _buildStatusButton(docId, 'Neu', currentStatus, Colors.orange),
+ const SizedBox(width: 8),
+ _buildStatusButton(
+ docId, 'In Bearbeitung', currentStatus, Colors.blue),
+ const SizedBox(width: 8),
+ _buildStatusButton(
+ docId, 'Erledigt', currentStatus, Colors.green),
+ ],
+ ),
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ TextButton.icon(
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ icon: const Icon(Icons.delete_outline),
+ label: const Text('Nachricht löschen'),
+ onPressed: () async {
+ final confirm = await showDialog(
+ context: context,
+ builder: (dCtx) => AlertDialog(
+ title: const Text('Nachricht löschen?'),
+ content: const Text(
+ 'Möchten Sie diese Bürgernachricht unwiderruflich löschen?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dCtx, false),
+ child: const Text('Abbrechen'),
+ ),
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red),
+ onPressed: () => Navigator.pop(dCtx, true),
+ child: const Text('Löschen'),
+ ),
+ ],
+ ),
+ );
+ if (confirm == true) {
+ await _firestoreService.deleteContactMessage(docId);
+ if (context.mounted) {
+ Navigator.pop(ctx);
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Nachricht gelöscht.')),
+ );
+ }
+ }
+ },
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Schließen'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStatusButton(
+ String docId,
+ String targetStatus,
+ String currentStatus,
+ Color color,
+ ) {
+ final isCurrent = currentStatus == targetStatus;
+ return Expanded(
+ child: OutlinedButton(
+ style: OutlinedButton.styleFrom(
+ backgroundColor: isCurrent ? color.withOpacity(0.15) : null,
+ side: BorderSide(
+ color: isCurrent ? color : Colors.grey.shade300,
+ width: isCurrent ? 2 : 1,
+ ),
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ ),
+ onPressed: () async {
+ await _firestoreService.updateContactMessageStatus(
+ docId, targetStatus);
+ if (mounted) Navigator.pop(context);
+ },
+ child: Text(
+ targetStatus,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
+ color: isCurrent ? color : Colors.black87,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStatusBadge(String status) {
+ Color bg;
+ Color fg;
+ switch (status) {
+ case 'In Bearbeitung':
+ bg = Colors.blue.shade50;
+ fg = Colors.blue.shade800;
+ break;
+ case 'Erledigt':
+ bg = Colors.green.shade50;
+ fg = Colors.green.shade800;
+ break;
+ case 'Neu':
+ default:
+ bg = Colors.orange.shade50;
+ fg = Colors.orange.shade900;
+ break;
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: fg.withOpacity(0.3)),
+ ),
+ child: Text(
+ status,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: fg,
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: _isSearchActive
+ ? TextField(
+ controller: _searchController,
+ autofocus: true,
+ style: const TextStyle(color: Colors.black87),
+ decoration: InputDecoration(
+ hintText: 'Nachrichten durchsuchen...',
+ border: InputBorder.none,
+ hintStyle: TextStyle(color: Colors.grey.shade600),
+ ),
+ onChanged: (val) => setState(() => _searchQuery = val.trim()),
+ )
+ : const Text('Posteingang (Bürgernachrichten)'),
+ 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;
+ }
+ });
+ },
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ // Filter Chips
+ Container(
+ width: double.infinity,
+ color: Colors.grey.shade50,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ ChoiceChip(
+ label: const Text('Alle'),
+ selected: _selectedFilter == 'all',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'all'),
+ ),
+ const SizedBox(width: 8),
+ ChoiceChip(
+ avatar: const Icon(Icons.fiber_new,
+ size: 16, color: Colors.orange),
+ label: const Text('Neu'),
+ selected: _selectedFilter == 'Neu',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'Neu'),
+ ),
+ const SizedBox(width: 8),
+ ChoiceChip(
+ avatar: const Icon(Icons.hourglass_top,
+ size: 16, color: Colors.blue),
+ label: const Text('In Bearbeitung'),
+ selected: _selectedFilter == 'In Bearbeitung',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'In Bearbeitung'),
+ ),
+ const SizedBox(width: 8),
+ ChoiceChip(
+ avatar: const Icon(Icons.check_circle_outline,
+ size: 16, color: Colors.green),
+ label: const Text('Erledigt'),
+ selected: _selectedFilter == 'Erledigt',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'Erledigt'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ Expanded(
+ child: StreamBuilder>>(
+ stream: _firestoreService.getContactMessagesStream(),
+ builder: (context, snapshot) {
+ if (snapshot.hasError) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Text(
+ 'Fehler beim Abrufen der Nachrichten: ${snapshot.error}',
+ style: const TextStyle(color: Colors.red),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ );
+ }
+
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final docs = snapshot.data?.docs ?? [];
+ final filteredDocs = docs.where((doc) {
+ final data = doc.data();
+ final status = data['status'] as String? ?? 'Neu';
+ if (_selectedFilter != 'all' && status != _selectedFilter) {
+ return false;
+ }
+
+ if (_searchQuery.isNotEmpty) {
+ final q = _searchQuery.toLowerCase();
+ final fn = (data['firstName'] as String? ?? '').toLowerCase();
+ final ln = (data['lastName'] as String? ?? '').toLowerCase();
+ final em = (data['email'] as String? ?? '').toLowerCase();
+ final msg = (data['message'] as String? ?? '').toLowerCase();
+ return fn.contains(q) ||
+ ln.contains(q) ||
+ em.contains(q) ||
+ msg.contains(q);
+ }
+
+ return true;
+ }).toList();
+
+ if (filteredDocs.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.mark_email_read_outlined,
+ size: 64, color: Colors.grey.shade400),
+ const SizedBox(height: 12),
+ Text(
+ _searchQuery.isNotEmpty
+ ? 'Keine Treffer für "'
+ : 'Keine Nachrichten vorhanden.',
+ style: TextStyle(
+ fontSize: 16,
+ color: Colors.grey.shade600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ListView.separated(
+ itemCount: filteredDocs.length,
+ separatorBuilder: (ctx, i) => const Divider(height: 1),
+ itemBuilder: (context, index) {
+ final doc = filteredDocs[index];
+ final data = doc.data();
+ final firstName = data['firstName'] as String? ?? '';
+ final lastName = data['lastName'] as String? ?? '';
+ final rawName = '$firstName $lastName'.trim();
+ final fullName =
+ rawName.isNotEmpty ? rawName : 'Unbekannter Absender';
+ final email = data['email'] as String? ?? '';
+ final message = data['message'] as String? ?? '';
+ final status = data['status'] as String? ?? 'Neu';
+
+ DateTime createdDate;
+ if (data['createdDate'] is Timestamp) {
+ createdDate = (data['createdDate'] as Timestamp).toDate();
+ } else {
+ createdDate = DateTime.now();
+ }
+ final formattedDate =
+ DateFormat('dd.MM.yyyy HH:mm').format(createdDate);
+
+ return ListTile(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16, vertical: 8),
+ leading: CircleAvatar(
+ backgroundColor: status == 'Neu'
+ ? Colors.orange.shade100
+ : status == 'In Bearbeitung'
+ ? Colors.blue.shade100
+ : Colors.green.shade100,
+ child: Text(
+ fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: status == 'Neu'
+ ? Colors.orange.shade900
+ : status == 'In Bearbeitung'
+ ? Colors.blue.shade900
+ : Colors.green.shade900,
+ ),
+ ),
+ ),
+ title: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Text(
+ fullName,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: status == 'Neu'
+ ? FontWeight.bold
+ : FontWeight.w600,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ formattedDate,
+ style: TextStyle(
+ fontSize: 11,
+ color: Colors.grey.shade600,
+ ),
+ ),
+ ],
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (email.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ email,
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.blue.shade800,
+ ),
+ ),
+ ],
+ const SizedBox(height: 4),
+ Text(
+ message,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 13,
+ color: Colors.grey.shade800,
+ ),
+ ),
+ ],
+ ),
+ trailing: _buildStatusBadge(status),
+ onTap: () => _showMessageDetail(context, doc.id, data),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/contact_screen.dart b/lib/screens/contact_screen.dart
index ea29378..aa09614 100644
--- a/lib/screens/contact_screen.dart
+++ b/lib/screens/contact_screen.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
+import 'admin_messages_screen.dart';
import 'auth_dialog.dart';
class ContactScreen extends StatefulWidget {
@@ -118,6 +119,21 @@ class _ContactScreenState extends State {
appBar: AppBar(
title: const Text('Kontakt Bürgerforum'),
centerTitle: true,
+ actions: [
+ if (auth.isAdmin)
+ IconButton(
+ icon: const Icon(Icons.inbox),
+ tooltip: 'Posteingang: Bürgernachrichten',
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => const AdminMessagesScreen(),
+ ),
+ );
+ },
+ ),
+ ],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
@@ -126,6 +142,38 @@ class _ContactScreenState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+ if (auth.isAdmin) ...[
+ Card(
+ color: Colors.indigo.shade50,
+ elevation: 0,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ side: BorderSide(color: Colors.indigo.shade200),
+ ),
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: Colors.indigo.shade700,
+ child: const Icon(Icons.mark_email_unread, color: Colors.white),
+ ),
+ title: const Text(
+ 'Admin-Posteingang',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ subtitle: const Text(
+ 'Alle eingegangenen Bürgernachrichten einsehen & verwalten'),
+ trailing: const Icon(Icons.arrow_forward_ios, size: 16),
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => const AdminMessagesScreen(),
+ ),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 12),
+ ],
if (!isRegistered) ...[
Card(
color: Colors.amber.shade50,
diff --git a/lib/screens/info_screen.dart b/lib/screens/info_screen.dart
index bf7630a..6fbb7f0 100644
--- a/lib/screens/info_screen.dart
+++ b/lib/screens/info_screen.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import 'auth_dialog.dart';
+import 'user_management_screen.dart';
class InfoScreen extends StatelessWidget {
const InfoScreen({super.key});
@@ -136,6 +137,29 @@ class InfoScreen extends StatelessWidget {
),
],
),
+ if (auth.isAdmin) ...[
+ const Divider(height: 20),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.manage_accounts),
+ label: const Text('Benutzerverwaltung & Admin-Rechte'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.indigo.shade700,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ ),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => const UserManagementScreen(),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[
const Divider(height: 16),
InkWell(
diff --git a/lib/screens/pdf_viewer_screen.dart b/lib/screens/pdf_viewer_screen.dart
index 0ad0c7d..1198d16 100644
--- a/lib/screens/pdf_viewer_screen.dart
+++ b/lib/screens/pdf_viewer_screen.dart
@@ -1,10 +1,11 @@
+import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:pdf/pdf.dart';
import 'package:printing/printing.dart';
import 'package:url_launcher/url_launcher.dart';
-class PdfViewerScreen extends StatelessWidget {
+class PdfViewerScreen extends StatefulWidget {
final String title;
final String pdfUrl;
@@ -14,77 +15,269 @@ class PdfViewerScreen extends StatelessWidget {
required this.pdfUrl,
});
+ @override
+ State createState() => _PdfViewerScreenState();
+}
+
+class _PdfViewerScreenState extends State {
+ final TransformationController _transformationController =
+ TransformationController();
+ double _zoomLevel = 1.0;
+ Uint8List? _cachedBytes;
+
+ @override
+ void dispose() {
+ _transformationController.dispose();
+ super.dispose();
+ }
+
+ void _zoomIn() {
+ setState(() {
+ _zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0);
+ _transformationController.value = Matrix4.identity()..scale(_zoomLevel);
+ });
+ }
+
+ void _zoomOut() {
+ setState(() {
+ _zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0);
+ _transformationController.value = Matrix4.identity()..scale(_zoomLevel);
+ });
+ }
+
+ void _resetZoom() {
+ setState(() {
+ _zoomLevel = 1.0;
+ _transformationController.value = Matrix4.identity();
+ });
+ }
+
Future _openExternal() async {
- final uri = Uri.parse(pdfUrl);
- if (await canLaunchUrl(uri)) {
- await launchUrl(uri, mode: LaunchMode.externalApplication);
+ final uri = Uri.parse(widget.pdfUrl);
+ try {
+ final launched = await launchUrl(
+ uri,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!launched) {
+ await launchUrl(uri, mode: LaunchMode.platformDefault);
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Browser konnte nicht geöffnet werden: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ Future _sharePdf() async {
+ if (_cachedBytes != null) {
+ final safeTitle =
+ widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
+ await Printing.sharePdf(
+ bytes: _cachedBytes!,
+ filename: '$safeTitle.pdf',
+ );
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('PDF wird noch geladen...')),
+ );
}
}
@override
Widget build(BuildContext context) {
- final safeTitle = title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
+ final safeTitle =
+ widget.title.replaceAll(RegExp(r'[\\/:*?<>|]'), '_');
- return Scaffold(
- appBar: AppBar(
- title: Text(title, overflow: TextOverflow.ellipsis),
- actions: [
- IconButton(
- icon: const Icon(Icons.open_in_browser),
- tooltip: 'Im Browser öffnen',
- onPressed: _openExternal,
- ),
- ],
- ),
- body: PdfPreview(
- build: (PdfPageFormat format) async {
- final response = await http
- .get(Uri.parse(pdfUrl))
- .timeout(const Duration(seconds: 15));
- if (response.statusCode == 200) {
- return response.bodyBytes;
- }
- throw Exception('Fehler beim Abrufen der Datei (${response.statusCode})');
- },
- canChangeOrientation: false,
- canChangePageFormat: false,
- canDebug: false,
- maxPageWidth: 700,
- pdfFileName: '$safeTitle.pdf',
- loadingWidget: const Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- CircularProgressIndicator(),
- SizedBox(height: 16),
- Text('Aussendung wird geladen...'),
- ],
- ),
- ),
- onError: (context, error) => Center(
- child: Padding(
- padding: const EdgeInsets.all(24.0),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(Icons.error_outline, color: Colors.red, size: 48),
- const SizedBox(height: 12),
- Text(
- 'Fehler beim Laden des Dokuments: $error',
- textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.red),
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(widget.title, overflow: TextOverflow.ellipsis),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.share),
+ tooltip: 'PDF teilen / drucken',
+ onPressed: _sharePdf,
+ ),
+ IconButton(
+ icon: const Icon(Icons.open_in_browser),
+ tooltip: 'Im Browser öffnen',
+ onPressed: _openExternal,
+ ),
+ ],
+ ),
+ body: Stack(
+ children: [
+ PdfPreview.builder(
+ build: (PdfPageFormat format) async {
+ if (_cachedBytes != null) return _cachedBytes!;
+ final response = await http
+ .get(Uri.parse(widget.pdfUrl))
+ .timeout(const Duration(seconds: 15));
+ if (response.statusCode == 200) {
+ _cachedBytes = response.bodyBytes;
+ return response.bodyBytes;
+ }
+ throw Exception(
+ 'Fehler beim Abrufen der Datei (${response.statusCode})');
+ },
+ canChangeOrientation: false,
+ canChangePageFormat: false,
+ canDebug: false,
+ allowPrinting: false,
+ allowSharing: false,
+ useActions: false,
+ pdfFileName: '$safeTitle.pdf',
+ loadingWidget: const Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(),
+ SizedBox(height: 16),
+ Text('Aussendung wird geladen...'),
+ ],
+ ),
+ ),
+ onError: (context, error) => Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error_outline, color: Colors.red, size: 48),
+ const SizedBox(height: 12),
+ Text(
+ 'Fehler beim Laden des Dokuments: $error',
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.red),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton.icon(
+ onPressed: _openExternal,
+ icon: const Icon(Icons.open_in_browser),
+ label: const Text('Direkt im Browser öffnen'),
+ ),
+ ],
),
- const SizedBox(height: 16),
- ElevatedButton.icon(
- onPressed: _openExternal,
- icon: const Icon(Icons.open_in_browser),
- label: const Text('Direkt im Browser öffnen'),
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
+ ),
+ ),
+ pagesBuilder: (context, pages) {
+ return InteractiveViewer(
+ transformationController: _transformationController,
+ minScale: 0.8,
+ maxScale: 5.0,
+ panEnabled: true,
+ scaleEnabled: true,
+ onInteractionEnd: (details) {
+ final scale =
+ _transformationController.value.getMaxScaleOnAxis();
+ if (scale != _zoomLevel) {
+ setState(() {
+ _zoomLevel = scale;
+ });
+ }
+ },
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8.0, vertical: 16.0),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (int i = 0; i < pages.length; i++) ...[
+ Container(
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(4),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.15),
+ blurRadius: 10,
+ offset: const Offset(0, 3),
+ ),
+ ],
+ ),
+ child: Image(
+ image: pages[i].image,
+ fit: BoxFit.contain,
+ ),
+ ),
+ if (pages.length > 1)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 12.0),
+ child: Text(
+ 'Seite ${i + 1} von ${pages.length}',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ // Floating Zoom Controls Overlay
+ Positioned(
+ right: 16,
+ bottom: 24,
+ child: Card(
+ elevation: 4,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(24)),
+ color: Colors.white.withOpacity(0.92),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.zoom_in, color: Colors.black87),
+ tooltip: 'Vergrößern (+)',
+ onPressed: _zoomIn,
+ ),
+ InkWell(
+ onTap: _resetZoom,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: 4, horizontal: 6),
+ child: Text(
+ '${(_zoomLevel * 100).round()}%',
+ style: const TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: Colors.blue,
+ ),
+ ),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.zoom_out, color: Colors.black87),
+ tooltip: 'Verkleinern (-)',
+ onPressed: _zoomOut,
+ ),
+ IconButton(
+ icon: const Icon(Icons.restart_alt, size: 20, color: Colors.grey),
+ tooltip: 'Zoom zurücksetzen (1:1)',
+ onPressed: _resetZoom,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
}
diff --git a/lib/screens/user_management_screen.dart b/lib/screens/user_management_screen.dart
new file mode 100644
index 0000000..2004a90
--- /dev/null
+++ b/lib/screens/user_management_screen.dart
@@ -0,0 +1,552 @@
+import 'package:cloud_firestore/cloud_firestore.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+import '../services/auth_service.dart';
+import '../services/firestore_service.dart';
+
+class UserManagementScreen extends StatefulWidget {
+ const UserManagementScreen({super.key});
+
+ @override
+ State createState() => _UserManagementScreenState();
+}
+
+class _UserManagementScreenState extends State {
+ final FirestoreService _firestoreService = FirestoreService();
+ String _searchQuery = '';
+ bool _isSearchActive = false;
+ final TextEditingController _searchController = TextEditingController();
+ String _selectedFilter = 'all'; // 'all', 'admins', 'citizens'
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ Future _toggleAdminRole({
+ required String uid,
+ required String email,
+ required String displayName,
+ required bool currentIsAdmin,
+ required String currentAdminUid,
+ }) async {
+ if (currentIsAdmin && uid == currentAdminUid) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'Sie können sich nicht selbst die Administrator-Rechte entziehen.'),
+ backgroundColor: Colors.amber,
+ ),
+ );
+ return;
+ }
+
+ final targetAction = currentIsAdmin ? 'entziehen' : 'erteilen';
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dCtx) => AlertDialog(
+ title: Text(currentIsAdmin
+ ? 'Admin-Rechte entziehen?'
+ : 'Zum Administrator ernennen?'),
+ content: Text(currentIsAdmin
+ ? 'Möchten Sie $displayName die Administrator-Rechte wirklich entziehen?'
+ : 'Möchten Sie $displayName wirklich zum Administrator ernennen? Dieser Nutzer erhält vollen Zugriff auf News, alle Beiträge, Bürgernachrichten und die Benutzerverwaltung.'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dCtx, false),
+ child: const Text('Abbrechen'),
+ ),
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor:
+ currentIsAdmin ? Colors.red : Colors.indigo.shade700,
+ ),
+ onPressed: () => Navigator.pop(dCtx, true),
+ child: Text(currentIsAdmin ? 'Rechte entziehen' : 'Ernennen'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true) {
+ try {
+ await _firestoreService.setAdminRole(
+ uid: uid,
+ email: email,
+ makeAdmin: !currentIsAdmin,
+ grantedBy: currentAdminUid,
+ );
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Administrator-Rechte wurden erfolgreich $targetAction.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Fehler beim Ändern der Rechte: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+ }
+
+ Future _showAddAdminDialog(String currentAdminUid) async {
+ final emailController = TextEditingController();
+ final uidController = TextEditingController();
+
+ await showDialog(
+ context: context,
+ builder: (dCtx) => AlertDialog(
+ title: const Text('Admin manuell hinzufügen'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ 'Geben Sie die E-Mail-Adresse oder Firebase-UID des neuen Administrators ein:',
+ style: TextStyle(fontSize: 13),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: 'E-Mail-Adresse *',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 10),
+ TextField(
+ controller: uidController,
+ decoration: const InputDecoration(
+ labelText: 'UID (optional)',
+ hintText: 'z. B. aus Firebase Auth',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dCtx),
+ child: const Text('Abbrechen'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ final email = emailController.text.trim().toLowerCase();
+ final uid = uidController.text.trim();
+ if (email.isEmpty && uid.isEmpty) {
+ return;
+ }
+ final identifier = uid.isNotEmpty ? uid : email;
+ try {
+ await _firestoreService.addAdminDirectly(
+ identifier: identifier,
+ email: email,
+ grantedBy: currentAdminUid,
+ );
+ if (dCtx.mounted) {
+ Navigator.pop(dCtx);
+ }
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Administrator erfolgreich hinzugefügt.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Fehler beim Hinzufügen: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ },
+ child: const Text('Hinzufügen'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final auth = Provider.of(context);
+ final currentAdminUid = auth.user?.uid ?? '';
+
+ return Scaffold(
+ appBar: AppBar(
+ title: _isSearchActive
+ ? TextField(
+ controller: _searchController,
+ autofocus: true,
+ style: const TextStyle(color: Colors.black87),
+ decoration: InputDecoration(
+ hintText: 'Benutzer nach Name oder E-Mail suchen...',
+ border: InputBorder.none,
+ hintStyle: TextStyle(color: Colors.grey.shade600),
+ ),
+ onChanged: (val) => setState(() => _searchQuery = val.trim()),
+ )
+ : const Text('Benutzerverwaltung'),
+ 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;
+ }
+ });
+ },
+ ),
+ IconButton(
+ icon: const Icon(Icons.person_add_alt_1),
+ tooltip: 'Admin manuell hinzufügen',
+ onPressed: () => _showAddAdminDialog(currentAdminUid),
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ // Filter Chips
+ Container(
+ width: double.infinity,
+ color: Colors.grey.shade50,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ ChoiceChip(
+ label: const Text('Alle'),
+ selected: _selectedFilter == 'all',
+ onSelected: (_) => setState(() => _selectedFilter = 'all'),
+ ),
+ const SizedBox(width: 8),
+ ChoiceChip(
+ avatar: const Icon(Icons.admin_panel_settings,
+ size: 16, color: Colors.indigo),
+ label: const Text('Administratoren'),
+ selected: _selectedFilter == 'admins',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'admins'),
+ selectedColor: Colors.indigo.shade100,
+ ),
+ const SizedBox(width: 8),
+ ChoiceChip(
+ avatar: const Icon(Icons.person_outline,
+ size: 16, color: Colors.teal),
+ label: const Text('Bürger'),
+ selected: _selectedFilter == 'citizens',
+ onSelected: (_) =>
+ setState(() => _selectedFilter = 'citizens'),
+ selectedColor: Colors.teal.shade100,
+ ),
+ ],
+ ),
+ ),
+ ),
+ // Users and Admins Stream
+ Expanded(
+ child: StreamBuilder>>(
+ stream: _firestoreService.getAdminsStream(),
+ builder: (context, adminSnapshot) {
+ final Set adminUids = {};
+ final Set adminEmails = {};
+
+ if (adminSnapshot.hasData) {
+ for (final doc in adminSnapshot.data!.docs) {
+ adminUids.add(doc.id.toLowerCase());
+ final data = doc.data();
+ final email =
+ (data['email'] as String? ?? '').toLowerCase();
+ if (email.isNotEmpty) adminEmails.add(email);
+ final uid =
+ (data['uid'] as String? ?? '').toLowerCase();
+ if (uid.isNotEmpty) adminUids.add(uid);
+ }
+ }
+
+ return StreamBuilder>>(
+ stream: _firestoreService.getUsersStream(),
+ builder: (context, userSnapshot) {
+ if (userSnapshot.hasError) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Text(
+ 'Fehler beim Laden der Benutzer: ${userSnapshot.error}',
+ style: const TextStyle(color: Colors.red),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ );
+ }
+
+ if (userSnapshot.connectionState ==
+ ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final userDocs = userSnapshot.data?.docs ?? [];
+ final filteredUsers = userDocs.where((doc) {
+ final data = doc.data();
+ final uid = doc.id;
+ final email =
+ (data['email'] as String? ?? '').toLowerCase();
+ final isUserAdmin = adminUids.contains(uid.toLowerCase()) ||
+ adminEmails.contains(email) ||
+ data['isAdmin'] == true;
+
+ if (_selectedFilter == 'admins' && !isUserAdmin) {
+ return false;
+ }
+ if (_selectedFilter == 'citizens' && isUserAdmin) {
+ return false;
+ }
+
+ if (_searchQuery.isNotEmpty) {
+ final q = _searchQuery.toLowerCase();
+ final name =
+ (data['displayName'] as String? ?? '').toLowerCase();
+ final fn =
+ (data['firstName'] as String? ?? '').toLowerCase();
+ final ln =
+ (data['lastName'] as String? ?? '').toLowerCase();
+ return name.contains(q) ||
+ email.contains(q) ||
+ fn.contains(q) ||
+ ln.contains(q);
+ }
+
+ return true;
+ }).toList();
+
+ if (filteredUsers.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.people_outline,
+ size: 64, color: Colors.grey.shade400),
+ const SizedBox(height: 12),
+ Text(
+ _searchQuery.isNotEmpty
+ ? 'Keine Treffer für "$_searchQuery"'
+ : 'Noch keine registrierten Benutzer vorhanden.',
+ style: TextStyle(
+ fontSize: 16,
+ color: Colors.grey.shade600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ListView.separated(
+ itemCount: filteredUsers.length,
+ separatorBuilder: (ctx, i) => const Divider(height: 1),
+ itemBuilder: (context, index) {
+ final doc = filteredUsers[index];
+ final data = doc.data();
+ final uid = doc.id;
+ final email = data['email'] as String? ?? '';
+ final displayName = data['displayName'] as String? ??
+ '${data['firstName'] ?? ''} ${data['lastName'] ?? ''}'.trim();
+ final name = displayName.isNotEmpty
+ ? displayName
+ : (email.isNotEmpty ? email : 'Bürger');
+ final isCurrentUser = uid == currentAdminUid;
+ final isUserAdmin =
+ adminUids.contains(uid.toLowerCase()) ||
+ adminEmails.contains(email.toLowerCase()) ||
+ data['isAdmin'] == true;
+
+ DateTime? createdAt;
+ if (data['createdAt'] is Timestamp) {
+ createdAt = (data['createdAt'] as Timestamp).toDate();
+ }
+
+ return ListTile(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16, vertical: 8),
+ leading: CircleAvatar(
+ backgroundColor: isUserAdmin
+ ? Colors.indigo.shade100
+ : Colors.blue.shade50,
+ child: Icon(
+ isUserAdmin
+ ? Icons.admin_panel_settings
+ : Icons.person,
+ color: isUserAdmin
+ ? Colors.indigo.shade800
+ : Colors.blue.shade700,
+ ),
+ ),
+ title: Row(
+ children: [
+ Expanded(
+ child: Text(
+ name,
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.bold,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (isCurrentUser) ...[
+ const SizedBox(width: 6),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade200,
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Text(
+ 'Du',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: Colors.black87,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (email.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ email,
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey.shade700,
+ ),
+ ),
+ ],
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: isUserAdmin
+ ? Colors.indigo.shade50
+ : Colors.teal.shade50,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: isUserAdmin
+ ? Colors.indigo.shade200
+ : Colors.teal.shade200,
+ ),
+ ),
+ child: Text(
+ isUserAdmin ? 'Administrator' : 'Bürger',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: isUserAdmin
+ ? Colors.indigo.shade800
+ : Colors.teal.shade800,
+ ),
+ ),
+ ),
+ if (createdAt != null) ...[
+ const SizedBox(width: 8),
+ Text(
+ 'Seit ${DateFormat('dd.MM.yyyy').format(createdAt)}',
+ style: TextStyle(
+ fontSize: 11,
+ color: Colors.grey.shade500,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ],
+ ),
+ trailing: PopupMenuButton(
+ icon: const Icon(Icons.more_vert),
+ tooltip: 'Optionen',
+ onSelected: (action) {
+ if (action == 'toggleAdmin') {
+ _toggleAdminRole(
+ uid: uid,
+ email: email,
+ displayName: name,
+ currentIsAdmin: isUserAdmin,
+ currentAdminUid: currentAdminUid,
+ );
+ }
+ },
+ itemBuilder: (ctx) => [
+ PopupMenuItem(
+ value: 'toggleAdmin',
+ enabled: !(isUserAdmin && isCurrentUser),
+ child: Row(
+ children: [
+ Icon(
+ isUserAdmin
+ ? Icons.remove_moderator
+ : Icons.add_moderator,
+ size: 20,
+ color: isUserAdmin
+ ? Colors.red
+ : Colors.indigo.shade700,
+ ),
+ const SizedBox(width: 10),
+ Text(
+ isUserAdmin
+ ? 'Admin-Rechte entziehen'
+ : 'Zum Admin ernennen',
+ style: TextStyle(
+ color: isUserAdmin
+ ? Colors.red
+ : Colors.indigo.shade700,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart
index a641623..8663b16 100644
--- a/lib/services/auth_service.dart
+++ b/lib/services/auth_service.dart
@@ -122,6 +122,9 @@ class AuthService extends ChangeNotifier {
_auth.authStateChanges().listen((User? user) async {
_user = user;
await _checkAdminClaim(user);
+ if (user != null && !user.isAnonymous) {
+ await _syncUserProfile(user);
+ }
notifyListeners();
});
@@ -130,6 +133,33 @@ class AuthService extends ChangeNotifier {
}
}
+ Future _syncUserProfile(User user, {String? firstName, String? lastName}) async {
+ if (user.isAnonymous) return;
+ try {
+ final cleanEmail = user.email?.trim().toLowerCase() ?? '';
+ if (cleanEmail.isEmpty) return;
+
+ final cleanFirst = firstName ?? this.firstName;
+ final cleanLast = lastName ?? this.lastName;
+ final name = user.displayName ?? '$cleanFirst $cleanLast'.trim();
+
+ await FirebaseFirestore.instance.collection('Users').doc(user.uid).set({
+ 'uid': user.uid,
+ 'email': cleanEmail,
+ 'displayName': name,
+ 'firstName': cleanFirst,
+ 'lastName': cleanLast,
+ 'emailVerified': user.emailVerified,
+ 'lastLogin': FieldValue.serverTimestamp(),
+ if (_isAdmin) 'isAdmin': true,
+ }, SetOptions(merge: true));
+ } catch (e) {
+ if (kDebugMode) {
+ debugPrint('Error syncing user profile in auth service: $e');
+ }
+ }
+ }
+
/// Verifies community access code
Future verifyCommunityCode(String enteredCode) async {
final cleanCode = enteredCode.trim().toLowerCase();
@@ -165,6 +195,10 @@ class AuthService extends ChangeNotifier {
password: password,
);
_user = credential.user;
+ await _checkAdminClaim(_user);
+ if (_user != null) {
+ await _syncUserProfile(_user!);
+ }
notifyListeners();
return null; // success
} on FirebaseAuthException catch (e) {
@@ -200,6 +234,10 @@ class AuthService extends ChangeNotifier {
await prefs.setString('user_first_name', cleanFirst);
await prefs.setString('user_last_name', cleanLast);
+ await _checkAdminClaim(_user);
+ await _syncUserProfile(_user!,
+ firstName: cleanFirst, lastName: cleanLast);
+
// Send email verification so user verifies they own the email address
try {
await _user!.sendEmailVerification();
diff --git a/lib/services/firestore_service.dart b/lib/services/firestore_service.dart
index 02c18c2..dffa1ca 100644
--- a/lib/services/firestore_service.dart
+++ b/lib/services/firestore_service.dart
@@ -63,4 +63,138 @@ class FirestoreService {
'status': 'Neu',
});
}
+
+ /// Stream of all contact messages (for admins)
+ Stream>> getContactMessagesStream() {
+ return _firestore
+ .collection('ContactMessages')
+ .orderBy('createdDate', descending: true)
+ .snapshots();
+ }
+
+ /// Update the status of a contact message
+ Future updateContactMessageStatus(String docId, String status) async {
+ await _firestore.collection('ContactMessages').doc(docId).update({
+ 'status': status,
+ 'statusUpdatedAt': FieldValue.serverTimestamp(),
+ });
+ }
+
+ /// Delete a contact message
+ Future deleteContactMessage(String docId) async {
+ await _firestore.collection('ContactMessages').doc(docId).delete();
+ }
+
+ /// Stream of all registered users
+ Stream>> getUsersStream() {
+ return _firestore.collection('Users').snapshots();
+ }
+
+ /// Stream of all admin documents
+ Stream>> getAdminsStream() {
+ return _firestore.collection('Admins').snapshots();
+ }
+
+ /// Upsert a registered user profile into Firestore
+ Future syncUserProfile({
+ required String uid,
+ required String email,
+ String? displayName,
+ String? firstName,
+ String? lastName,
+ bool? emailVerified,
+ bool? isAdmin,
+ }) async {
+ try {
+ final docRef = _firestore.collection('Users').doc(uid);
+ final doc = await docRef.get();
+ final data = {
+ 'uid': uid,
+ 'email': email.trim().toLowerCase(),
+ 'lastLogin': FieldValue.serverTimestamp(),
+ if (displayName != null && displayName.isNotEmpty)
+ 'displayName': displayName,
+ if (firstName != null && firstName.isNotEmpty)
+ 'firstName': firstName,
+ if (lastName != null && lastName.isNotEmpty)
+ 'lastName': lastName,
+ if (emailVerified != null) 'emailVerified': emailVerified,
+ if (isAdmin != null) 'isAdmin': isAdmin,
+ };
+
+ if (!doc.exists) {
+ data['createdAt'] = FieldValue.serverTimestamp();
+ }
+
+ await docRef.set(data, SetOptions(merge: true));
+ } catch (e) {
+ if (kDebugMode) {
+ debugPrint('Error syncing user profile to Firestore: $e');
+ }
+ }
+ }
+
+ /// Promote or demote a user to Admin in Firestore
+ Future setAdminRole({
+ required String uid,
+ required String email,
+ required bool makeAdmin,
+ String? grantedBy,
+ }) async {
+ final cleanEmail = email.trim().toLowerCase();
+ final adminRef = _firestore.collection('Admins').doc(uid);
+ final userRef = _firestore.collection('Users').doc(uid);
+
+ if (makeAdmin) {
+ // 1. Add UID to Admins collection
+ await adminRef.set({
+ 'uid': uid,
+ 'email': cleanEmail,
+ 'grantedAt': FieldValue.serverTimestamp(),
+ 'grantedBy': grantedBy ?? '',
+ }, SetOptions(merge: true));
+
+ // 2. Also ensure email doc exists if email is known
+ if (cleanEmail.isNotEmpty) {
+ await _firestore.collection('Admins').doc(cleanEmail).set({
+ 'uid': uid,
+ 'email': cleanEmail,
+ 'grantedAt': FieldValue.serverTimestamp(),
+ 'grantedBy': grantedBy ?? '',
+ }, SetOptions(merge: true));
+ }
+
+ // 3. Mark user document as admin
+ await userRef.set({'isAdmin': true}, SetOptions(merge: true));
+ } else {
+ // 1. Remove UID from Admins collection
+ await adminRef.delete();
+
+ // 2. Remove email from Admins collection if present
+ if (cleanEmail.isNotEmpty) {
+ try {
+ await _firestore.collection('Admins').doc(cleanEmail).delete();
+ } catch (_) {}
+ }
+
+ // 3. Update user document
+ await userRef.set({'isAdmin': false}, SetOptions(merge: true));
+ }
+ }
+
+ /// Add admin directly by UID or email (e.g. manual entry)
+ Future addAdminDirectly({
+ required String identifier,
+ required String email,
+ String? grantedBy,
+ }) async {
+ final cleanId = identifier.trim();
+ final cleanEmail = email.trim().toLowerCase();
+ await _firestore.collection('Admins').doc(cleanId).set({
+ 'uid': cleanId,
+ 'email': cleanEmail,
+ 'grantedAt': FieldValue.serverTimestamp(),
+ 'grantedBy': grantedBy ?? '',
+ }, SetOptions(merge: true));
+ }
}
diff --git a/test/widget_test.dart b/test/widget_test.dart
index 734bb41..6fcd4a6 100644
--- a/test/widget_test.dart
+++ b/test/widget_test.dart
@@ -116,4 +116,55 @@ void main() {
expect(list[2].id, 'news_1');
});
});
+
+ group('Contact Message & Admin Management Tests', () {
+ test('validates contact message status states and defaults', () {
+ const allowedStatuses = ['Neu', 'In Bearbeitung', 'Erledigt'];
+ expect(allowedStatuses.contains('Neu'), isTrue);
+ expect(allowedStatuses.contains('In Bearbeitung'), isTrue);
+ expect(allowedStatuses.contains('Erledigt'), isTrue);
+ expect(allowedStatuses.contains('Unbekannt'), isFalse);
+
+ final messageMap = {
+ 'subject': 'Straßenbeleuchtung kaputt',
+ 'message': 'Vor der Schule ist es dunkel.',
+ 'senderName': 'Max Mustermann',
+ 'senderEmail': 'max@example.com',
+ 'senderPhone': '0664 1234567',
+ 'status': 'Neu',
+ 'userId': 'user-123',
+ };
+
+ expect(messageMap['status'], 'Neu');
+ expect(messageMap['senderEmail'], 'max@example.com');
+ expect(messageMap['userId'], 'user-123');
+ });
+
+ test('validates user profile normalization and admin lookup', () {
+ String normalizeEmail(String email) => email.trim().toLowerCase();
+
+ final rawEmail = ' Admin.Test@Oggau.at ';
+ expect(normalizeEmail(rawEmail), 'admin.test@oggau.at');
+
+ final adminUids = {'uid_123', 'uid_456'};
+ final adminEmails = {'admin.test@oggau.at', 'buergermeister@oggau.at'};
+
+ bool isUserAdmin(String uid, String email) {
+ return adminUids.contains(uid) || adminEmails.contains(normalizeEmail(email));
+ }
+
+ expect(isUserAdmin('uid_123', 'other@mail.com'), isTrue);
+ expect(isUserAdmin('uid_999', 'Admin.Test@oggau.at'), isTrue);
+ expect(isUserAdmin('uid_999', 'regular.user@oggau.at'), isFalse);
+ });
+
+ test('prevents self-demotion check logic', () {
+ bool canDemote(String targetUid, String currentUid) {
+ return targetUid != currentUid;
+ }
+
+ expect(canDemote('target_user', 'current_admin'), isTrue);
+ expect(canDemote('current_admin', 'current_admin'), isFalse);
+ });
+ });
}