From 2e9abac25f66e6785603f882eee423190784f06b Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Sep 2026 11:29:12 +0200 Subject: [PATCH] feat(auth & contact): separate Vorname/Nachname in registration, pre-fill read-only profile in contact, and restrict contact messages to registered users --- firestore.rules | 4 +- lib/screens/auth_dialog.dart | 50 +++-- lib/screens/contact_screen.dart | 290 ++++++++++++++++++++++------ lib/services/auth_service.dart | 48 ++++- lib/services/firestore_service.dart | 2 + 5 files changed, 310 insertions(+), 84 deletions(-) diff --git a/firestore.rules b/firestore.rules index 33e1021..ecd9f48 100644 --- a/firestore.rules +++ b/firestore.rules @@ -26,9 +26,9 @@ service cloud.firestore { (resource.data.author_uid == request.auth.uid); } - // Kontakt-Nachrichten: Jeder kann einsenden, Lesen nur berechtigte Nutzer + // Kontakt-Nachrichten: Nur registrierte Nutzer können einsenden, Lesen für berechtigte Nutzer match /ContactMessages/{document} { - allow create: if true; + allow create: if request.auth != null && request.auth.token.firebase.sign_in_provider != 'anonymous'; allow read, update, delete: if request.auth != null; } } diff --git a/lib/screens/auth_dialog.dart b/lib/screens/auth_dialog.dart index a98bc9d..2b97520 100644 --- a/lib/screens/auth_dialog.dart +++ b/lib/screens/auth_dialog.dart @@ -25,7 +25,8 @@ class _AuthDialogState extends State final _loginPasswordController = TextEditingController(); // Register Controllers - final _regNameController = TextEditingController(); + final _regFirstNameController = TextEditingController(); + final _regLastNameController = TextEditingController(); final _regEmailController = TextEditingController(); final _regPasswordController = TextEditingController(); final _regConfirmPasswordController = TextEditingController(); @@ -51,7 +52,8 @@ class _AuthDialogState extends State _tabController.dispose(); _loginEmailController.dispose(); _loginPasswordController.dispose(); - _regNameController.dispose(); + _regFirstNameController.dispose(); + _regLastNameController.dispose(); _regEmailController.dispose(); _regPasswordController.dispose(); _regConfirmPasswordController.dispose(); @@ -93,13 +95,14 @@ class _AuthDialogState extends State } Future _handleRegister() async { - final name = _regNameController.text.trim(); + final firstName = _regFirstNameController.text.trim(); + final lastName = _regLastNameController.text.trim(); final email = _regEmailController.text.trim(); final password = _regPasswordController.text; final confirm = _regConfirmPasswordController.text; - if (name.isEmpty || email.isEmpty || password.isEmpty) { - setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen.'); + if (firstName.isEmpty || lastName.isEmpty || email.isEmpty || password.isEmpty) { + setState(() => _errorMessage = 'Bitte alle Pflichtfelder ausfüllen (Vorname, Nachname, E-Mail, Passwort).'); return; } @@ -123,7 +126,8 @@ class _AuthDialogState extends State final error = await auth.signUpWithEmail( email: email, password: password, - name: name, + firstName: firstName, + lastName: lastName, ); if (!mounted) return; @@ -453,14 +457,32 @@ class _AuthDialogState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: _regNameController, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Name / Vorname', - prefixIcon: Icon(Icons.person_outline), - border: OutlineInputBorder(), - ), + Row( + children: [ + Expanded( + child: TextField( + controller: _regFirstNameController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Vorname *', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _regLastNameController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Nachname *', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + ), + ), + ], ), const SizedBox(height: 10), TextField( diff --git a/lib/screens/contact_screen.dart b/lib/screens/contact_screen.dart index 1df5155..ea29378 100644 --- a/lib/screens/contact_screen.dart +++ b/lib/screens/contact_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/auth_service.dart'; import '../services/firestore_service.dart'; +import 'auth_dialog.dart'; class ContactScreen extends StatefulWidget { const ContactScreen({super.key}); @@ -29,11 +32,12 @@ class _ContactScreenState extends State { super.dispose(); } - bool _isValidEmail(String email) { - return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email); - } + Future _submitForm(AuthService auth) async { + if (!auth.isRegisteredUser) { + await AuthDialog.show(context); + return; + } - Future _submitForm() async { if (!_formKey.currentState!.validate() || _isSubmitting) return; setState(() { @@ -41,19 +45,27 @@ class _ContactScreenState extends State { }); try { + final firstName = auth.firstName.isNotEmpty + ? auth.firstName + : _firstNameController.text.trim(); + final lastName = auth.lastName.isNotEmpty + ? auth.lastName + : _lastNameController.text.trim(); + final email = (auth.email != null && auth.email!.isNotEmpty) + ? auth.email! + : _emailController.text.trim(); + await _firestoreService.submitContactMessage( - firstName: _firstNameController.text.trim(), - lastName: _lastNameController.text.trim(), - email: _emailController.text.trim(), + firstName: firstName, + lastName: lastName, + email: email, phone: _phoneController.text.trim(), message: _messageController.text.trim(), + authorUid: auth.user?.uid, ); if (!mounted) return; - _firstNameController.clear(); - _lastNameController.clear(); - _emailController.clear(); _phoneController.clear(); _messageController.clear(); @@ -82,6 +94,26 @@ class _ContactScreenState extends State { @override Widget build(BuildContext context) { + final auth = Provider.of(context); + final isRegistered = auth.isRegisteredUser; + + // Sync controllers with authenticated user details + if (isRegistered) { + if (_firstNameController.text != auth.firstName) { + _firstNameController.text = auth.firstName; + } + if (_lastNameController.text != auth.lastName) { + _lastNameController.text = auth.lastName; + } + if (_emailController.text != (auth.email ?? '')) { + _emailController.text = auth.email ?? ''; + } + } else { + if (_firstNameController.text.isNotEmpty) _firstNameController.clear(); + if (_lastNameController.text.isNotEmpty) _lastNameController.clear(); + if (_emailController.text.isNotEmpty) _emailController.clear(); + } + return Scaffold( appBar: AppBar( title: const Text('Kontakt Bürgerforum'), @@ -94,54 +126,154 @@ class _ContactScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Card( - color: Colors.blue.shade50, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - child: const Padding( - padding: EdgeInsets.all(14.0), - child: Row( - children: [ - Icon(Icons.mail_outline, color: Colors.blue, size: 28), - SizedBox(width: 12), - Expanded( - child: Text( - 'Hier kannst du direkt mit dem Bürgerforum Oggau in Kontakt treten. Wir freuen uns über deine Anregungen und Fragen!', - style: TextStyle(fontSize: 13), + if (!isRegistered) ...[ + Card( + color: Colors.amber.shade50, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.amber.shade300), + ), + child: Padding( + padding: const EdgeInsets.all(14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lock_outline, + color: Colors.amber.shade900, size: 22), + const SizedBox(width: 8), + Text( + 'Anmeldung erforderlich', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.amber.shade900, + ), + ), + ], ), - ), - ], + const SizedBox(height: 6), + Text( + 'Um eine Nachricht an das Bürgerforum zu senden, ist ein registriertes Benutzerkonto erforderlich. Deine Kontaktdaten werden dann automatisch hinterlegt.', + style: TextStyle( + fontSize: 13, color: Colors.grey.shade800), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => AuthDialog.show(context), + icon: const Icon(Icons.login), + label: const Text('Jetzt anmelden oder registrieren'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 11), + ), + ), + ), + ], + ), ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), + ] else ...[ + Card( + color: Colors.blue.shade50, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.blue.shade100), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Icon(Icons.verified_user_outlined, + color: Colors.blue.shade700, size: 24), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Angemeldet als ${auth.displayName ?? auth.email}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + 'Deine Kontaktdaten sind automatisch hinterlegt.', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade700), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], Row( children: [ Expanded( child: TextFormField( controller: _firstNameController, - decoration: const InputDecoration( + readOnly: true, + style: TextStyle( + color: isRegistered + ? Colors.black87 + : Colors.grey.shade600, + fontWeight: isRegistered + ? FontWeight.w500 + : FontWeight.normal, + ), + decoration: InputDecoration( labelText: 'Vorname *', - border: OutlineInputBorder(), + hintText: + isRegistered ? null : 'Wird automatisch ausgefüllt', + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(Icons.person_outline), + suffixIcon: isRegistered + ? const Icon(Icons.lock_outline, + size: 18, color: Colors.grey) + : null, + border: const OutlineInputBorder(), ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Bitte Vorname eingeben' - : null, ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _lastNameController, - decoration: const InputDecoration( + readOnly: true, + style: TextStyle( + color: isRegistered + ? Colors.black87 + : Colors.grey.shade600, + fontWeight: isRegistered + ? FontWeight.w500 + : FontWeight.normal, + ), + decoration: InputDecoration( labelText: 'Nachname *', - border: OutlineInputBorder(), + hintText: + isRegistered ? null : 'Wird automatisch ausgefüllt', + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(Icons.person_outline), + suffixIcon: isRegistered + ? const Icon(Icons.lock_outline, + size: 18, color: Colors.grey) + : null, + border: const OutlineInputBorder(), ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Bitte Nachname eingeben' - : null, ), ), ], @@ -149,43 +281,60 @@ class _ContactScreenState extends State { const SizedBox(height: 12), TextFormField( controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( + readOnly: true, + style: TextStyle( + color: isRegistered ? Colors.black87 : Colors.grey.shade600, + fontWeight: + isRegistered ? FontWeight.w500 : FontWeight.normal, + ), + decoration: InputDecoration( labelText: 'E-Mail-Adresse *', - border: OutlineInputBorder(), + hintText: + isRegistered ? null : 'Wird durch Anmeldung ausgefüllt', + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(Icons.email_outlined), + suffixIcon: isRegistered + ? const Icon(Icons.lock_outline, + size: 18, color: Colors.grey) + : null, + helperText: isRegistered + ? 'Antworten des Bürgerforums gehen an diese E-Mail' + : null, + border: const OutlineInputBorder(), ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Bitte E-Mail-Adresse eingeben'; - } - if (!_isValidEmail(value.trim())) { - return 'Bitte eine gültige E-Mail eingeben'; - } - return null; - }, ), const SizedBox(height: 12), TextFormField( controller: _phoneController, keyboardType: TextInputType.phone, - decoration: const InputDecoration( + enabled: isRegistered, + decoration: InputDecoration( labelText: 'Rückrufnummer (optional)', - border: OutlineInputBorder(), + prefixIcon: const Icon(Icons.phone_outlined), + border: const OutlineInputBorder(), + helperText: isRegistered + ? 'Optional für eventuelle telefonische Rückfragen' + : null, + filled: !isRegistered, + fillColor: !isRegistered ? Colors.grey.shade100 : null, ), ), const SizedBox(height: 12), TextFormField( controller: _messageController, maxLines: 5, - decoration: const InputDecoration( + enabled: isRegistered, + decoration: InputDecoration( labelText: 'Ihr Anliegen / Ihre Nachricht *', alignLabelWithHint: true, - border: OutlineInputBorder(), + border: const OutlineInputBorder(), + filled: !isRegistered, + fillColor: !isRegistered ? Colors.grey.shade100 : null, ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Bitte Ihre Nachricht eingeben' - : null, + validator: (value) => (value == null || value.trim().isEmpty) + ? 'Bitte Ihre Nachricht eingeben' + : null, ), const SizedBox(height: 12), Text( @@ -201,16 +350,29 @@ class _ContactScreenState extends State { width: double.infinity, height: 48, child: ElevatedButton.icon( - onPressed: _isSubmitting ? null : _submitForm, + onPressed: !isRegistered + ? () => AuthDialog.show(context) + : (_isSubmitting ? null : () => _submitForm(auth)), + style: ElevatedButton.styleFrom( + backgroundColor: isRegistered + ? Colors.blue.shade800 + : Colors.grey.shade700, + foregroundColor: Colors.white, + ), icon: _isSubmitting ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), ) - : const Icon(Icons.send), + : Icon(!isRegistered ? Icons.login : Icons.send), label: Text( - _isSubmitting ? 'Wird gesendet...' : 'Nachricht absenden', + !isRegistered + ? 'Anmelden zum Absenden' + : (_isSubmitting + ? 'Wird gesendet...' + : 'Nachricht absenden'), style: const TextStyle(fontSize: 16), ), ), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 9f8539b..bc0c601 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -18,6 +18,29 @@ class AuthService extends ChangeNotifier { String? get email => _user?.email; String? get displayName => _user?.displayName; + String? _cachedFirstName; + String? _cachedLastName; + + String get firstName { + if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) { + return _cachedFirstName!; + } + final name = _user?.displayName ?? ''; + if (name.isEmpty) return ''; + final parts = name.trim().split(RegExp(r'\s+')); + return parts.isNotEmpty ? parts.first : ''; + } + + String get lastName { + if (_cachedLastName != null && _cachedLastName!.isNotEmpty) { + return _cachedLastName!; + } + final name = _user?.displayName ?? ''; + if (name.isEmpty) return ''; + final parts = name.trim().split(RegExp(r'\s+')); + return parts.length > 1 ? parts.sublist(1).join(' ') : ''; + } + AuthService() { _initAuth(); } @@ -25,6 +48,8 @@ class AuthService extends ChangeNotifier { Future _initAuth() async { final prefs = await SharedPreferences.getInstance(); _isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false; + _cachedFirstName = prefs.getString('user_first_name'); + _cachedLastName = prefs.getString('user_last_name'); _auth.authStateChanges().listen((User? user) { _user = user; @@ -80,11 +105,12 @@ class AuthService extends ChangeNotifier { } } - /// Sign up with Email, Password, and Full Name + /// Sign up with Email, Password, First Name, and Last Name Future signUpWithEmail({ required String email, required String password, - required String name, + required String firstName, + required String lastName, }) async { try { final credential = await _auth.createUserWithEmailAndPassword( @@ -93,9 +119,18 @@ class AuthService extends ChangeNotifier { ); _user = credential.user; if (_user != null) { - if (name.isNotEmpty) { - await _user!.updateDisplayName(name.trim()); + final cleanFirst = firstName.trim(); + final cleanLast = lastName.trim(); + final fullName = '$cleanFirst $cleanLast'.trim(); + if (fullName.isNotEmpty) { + await _user!.updateDisplayName(fullName); } + _cachedFirstName = cleanFirst; + _cachedLastName = cleanLast; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_first_name', cleanFirst); + await prefs.setString('user_last_name', cleanLast); + // Send email verification so user verifies they own the email address try { await _user!.sendEmailVerification(); @@ -169,6 +204,11 @@ class AuthService extends ChangeNotifier { /// Sign out Future signOut() async { try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('user_first_name'); + await prefs.remove('user_last_name'); + _cachedFirstName = null; + _cachedLastName = null; await _auth.signOut(); } catch (e) { if (kDebugMode) { diff --git a/lib/services/firestore_service.dart b/lib/services/firestore_service.dart index 482bfbb..02c18c2 100644 --- a/lib/services/firestore_service.dart +++ b/lib/services/firestore_service.dart @@ -50,6 +50,7 @@ class FirestoreService { required String email, String? phone, required String message, + String? authorUid, }) async { await _firestore.collection('ContactMessages').add({ 'firstName': firstName, @@ -57,6 +58,7 @@ class FirestoreService { 'email': email, 'phone': phone ?? '', 'message': message, + 'author_uid': authorUid ?? '', 'createdDate': Timestamp.now(), 'status': 'Neu', });