From f27ad83908b3a193c5845e725c9713237922b92c Mon Sep 17 00:00:00 2001 From: nicolas Date: Tue, 15 Sep 2026 22:06:36 +0200 Subject: [PATCH] feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management --- android/app/src/main/AndroidManifest.xml | 19 + firestore.rules | 7 + lib/screens/admin_messages_screen.dart | 604 +++++++++++++++++++++++ lib/screens/contact_screen.dart | 48 ++ lib/screens/info_screen.dart | 24 + lib/screens/pdf_viewer_screen.dart | 325 +++++++++--- lib/screens/user_management_screen.dart | 552 +++++++++++++++++++++ lib/services/auth_service.dart | 38 ++ lib/services/firestore_service.dart | 134 +++++ test/widget_test.dart | 51 ++ 10 files changed, 1736 insertions(+), 66 deletions(-) create mode 100644 lib/screens/admin_messages_screen.dart create mode 100644 lib/screens/user_management_screen.dart 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); + }); + }); }