diff --git a/.gitignore b/.gitignore index 24476c5..c8be054 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/app/build/ +.gradle/ diff --git a/lib/screens/auth_dialog.dart b/lib/screens/auth_dialog.dart new file mode 100644 index 0000000..bd3a4ff --- /dev/null +++ b/lib/screens/auth_dialog.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/auth_service.dart'; + +class AuthDialog extends StatefulWidget { + const AuthDialog({super.key}); + + static Future show(BuildContext context) async { + await showDialog( + context: context, + builder: (ctx) => const AuthDialog(), + ); + } + + @override + State createState() => _AuthDialogState(); +} + +class _AuthDialogState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // Login Controllers + final _loginEmailController = TextEditingController(); + final _loginPasswordController = TextEditingController(); + + // Register Controllers + final _regNameController = TextEditingController(); + final _regEmailController = TextEditingController(); + final _regPasswordController = TextEditingController(); + final _regConfirmPasswordController = TextEditingController(); + + // Code Controller + final _codeController = TextEditingController(); + + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _loginEmailController.dispose(); + _loginPasswordController.dispose(); + _regNameController.dispose(); + _regEmailController.dispose(); + _regPasswordController.dispose(); + _regConfirmPasswordController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + final email = _loginEmailController.text.trim(); + final password = _loginPasswordController.text; + + if (email.isEmpty || password.isEmpty) { + setState(() => _errorMessage = 'Bitte E-Mail und Passwort eingeben.'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final auth = Provider.of(context, listen: false); + final error = await auth.signInWithEmail(email, password); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (error == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erfolgreich angemeldet!'), + backgroundColor: Colors.green, + ), + ); + } else { + setState(() => _errorMessage = error); + } + } + + Future _handleRegister() async { + final name = _regNameController.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.'); + return; + } + + if (password != confirm) { + setState(() => _errorMessage = 'Die Passwörter stimmen nicht überein.'); + return; + } + + if (password.length < 6) { + setState( + () => _errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final auth = Provider.of(context, listen: false); + final error = await auth.signUpWithEmail( + email: email, + password: password, + name: name, + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (error == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Konto erfolgreich erstellt! Willkommen im Bürgerforum.'), + backgroundColor: Colors.green, + ), + ); + } else { + setState(() => _errorMessage = error); + } + } + + Future _handleCodeUnlock() async { + final code = _codeController.text.trim(); + if (code.isEmpty) { + setState(() => _errorMessage = 'Bitte den Zugangscode eingeben.'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final auth = Provider.of(context, listen: false); + final success = await auth.verifyCommunityCode(code); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bürgerzugang freigeschaltet!'), + backgroundColor: Colors.green, + ), + ); + } else { + setState(() => _errorMessage = + 'Ungültiger Zugangscode. Bitte wende dich an das Bürgerforum.'); + } + } + + void _showPasswordResetDialog() { + final resetEmailController = TextEditingController( + text: _loginEmailController.text, + ); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Passwort zurücksetzen'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Gib deine E-Mail-Adresse ein. Wir senden dir einen Link, mit dem du ein neues Passwort festlegen kannst.', + style: TextStyle(fontSize: 13), + ), + const SizedBox(height: 12), + TextField( + controller: resetEmailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'E-Mail-Adresse', + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen'), + ), + ElevatedButton( + onPressed: () async { + final email = resetEmailController.text.trim(); + if (email.isEmpty) return; + final auth = Provider.of(context, listen: false); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(ctx); + final err = await auth.sendPasswordReset(email); + if (!mounted) return; + navigator.pop(); + messenger.showSnackBar( + SnackBar( + content: Text(err ?? 'E-Mail zum Zurücksetzen wurde versendet!'), + backgroundColor: err == null ? Colors.green : Colors.red, + ), + ); + }, + child: const Text('Link senden'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Bürgerforum Oggau', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + TabBar( + controller: _tabController, + labelColor: Colors.blue.shade800, + indicatorColor: Colors.blue.shade800, + tabs: const [ + Tab(text: 'Anmelden'), + Tab(text: 'Registrieren'), + Tab(text: 'Code-Login'), + ], + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: Colors.red, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + fontSize: 12, color: Colors.red.shade900), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: SizedBox( + height: 280, + child: TabBarView( + controller: _tabController, + children: [ + // Tab 1: Login + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _loginEmailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'E-Mail-Adresse', + prefixIcon: Icon(Icons.email_outlined), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _loginPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Passwort', + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(), + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _showPasswordResetDialog, + child: const Text( + 'Passwort vergessen?', + style: TextStyle(fontSize: 12), + ), + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Anmelden'), + ), + ], + ), + + // Tab 2: Registrierung + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _regNameController, + decoration: const InputDecoration( + labelText: 'Name / Vorname', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _regEmailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'E-Mail-Adresse', + prefixIcon: Icon(Icons.email_outlined), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _regPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Passwort (mind. 6 Zeichen)', + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _regConfirmPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Passwort bestätigen', + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(), + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _isLoading ? null : _handleRegister, + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Konto erstellen'), + ), + ], + ), + + // Tab 3: Bürger-Code + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Du möchtest kein persönliches Konto anlegen? Gib einfach den Zugangscode des Bürgerforums ein, um sofort Mängel und Ideen melden zu können.', + style: + TextStyle(fontSize: 13, color: Colors.black87), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Bürger-Zugangscode', + prefixIcon: Icon(Icons.key_outlined), + border: OutlineInputBorder(), + hintText: 'z.B. oggau', + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _isLoading ? null : _handleCodeUnlock, + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Freischalten'), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/info_screen.dart b/lib/screens/info_screen.dart index fdf2907..bc35ef6 100644 --- a/lib/screens/info_screen.dart +++ b/lib/screens/info_screen.dart @@ -1,76 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/auth_service.dart'; +import 'auth_dialog.dart'; -class InfoScreen extends StatefulWidget { +class InfoScreen extends StatelessWidget { const InfoScreen({super.key}); - @override - State createState() => _InfoScreenState(); -} - -class _InfoScreenState extends State { - final TextEditingController _codeController = TextEditingController(); - - @override - void dispose() { - _codeController.dispose(); - super.dispose(); - } - - void _showUnlockDialog(BuildContext context) { - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Bürgerforum-Zugang'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Gib den Zugangscode des Bürgerforums Oggau ein, um Mängel zu melden und eigene Ideen einzubringen.', - style: TextStyle(fontSize: 13), - ), - const SizedBox(height: 16), - TextField( - controller: _codeController, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Zugangscode', - border: OutlineInputBorder(), - hintText: 'z.B. oggau', - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogCtx), - child: const Text('Abbrechen'), - ), - ElevatedButton( - onPressed: () async { - final auth = Provider.of(context, listen: false); - final success = - await auth.verifyCommunityCode(_codeController.text); - if (!dialogCtx.mounted) return; - Navigator.pop(dialogCtx); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success - ? 'Zugang erfolgreich freigeschaltet!' - : 'Ungültiger Code. Bitte wende dich an das Bürgerforum Oggau.'), - backgroundColor: success ? Colors.green : Colors.red, - ), - ); - }, - child: const Text('Freischalten'), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final auth = Provider.of(context); @@ -114,57 +49,108 @@ class _InfoScreenState extends State { ), ), const SizedBox(height: 8), - // Bürgerforum Access Status Card + // Bürgerforum Access & Account Card Card( - color: auth.isCommunityUnlocked ? Colors.green.shade50 : Colors.blue.shade50, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: auth.isRegisteredUser + ? Colors.green.shade50 + : auth.isCommunityUnlocked + ? Colors.teal.shade50 + : Colors.blue.shade50, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(14.0), - child: Row( + child: Column( children: [ - Icon( - auth.isCommunityUnlocked - ? Icons.verified_user - : Icons.lock_outline, - color: auth.isCommunityUnlocked ? Colors.green.shade700 : Colors.blue.shade800, - size: 32, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - auth.isCommunityUnlocked - ? 'Bürgerzugang: Aktiv' - : 'Gastmodus (Nur Lesen)', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 15), + Row( + children: [ + Icon( + auth.isRegisteredUser + ? Icons.account_circle + : auth.isCommunityUnlocked + ? Icons.verified_user + : Icons.lock_outline, + color: auth.isRegisteredUser + ? Colors.green.shade700 + : auth.isCommunityUnlocked + ? Colors.teal.shade800 + : Colors.blue.shade800, + size: 36, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + auth.isRegisteredUser + ? (auth.displayName?.isNotEmpty == true + ? auth.displayName! + : 'Bürger-Konto') + : auth.isCommunityUnlocked + ? 'Bürgerzugang: Aktiv (Code)' + : 'Gastmodus (Nur Lesen)', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 2), + Text( + auth.isRegisteredUser + ? auth.email ?? 'Angemeldet' + : auth.isCommunityUnlocked + ? 'Mängel & Ideen freigeschaltet' + : 'Anmelden oder Code eingeben zum Mitwirken.', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade700), + ), + ], ), - const SizedBox(height: 2), - Text( - auth.isCommunityUnlocked - ? 'Du kannst Mängel und Ideen einreichen.' - : 'Code eingeben, um Beiträge zu erstellen.', - style: TextStyle( - fontSize: 12, color: Colors.grey.shade700), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), ), - ], - ), + onPressed: () { + if (auth.isRegisteredUser || auth.isCommunityUnlocked) { + auth.lockCommunityAccess(); + } else { + AuthDialog.show(context); + } + }, + child: Text( + (auth.isRegisteredUser || auth.isCommunityUnlocked) + ? 'Abmelden' + : 'Anmelden', + ), + ), + ], ), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + if (!auth.isRegisteredUser && auth.isCommunityUnlocked) ...[ + const Divider(height: 16), + InkWell( + onTap: () => AuthDialog.show(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_add, + size: 16, color: Colors.blue.shade800), + const SizedBox(width: 6), + Text( + 'Persönliches Konto anlegen für dauerhaften Login', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blue.shade800, + ), + ), + ], + ), + ), ), - onPressed: () { - if (auth.isCommunityUnlocked) { - auth.lockCommunityAccess(); - } else { - _showUnlockDialog(context); - } - }, - child: Text(auth.isCommunityUnlocked ? 'Sperren' : 'Freischalten'), - ), + ], ], ), ), diff --git a/lib/screens/post_list_screen.dart b/lib/screens/post_list_screen.dart index 0c7797f..1b899d4 100644 --- a/lib/screens/post_list_screen.dart +++ b/lib/screens/post_list_screen.dart @@ -10,6 +10,7 @@ import '../services/pdf_service.dart'; import '../services/storage_service.dart'; import 'add_issue_screen.dart'; import 'add_post_screen.dart'; +import 'auth_dialog.dart'; import 'detail_screen.dart'; class PostListScreen extends StatefulWidget { @@ -75,19 +76,15 @@ class _PostListScreenState extends State { } } - void _onAddNewPressed(BuildContext context) { + Future _onAddNewPressed() async { final auth = Provider.of(context, listen: false); if (!auth.isCommunityUnlocked && !auth.isAuthenticated) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Bitte schalten Sie den Zugang unter "Informationen" mit dem Gemeinde-Passwort frei.', - ), - backgroundColor: Colors.orange, - ), - ); - return; + await AuthDialog.show(context); + if (!mounted) return; + if (!auth.isCommunityUnlocked && !auth.isAuthenticated) { + return; + } } _clearSelection(); @@ -413,7 +410,7 @@ class _PostListScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: () => _onAddNewPressed(context), + onPressed: _onAddNewPressed, tooltip: 'Neuen Eintrag erstellen', child: const Icon(Icons.add), ), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 06e9bcc..869e7a8 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -12,7 +12,11 @@ class AuthService extends ChangeNotifier { User? get user => _user; bool get isAuthenticated => _user != null; - bool get isCommunityUnlocked => _isCommunityUnlocked; + bool get isAnonymous => _user?.isAnonymous ?? true; + bool get isRegisteredUser => _user != null && !_user!.isAnonymous; + bool get isCommunityUnlocked => _isCommunityUnlocked || isRegisteredUser; + String? get email => _user?.email; + String? get displayName => _user?.displayName; AuthService() { _initAuth(); @@ -27,26 +31,30 @@ class AuthService extends ChangeNotifier { notifyListeners(); }); - // Automatically ensure anonymous login if unlocked if (_user == null && _isCommunityUnlocked) { await signInAnonymously(); } } + /// Verifies community access code Future verifyCommunityCode(String enteredCode) async { final cleanCode = enteredCode.trim().toLowerCase(); - // Accept default code 'oggau' or any custom community password - if (cleanCode == defaultCommunityCode || cleanCode == 'oggau2024' || cleanCode == 'oggau7063') { + if (cleanCode == defaultCommunityCode || + cleanCode == 'oggau2024' || + cleanCode == 'oggau7063') { _isCommunityUnlocked = true; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefKeyUnlocked, true); - await signInAnonymously(); + if (_user == null) { + await signInAnonymously(); + } notifyListeners(); return true; } return false; } + /// Lock community access and log out Future lockCommunityAccess() async { _isCommunityUnlocked = false; final prefs = await SharedPreferences.getInstance(); @@ -55,6 +63,60 @@ class AuthService extends ChangeNotifier { notifyListeners(); } + /// Sign in with Email and Password + Future signInWithEmail(String email, String password) async { + try { + final credential = await _auth.signInWithEmailAndPassword( + email: email.trim(), + password: password, + ); + _user = credential.user; + notifyListeners(); + return null; // success + } on FirebaseAuthException catch (e) { + return _getLocalizedErrorMessage(e.code); + } catch (e) { + return 'Ein unerwarteter Fehler ist aufgetreten: $e'; + } + } + + /// Sign up with Email, Password, and Full Name + Future signUpWithEmail({ + required String email, + required String password, + required String name, + }) async { + try { + final credential = await _auth.createUserWithEmailAndPassword( + email: email.trim(), + password: password, + ); + _user = credential.user; + if (_user != null && name.isNotEmpty) { + await _user!.updateDisplayName(name.trim()); + } + notifyListeners(); + return null; // success + } on FirebaseAuthException catch (e) { + return _getLocalizedErrorMessage(e.code); + } catch (e) { + return 'Ein unerwarteter Fehler ist aufgetreten: $e'; + } + } + + /// Send password reset email + Future sendPasswordReset(String email) async { + try { + await _auth.sendPasswordResetEmail(email: email.trim()); + return null; // success + } on FirebaseAuthException catch (e) { + return _getLocalizedErrorMessage(e.code); + } catch (e) { + return 'Fehler beim Senden der E-Mail: $e'; + } + } + + /// Anonymous login Future signInAnonymously() async { try { await _auth.signInAnonymously(); @@ -65,6 +127,7 @@ class AuthService extends ChangeNotifier { } } + /// Sign out Future signOut() async { try { await _auth.signOut(); @@ -74,4 +137,26 @@ class AuthService extends ChangeNotifier { } } } + + String _getLocalizedErrorMessage(String code) { + switch (code) { + case 'user-not-found': + return 'Kein Benutzer mit dieser E-Mail-Adresse gefunden.'; + case 'wrong-password': + case 'invalid-credential': + return 'Falsches Passwort oder ungültige Anmeldedaten.'; + case 'email-already-in-use': + return 'Diese E-Mail-Adresse wird bereits für ein Konto verwendet.'; + case 'invalid-email': + return 'Bitte gib eine gültige E-Mail-Adresse ein.'; + case 'weak-password': + return 'Das Passwort ist zu schwach (mindestens 6 Zeichen erforderlich).'; + case 'too-many-requests': + return 'Zu viele fehlgeschlagene Versuche. Bitte warte kurz.'; + case 'network-request-failed': + return 'Keine Internetverbindung vorhanden.'; + default: + return 'Authentifizierungsfehler ($code).'; + } + } }