feat: PDF zoom & external link fix, admin contact messages inbox, and in-app user management

This commit is contained in:
2026-09-15 22:06:36 +02:00
parent 9a823cbcdb
commit 75f1610cde
10 changed files with 1736 additions and 66 deletions
+604
View File
@@ -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<AdminMessagesScreen> createState() => _AdminMessagesScreenState();
}
class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
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<void> _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<void> _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<String, dynamic> 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<bool>(
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<QuerySnapshot<Map<String, dynamic>>>(
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),
);
},
);
},
),
),
],
),
);
}
}
+48
View File
@@ -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<ContactScreen> {
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<ContactScreen> {
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,
+24
View File
@@ -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(
+259 -66
View File
@@ -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<PdfViewerScreen> createState() => _PdfViewerScreenState();
}
class _PdfViewerScreenState extends State<PdfViewerScreen> {
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<void> _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<void> _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,
),
],
),
),
),
),
],
),
);
}
}
+552
View File
@@ -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<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> {
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<void> _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<bool>(
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<void> _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<AuthService>(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<QuerySnapshot<Map<String, dynamic>>>(
stream: _firestoreService.getAdminsStream(),
builder: (context, adminSnapshot) {
final Set<String> adminUids = {};
final Set<String> 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<QuerySnapshot<Map<String, dynamic>>>(
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<String>(
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,
),
),
],
),
),
],
),
);
},
);
},
);
},
),
),
],
),
);
}
}
+38
View File
@@ -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<void> _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<bool> 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();
+134
View File
@@ -63,4 +63,138 @@ class FirestoreService {
'status': 'Neu',
});
}
/// Stream of all contact messages (for admins)
Stream<QuerySnapshot<Map<String, dynamic>>> getContactMessagesStream() {
return _firestore
.collection('ContactMessages')
.orderBy('createdDate', descending: true)
.snapshots();
}
/// Update the status of a contact message
Future<void> updateContactMessageStatus(String docId, String status) async {
await _firestore.collection('ContactMessages').doc(docId).update({
'status': status,
'statusUpdatedAt': FieldValue.serverTimestamp(),
});
}
/// Delete a contact message
Future<void> deleteContactMessage(String docId) async {
await _firestore.collection('ContactMessages').doc(docId).delete();
}
/// Stream of all registered users
Stream<QuerySnapshot<Map<String, dynamic>>> getUsersStream() {
return _firestore.collection('Users').snapshots();
}
/// Stream of all admin documents
Stream<QuerySnapshot<Map<String, dynamic>>> getAdminsStream() {
return _firestore.collection('Admins').snapshots();
}
/// Upsert a registered user profile into Firestore
Future<void> 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 = <String, dynamic>{
'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<void> 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<void> 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));
}
}