feat(auth): add AuthDialog with registration, login, code-unlock and wire into UI

This commit is contained in:
2026-09-13 21:26:57 +02:00
parent 9fd2b2aa4b
commit cfb68fd8bb
5 changed files with 655 additions and 126 deletions
+459
View File
@@ -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<void> show(BuildContext context) async {
await showDialog(
context: context,
builder: (ctx) => const AuthDialog(),
);
}
@override
State<AuthDialog> createState() => _AuthDialogState();
}
class _AuthDialogState extends State<AuthDialog>
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<void> _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<AuthService>(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<void> _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<AuthService>(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<void> _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<AuthService>(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<AuthService>(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'),
),
],
),
],
),
),
),
),
],
),
),
),
);
}
}
+96 -110
View File
@@ -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<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
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<AuthService>(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<AuthService>(context);
@@ -114,57 +49,108 @@ class _InfoScreenState extends State<InfoScreen> {
),
),
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'),
),
],
],
),
),
+8 -11
View File
@@ -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<PostListScreen> {
}
}
void _onAddNewPressed(BuildContext context) {
Future<void> _onAddNewPressed() async {
final auth = Provider.of<AuthService>(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<PostListScreen> {
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onAddNewPressed(context),
onPressed: _onAddNewPressed,
tooltip: 'Neuen Eintrag erstellen',
child: const Icon(Icons.add),
),