feat: implement authentication flow with login, password reset, and club selection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s

- Added dependencies for dio, flutter_secure_storage, and shared_preferences.
- Created ApiClient for handling API requests with token management.
- Implemented AuthRepository for authentication operations.
- Developed TokenStorage for secure token storage.
- Added AppSession to manage user session state and club selection.
- Created password reset pages for requesting and saving new passwords.
- Implemented ClubSelectionPage for selecting an active club.
- Updated widget tests to reflect new login flow and UI changes.
- Documented progress in FLUTTER_DESKTOP_APP_PLAN.md.
This commit is contained in:
Torsten Schulz (local)
2026-07-30 10:54:02 +02:00
parent 240cbf7d14
commit ef8d99eaf4
23 changed files with 1469 additions and 105 deletions

View File

@@ -23,6 +23,7 @@ linter:
rules:
avoid_print: true
prefer_single_quotes: true
prefer_initializing_formals: false
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,73 @@
import 'package:dio/dio.dart';
import '../../data/auth/token_storage.dart';
import '../config/app_environment.dart';
import 'api_exception.dart';
class ApiClient {
ApiClient({
required AppEnvironment environment,
required TokenStorage tokenStorage,
}) : _dio = Dio(
BaseOptions(
baseUrl: environment.apiBaseUrl.toString(),
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 60),
headers: const {'Accept': 'application/json'},
),
) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenStorage.readToken();
if (token != null && token.isNotEmpty) {
options.headers['authcode'] = token;
}
handler.next(options);
},
),
);
}
final Dio _dio;
Future<Map<String, dynamic>> getObject(String path) async {
try {
final response = await _dio.get<dynamic>(path);
return Map<String, dynamic>.from(response.data as Map);
} on DioException catch (error) {
throw _toApiException(error);
}
}
Future<List<dynamic>> getList(String path) async {
try {
final response = await _dio.get<dynamic>(path);
return List<dynamic>.from(response.data as List);
} on DioException catch (error) {
throw _toApiException(error);
}
}
Future<Map<String, dynamic>> postObject(String path, {Object? data}) async {
try {
final response = await _dio.post<dynamic>(path, data: data);
return Map<String, dynamic>.from(response.data as Map);
} on DioException catch (error) {
throw _toApiException(error);
}
}
ApiException _toApiException(DioException error) {
final data = error.response?.data;
final message = data is Map
? (data['message'] ??
data['error'] ??
'Die Anfrage konnte nicht verarbeitet werden.')
.toString()
: error.type == DioExceptionType.connectionTimeout
? 'Die Verbindung dauert zu lange.'
: 'Der Server ist derzeit nicht erreichbar.';
return ApiException(message, statusCode: error.response?.statusCode);
}
}

View File

@@ -0,0 +1,11 @@
class ApiException implements Exception {
const ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
bool get isUnauthorized => statusCode == 401;
@override
String toString() => message;
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../data/auth/auth_repository.dart';
import '../../data/auth/token_storage.dart';
import '../../data/clubs/club_repository.dart';
import '../../domain/models/club.dart';
import '../../domain/models/club_permissions.dart';
enum SessionStatus { starting, signedOut, selectingClub, ready }
class AppSession extends ChangeNotifier {
AppSession({
required AuthRepository auth,
required ClubRepository clubs,
required TokenStorage tokens,
}) : _auth = auth,
_clubs = clubs,
_tokens = tokens;
static const _clubKey = 'active_club_id';
final AuthRepository _auth;
final ClubRepository _clubs;
final TokenStorage _tokens;
final _preferences = SharedPreferencesAsync();
SessionStatus status = SessionStatus.starting;
bool busy = false;
String? errorMessage;
List<Club> clubs = const [];
Club? activeClub;
ClubPermissions? permissions;
Future<void> restore() async {
if (await _tokens.readToken() == null) {
return _setStatus(SessionStatus.signedOut);
}
try {
if (!await _auth.isSessionValid()) return logout(notifyServer: false);
await _loadClubs();
} catch (_) {
await logout(notifyServer: false);
}
}
Future<void> signIn(String email, String password, bool rememberMe) async {
await _run(() async {
final token = await _auth.login(
email: email,
password: password,
rememberMe: rememberMe,
);
await _tokens.saveToken(token, persist: rememberMe);
await _loadClubs();
});
}
Future<void> requestPasswordReset(String email) =>
_auth.requestPasswordReset(email);
Future<void> resetPassword(String token, String password) =>
_auth.resetPassword(token, password);
Future<void> _loadClubs() async {
clubs = await _clubs.list();
final savedId = int.tryParse(await _preferences.getString(_clubKey) ?? '');
final selected = clubs.where((club) => club.id == savedId).firstOrNull;
if (selected != null) {
await selectClub(selected.id);
return;
}
status = SessionStatus.selectingClub;
notifyListeners();
}
Future<void> selectClub(int clubId) async {
await _run(() async {
final club = clubs.firstWhere((item) => item.id == clubId);
permissions = await _clubs.permissions(clubId);
activeClub = club;
await _preferences.setString(_clubKey, '$clubId');
status = SessionStatus.ready;
});
}
void chooseClub(int clubId) {
activeClub = clubs.firstWhere((club) => club.id == clubId);
notifyListeners();
}
Future<void> logout({bool notifyServer = true}) async {
try {
if (notifyServer) {
await _auth.logout();
}
} catch (_) {}
await _tokens.clear();
await _preferences.remove(_clubKey);
clubs = const [];
activeClub = null;
permissions = null;
_setStatus(SessionStatus.signedOut);
}
Future<void> _run(Future<void> Function() task) async {
busy = true;
errorMessage = null;
notifyListeners();
try {
await task();
} catch (error) {
errorMessage = error.toString();
}
busy = false;
if (status == SessionStatus.starting && errorMessage != null) {
status = SessionStatus.signedOut;
}
notifyListeners();
}
void _setStatus(SessionStatus value) {
status = value;
busy = false;
errorMessage = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,28 @@
import '../../core/network/api_client.dart';
class AuthRepository {
const AuthRepository(this._api);
final ApiClient _api;
Future<String> login({
required String email,
required String password,
required bool rememberMe,
}) async {
final response = await _api.postObject(
'/auth/login',
data: {'email': email, 'password': password, 'rememberMe': rememberMe},
);
return response['token'] as String;
}
Future<bool> isSessionValid() async =>
(await _api.getObject('/session/status'))['valid'] == true;
Future<void> logout() async => _api.postObject('/auth/logout');
Future<void> requestPasswordReset(String email) =>
_api.postObject('/auth/forgot-password', data: {'email': email});
Future<void> resetPassword(String token, String password) => _api.postObject(
'/auth/reset-password',
data: {'token': token, 'password': password},
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
TokenStorage({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const _tokenKey = 'tt_tagebuch.jwt';
final FlutterSecureStorage _storage;
String? _sessionToken;
Future<String?> readToken() async =>
_sessionToken ?? _storage.read(key: _tokenKey);
Future<void> saveToken(String token, {required bool persist}) async {
_sessionToken = persist ? null : token;
if (persist) {
await _storage.write(key: _tokenKey, value: token);
} else {
await _storage.delete(key: _tokenKey);
}
}
Future<void> clear() async {
_sessionToken = null;
await _storage.delete(key: _tokenKey);
}
}

View File

@@ -0,0 +1,15 @@
import '../../core/network/api_client.dart';
import '../../domain/models/club.dart';
import '../../domain/models/club_permissions.dart';
class ClubRepository {
const ClubRepository(this._api);
final ApiClient _api;
Future<List<Club>> list() async => (await _api.getList('/clubs'))
.map((item) => Club.fromJson(Map<String, dynamic>.from(item as Map)))
.toList();
Future<ClubPermissions> permissions(int clubId) async =>
ClubPermissions.fromJson(await _api.getObject('/permissions/$clubId'));
}

View File

@@ -0,0 +1,11 @@
class Club {
const Club({required this.id, required this.name});
final int id;
final String name;
factory Club.fromJson(Map<String, dynamic> json) => Club(
id: (json['id'] as num).toInt(),
name: json['name'] as String? ?? 'Unbenannter Verein',
);
}

View File

@@ -0,0 +1,14 @@
class ClubPermissions {
const ClubPermissions({required this.role, required this.permissions});
final String? role;
final Map<String, dynamic> permissions;
factory ClubPermissions.fromJson(Map<String, dynamic> json) =>
ClubPermissions(
role: json['role'] as String?,
permissions: Map<String, dynamic>.from(
json['permissions'] as Map? ?? const {},
),
);
}

View File

@@ -1,22 +1,52 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/product_mark.dart';
typedef SignInCallback =
Future<void> Function(String email, String password, bool rememberMe);
/// A focused sign-in surface. Navigation and backend integration belong to the
/// caller; this widget only validates input and forwards the submitted values.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
const LoginPage({
super.key,
this.onSignIn,
this.onForgotPassword,
this.isLoading = false,
this.errorMessage,
});
final SignInCallback? onSignIn;
final VoidCallback? onForgotPassword;
final bool isLoading;
final String? errorMessage;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _rememberMe = true;
void _enterWorkspace() {
// Phase 2 replaces this temporary transition with API-backed login.
context.go('/app');
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate() || widget.isLoading) return;
await widget.onSignIn?.call(
_emailController.text.trim(),
_passwordController.text,
_rememberMe,
);
}
@override
@@ -34,11 +64,22 @@ class _LoginPageState extends State<LoginPage> {
padding: const EdgeInsets.all(32),
child: SizedBox(
width: 420,
child: _LoginForm(
onSubmit: _enterWorkspace,
obscure: _obscurePassword,
onToggle: () => setState(
() => _obscurePassword = !_obscurePassword,
child: Form(
key: _formKey,
child: _LoginForm(
emailController: _emailController,
passwordController: _passwordController,
obscurePassword: _obscurePassword,
rememberMe: _rememberMe,
isLoading: widget.isLoading,
errorMessage: widget.errorMessage,
onTogglePassword: () => setState(
() => _obscurePassword = !_obscurePassword,
),
onRememberMeChanged: (value) =>
setState(() => _rememberMe = value),
onForgotPassword: widget.onForgotPassword,
onSubmit: _submit,
),
),
),
@@ -57,72 +98,85 @@ class _WelcomePanel extends StatelessWidget {
const _WelcomePanel();
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
color: AppColors.green,
padding: const EdgeInsets.all(56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
ProductMark(light: true),
SizedBox(width: 12),
Text(
'TT-Tagebuch',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 20,
),
Widget build(BuildContext context) => Expanded(
child: Container(
color: AppColors.green,
padding: const EdgeInsets.all(56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
ProductMark(light: true),
SizedBox(width: 12),
Text(
'TT-Tagebuch',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 20,
),
],
),
const Spacer(),
const Text(
'Der gemeinsame\nSpielraum für\neuren Verein.',
style: TextStyle(
color: Colors.white,
fontSize: 42,
fontWeight: FontWeight.w700,
height: 1.08,
letterSpacing: -1.2,
),
],
),
const Spacer(),
const Text(
'Der gemeinsame\nSpielraum für\neuren Verein.',
style: TextStyle(
color: Colors.white,
fontSize: 42,
fontWeight: FontWeight.w700,
height: 1.08,
letterSpacing: -1.2,
),
const SizedBox(height: 20),
Text(
'Trainings, Mannschaften und Termine an einem klaren Ort für Mitglieder, Trainer und Verwaltung.',
style: TextStyle(
color: Colors.white.withValues(alpha: .78),
fontSize: 16,
height: 1.5,
),
),
const SizedBox(height: 20),
Text(
'Trainings, Mannschaften und Termine an einem klaren Ort für Mitglieder, Trainer und Verwaltung.',
style: TextStyle(
color: Colors.white.withValues(alpha: .78),
fontSize: 16,
height: 1.5,
),
const Spacer(),
Text(
'Tischtennis organisieren. Gemeinsam wachsen.',
style: TextStyle(
color: Colors.white.withValues(alpha: .68),
fontSize: 13,
),
),
const Spacer(),
Text(
'Tischtennis organisieren. Gemeinsam wachsen.',
style: TextStyle(
color: Colors.white.withValues(alpha: .68),
fontSize: 13,
),
],
),
),
],
),
);
}
),
);
}
class _LoginForm extends StatelessWidget {
const _LoginForm({
required this.emailController,
required this.passwordController,
required this.obscurePassword,
required this.rememberMe,
required this.isLoading,
required this.onTogglePassword,
required this.onRememberMeChanged,
required this.onSubmit,
required this.obscure,
required this.onToggle,
this.errorMessage,
this.onForgotPassword,
});
final TextEditingController emailController;
final TextEditingController passwordController;
final bool obscurePassword;
final bool rememberMe;
final bool isLoading;
final String? errorMessage;
final VoidCallback onTogglePassword;
final ValueChanged<bool> onRememberMeChanged;
final VoidCallback onSubmit;
final bool obscure;
final VoidCallback onToggle;
final VoidCallback? onForgotPassword;
@override
Widget build(BuildContext context) {
@@ -138,46 +192,88 @@ class _LoginForm extends StatelessWidget {
'Melde dich mit deinem Vereinszugang an.',
style: text.bodyLarge?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 34),
const TextField(
if (errorMessage != null) ...[
const SizedBox(height: 24),
_MessagePanel(message: errorMessage!, isError: true),
],
const SizedBox(height: 28),
TextFormField(
controller: emailController,
enabled: !isLoading,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username, AutofillHints.email],
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.mail_outline_rounded),
),
validator: (value) {
final email = value?.trim() ?? '';
if (email.isEmpty) return 'Bitte gib deine E-Mail-Adresse ein.';
if (!email.contains('@')) return 'Bitte prüfe die E-Mail-Adresse.';
return null;
},
),
const SizedBox(height: 16),
TextField(
obscureText: obscure,
TextFormField(
controller: passwordController,
enabled: !isLoading,
obscureText: obscurePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onFieldSubmitted: (_) => onSubmit(),
decoration: InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
tooltip: obscure ? 'Passwort anzeigen' : 'Passwort verbergen',
onPressed: onToggle,
tooltip: obscurePassword
? 'Passwort anzeigen'
: 'Passwort verbergen',
onPressed: isLoading ? null : onTogglePassword,
icon: Icon(
obscure
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) =>
(value?.isEmpty ?? true) ? 'Bitte gib dein Passwort ein.' : null,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {},
child: const Text('Passwort vergessen?'),
),
const SizedBox(height: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.spaceBetween,
children: [
Checkbox(
value: rememberMe,
onChanged: isLoading
? null
: (value) => onRememberMeChanged(value ?? false),
),
const Text('Angemeldet bleiben'),
TextButton(
onPressed: isLoading ? null : onForgotPassword,
child: const Text('Passwort vergessen?'),
),
],
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onSubmit,
icon: const Icon(Icons.login_rounded),
label: const Text('Anmelden'),
onPressed: isLoading ? null : onSubmit,
icon: isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: Text(isLoading ? 'Anmeldung läuft …' : 'Anmelden'),
),
),
const SizedBox(height: 28),
@@ -191,3 +287,41 @@ class _LoginForm extends StatelessWidget {
);
}
}
class _MessagePanel extends StatelessWidget {
const _MessagePanel({required this.message, required this.isError});
final String message;
final bool isError;
@override
Widget build(BuildContext context) {
final color = isError ? AppColors.coral : AppColors.green;
return Semantics(
liveRegion: true,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: color.withValues(alpha: .09),
border: Border(left: BorderSide(color: color, width: 3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isError
? Icons.info_outline_rounded
: Icons.check_circle_outline_rounded,
color: color,
),
const SizedBox(width: 10),
Expanded(
child: Text(message, style: TextStyle(color: AppColors.ink)),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/product_mark.dart';
typedef ResetRequestCallback = Future<void> Function(String email);
typedef ResetPasswordCallback = Future<void> Function(String password);
class PasswordResetRequestPage extends StatefulWidget {
const PasswordResetRequestPage({
super.key,
this.onRequestReset,
this.onBackToLogin,
this.isLoading = false,
this.errorMessage,
this.isSuccess = false,
});
final ResetRequestCallback? onRequestReset;
final VoidCallback? onBackToLogin;
final bool isLoading;
final String? errorMessage;
final bool isSuccess;
@override
State<PasswordResetRequestPage> createState() =>
_PasswordResetRequestPageState();
}
class _PasswordResetRequestPageState extends State<PasswordResetRequestPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate() || widget.isLoading) return;
await widget.onRequestReset?.call(_emailController.text.trim());
}
@override
Widget build(BuildContext context) => _AuthFrame(
title: widget.isSuccess ? 'E-Mail unterwegs' : 'Passwort zurücksetzen',
subtitle: widget.isSuccess
? 'Falls ein Zugang zu dieser Adresse gehört, findest du dort in Kürze einen Link zum Zurücksetzen.'
: 'Gib deine Vereins-E-Mail-Adresse ein. Wir schicken dir einen sicheren Link.',
child: widget.isSuccess
? _BackButton(onPressed: widget.onBackToLogin)
: Form(
key: _formKey,
child: Column(
children: [
if (widget.errorMessage != null) ...[
_AuthNotice(message: widget.errorMessage!, error: true),
const SizedBox(height: 20),
],
TextFormField(
controller: _emailController,
enabled: !widget.isLoading,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.mail_outline_rounded),
),
validator: (value) => (value?.trim().contains('@') ?? false)
? null
: 'Bitte gib eine gültige E-Mail-Adresse ein.',
),
const SizedBox(height: 24),
_PrimaryButton(
loading: widget.isLoading,
icon: Icons.send_outlined,
label: 'Link anfordern',
onPressed: _submit,
),
const SizedBox(height: 12),
_BackButton(onPressed: widget.onBackToLogin),
],
),
),
);
}
class PasswordResetPage extends StatefulWidget {
const PasswordResetPage({
super.key,
required this.token,
this.onSavePassword,
this.onBackToLogin,
this.isLoading = false,
this.errorMessage,
this.isSuccess = false,
});
final String token;
final ResetPasswordCallback? onSavePassword;
final VoidCallback? onBackToLogin;
final bool isLoading;
final String? errorMessage;
final bool isSuccess;
@override
State<PasswordResetPage> createState() => _PasswordResetPageState();
}
class _PasswordResetPageState extends State<PasswordResetPage> {
final _formKey = GlobalKey<FormState>();
final _passwordController = TextEditingController();
final _confirmationController = TextEditingController();
@override
void dispose() {
_passwordController.dispose();
_confirmationController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate() || widget.isLoading) return;
await widget.onSavePassword?.call(_passwordController.text);
}
@override
Widget build(BuildContext context) => _AuthFrame(
title: widget.isSuccess ? 'Passwort gespeichert' : 'Neues Passwort wählen',
subtitle: widget.isSuccess
? 'Dein Zugang ist wieder bereit. Du kannst dich jetzt mit deinem neuen Passwort anmelden.'
: 'Wähle ein neues Passwort für deinen Vereinszugang.',
child: widget.isSuccess
? _BackButton(onPressed: widget.onBackToLogin, label: 'Zur Anmeldung')
: Form(
key: _formKey,
child: Column(
children: [
if (widget.errorMessage != null) ...[
_AuthNotice(message: widget.errorMessage!, error: true),
const SizedBox(height: 20),
],
TextFormField(
controller: _passwordController,
enabled: !widget.isLoading,
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
decoration: const InputDecoration(
labelText: 'Neues Passwort',
prefixIcon: Icon(Icons.lock_outline_rounded),
),
validator: (value) => (value?.length ?? 0) >= 8
? null
: 'Das Passwort muss mindestens 8 Zeichen haben.',
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmationController,
enabled: !widget.isLoading,
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: const InputDecoration(
labelText: 'Passwort wiederholen',
prefixIcon: Icon(Icons.lock_reset_outlined),
),
validator: (value) => value == _passwordController.text
? null
: 'Die Passwörter stimmen nicht überein.',
),
const SizedBox(height: 24),
_PrimaryButton(
loading: widget.isLoading,
icon: Icons.save_outlined,
label: 'Passwort speichern',
onPressed: _submit,
),
],
),
),
);
}
class _AuthFrame extends StatelessWidget {
const _AuthFrame({
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: SizedBox(
width: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProductMark(size: 38),
const SizedBox(height: 36),
Text(title, style: text.displaySmall),
const SizedBox(height: 8),
Text(
subtitle,
style: text.bodyLarge?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 32),
child,
],
),
),
),
),
);
}
}
class _PrimaryButton extends StatelessWidget {
const _PrimaryButton({
required this.loading,
required this.icon,
required this.label,
required this.onPressed,
});
final bool loading;
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) => SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: loading ? null : onPressed,
icon: loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(icon),
label: Text(loading ? 'Bitte warten …' : label),
),
);
}
class _BackButton extends StatelessWidget {
const _BackButton({this.onPressed, this.label = 'Zurück zur Anmeldung'});
final VoidCallback? onPressed;
final String label;
@override
Widget build(BuildContext context) => TextButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.arrow_back_rounded),
label: Text(label),
);
}
class _AuthNotice extends StatelessWidget {
const _AuthNotice({required this.message, required this.error});
final String message;
final bool error;
@override
Widget build(BuildContext context) => Semantics(
liveRegion: true,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: (error ? AppColors.coral : AppColors.green).withValues(
alpha: .09,
),
border: Border(
left: BorderSide(
color: error ? AppColors.coral : AppColors.green,
width: 3,
),
),
),
child: Text(message),
),
);
}

View File

@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/product_mark.dart';
class ClubViewModel {
const ClubViewModel({
required this.id,
required this.name,
required this.subtitle,
});
final String id;
final String name;
final String subtitle;
}
class ClubSelectionPage extends StatelessWidget {
const ClubSelectionPage({
super.key,
required this.clubs,
required this.selectedClubId,
required this.onSelected,
this.onContinue,
this.onRetry,
this.onLogout,
this.isLoading = false,
this.errorMessage,
});
final List<ClubViewModel> clubs;
final String? selectedClubId;
final ValueChanged<String> onSelected;
final VoidCallback? onContinue;
final VoidCallback? onRetry;
final VoidCallback? onLogout;
final bool isLoading;
final String? errorMessage;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final content = isLoading
? const _LoadingState()
: errorMessage != null
? _ErrorState(message: errorMessage!, onRetry: onRetry)
: clubs.isEmpty
? const _EmptyState()
: _ClubList(
clubs: clubs,
selectedClubId: selectedClubId,
onSelected: onSelected,
);
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProductMark(size: 38),
const SizedBox(height: 36),
Text('Verein auswählen', style: text.displaySmall),
const SizedBox(height: 8),
Text(
'Wähle den Verein, in dem du jetzt arbeiten möchtest.',
style: text.bodyLarge?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 28),
Expanded(child: content),
if (!isLoading && errorMessage == null && clubs.isNotEmpty) ...[
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: selectedClubId == null ? null : onContinue,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('Weiter zum Verein'),
),
),
],
if (onLogout != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: onLogout,
icon: const Icon(Icons.logout_rounded),
label: const Text('Abmelden'),
),
],
],
),
),
),
),
);
}
}
class _ClubList extends StatelessWidget {
const _ClubList({
required this.clubs,
required this.selectedClubId,
required this.onSelected,
});
final List<ClubViewModel> clubs;
final String? selectedClubId;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) => ListView.separated(
itemCount: clubs.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final club = clubs[index];
final selected = club.id == selectedClubId;
return Semantics(
selected: selected,
button: true,
child: Material(
color: selected
? AppColors.green.withValues(alpha: .08)
: AppColors.surface,
shape: RoundedRectangleBorder(
side: BorderSide(
color: selected ? AppColors.green : AppColors.line,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onSelected(club.id),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Icon(
selected
? Icons.check_circle_rounded
: Icons.location_city_outlined,
color: selected ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
club.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 3),
Text(
club.subtitle,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppColors.muted),
),
],
),
),
],
),
),
),
),
);
},
);
}
class _LoadingState extends StatelessWidget {
const _LoadingState();
@override
Widget build(BuildContext context) => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Vereine werden geladen …'),
],
),
);
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups_outlined, size: 42, color: AppColors.muted),
SizedBox(height: 14),
Text('Kein Verein verfügbar.'),
SizedBox(height: 4),
Text(
'Bitte wende dich an die Vereinsverwaltung.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.muted),
),
],
),
);
}
class _ErrorState extends StatelessWidget {
const _ErrorState({required this.message, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.info_outline_rounded,
size: 42,
color: AppColors.coral,
),
const SizedBox(height: 14),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Erneut versuchen'),
),
],
),
);
}

View File

@@ -4,7 +4,8 @@ import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/product_mark.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
const AppShell({super.key, this.onLogout});
final VoidCallback? onLogout;
@override
State<AppShell> createState() => _AppShellState();
@@ -46,6 +47,7 @@ class _AppShellState extends State<AppShell> {
definition: definition,
compact: compact,
onProductSelected: _changeProduct,
onLogout: widget.onLogout,
),
Expanded(
child: _Content(page: page, product: definition),
@@ -370,11 +372,13 @@ class _TopBar extends StatelessWidget {
required this.definition,
required this.compact,
required this.onProductSelected,
this.onLogout,
});
final ProductDefinition definition;
final bool compact;
final ValueChanged<Product> onProductSelected;
final VoidCallback? onLogout;
@override
Widget build(BuildContext context) => Container(
@@ -401,6 +405,12 @@ class _TopBar extends StatelessWidget {
),
),
const SizedBox(width: 10),
IconButton(
onPressed: onLogout,
tooltip: 'Abmelden',
icon: const Icon(Icons.logout_rounded),
),
const SizedBox(width: 4),
const CircleAvatar(
radius: 17,
backgroundColor: Color(0xFFDDECE5),

View File

@@ -1,23 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/navigation/app_router.dart';
import 'core/config/app_environment.dart';
import 'core/network/api_client.dart';
import 'core/session/app_session.dart';
import 'core/theme/app_theme.dart';
import 'data/auth/auth_repository.dart';
import 'data/auth/token_storage.dart';
import 'data/clubs/club_repository.dart';
import 'features/auth/presentation/login_page.dart';
import 'features/auth/presentation/password_reset_pages.dart';
import 'features/clubs/presentation/club_selection_page.dart';
import 'features/shell/presentation/app_shell.dart';
void main() {
runApp(const ProviderScope(child: TtTagebuchApp()));
WidgetsFlutterBinding.ensureInitialized();
final tokens = TokenStorage();
final api = ApiClient(
environment: AppEnvironment.fromBuildConfiguration(),
tokenStorage: tokens,
);
final session = AppSession(
auth: AuthRepository(api),
clubs: ClubRepository(api),
tokens: tokens,
);
runApp(ProviderScope(child: TtTagebuchApp(session: session)));
}
class TtTagebuchApp extends StatelessWidget {
const TtTagebuchApp({super.key});
class TtTagebuchApp extends StatefulWidget {
const TtTagebuchApp({super.key, required this.session});
final AppSession session;
@override
State<TtTagebuchApp> createState() => _TtTagebuchAppState();
}
class _TtTagebuchAppState extends State<TtTagebuchApp> {
bool _forgotPassword = false;
bool _resetSent = false;
@override
void initState() {
super.initState();
widget.session.restore();
}
void showForgotPassword() => setState(() => _forgotPassword = true);
void markResetSent() => setState(() => _resetSent = true);
void returnToLogin() => setState(() {
_forgotPassword = false;
_resetSent = false;
});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
return MaterialApp(
title: 'TT-Tagebuch',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
routerConfig: appRouter,
home: AnimatedBuilder(
animation: widget.session,
builder: (context, _) =>
_SessionPage(session: widget.session, state: this),
),
);
}
}
class _SessionPage extends StatelessWidget {
const _SessionPage({required this.session, required this.state});
final AppSession session;
final _TtTagebuchAppState state;
@override
Widget build(BuildContext context) {
if (session.status == SessionStatus.starting) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (session.status == SessionStatus.selectingClub) {
return ClubSelectionPage(
clubs: session.clubs
.map(
(club) => ClubViewModel(
id: '${club.id}',
name: club.name,
subtitle: 'Vereinszugang',
),
)
.toList(),
selectedClubId: session.activeClub == null
? null
: '${session.activeClub!.id}',
isLoading: session.busy,
errorMessage: session.errorMessage,
onSelected: (id) => session.chooseClub(int.parse(id)),
onContinue: () {
if (session.activeClub != null) {
session.selectClub(session.activeClub!.id);
}
},
onRetry: session.restore,
onLogout: session.logout,
);
}
if (session.status == SessionStatus.ready) {
return AppShell(onLogout: session.logout);
}
if (state._forgotPassword) {
return PasswordResetRequestPage(
isLoading: session.busy,
errorMessage: session.errorMessage,
isSuccess: state._resetSent,
onRequestReset: (email) async {
await session.requestPasswordReset(email);
state.markResetSent();
},
onBackToLogin: state.returnToLogin,
);
}
return LoginPage(
isLoading: session.busy,
errorMessage: session.errorMessage,
onSignIn: session.signIn,
onForgotPassword: state.showForgotPassword,
);
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -5,6 +5,10 @@
import FlutterMacOS
import Foundation
import flutter_secure_storage_darwin
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -97,6 +105,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dio:
dependency: "direct main"
description:
name: dio
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
url: "https://pub.dev"
source: hosted
version: "5.11.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
fake_async:
dependency: transitive
description:
@@ -105,6 +129,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -142,6 +182,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.2"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -176,6 +264,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "17.3.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http_multi_server:
dependency: transitive
description:
@@ -200,6 +296,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
@@ -288,6 +408,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
@@ -304,6 +432,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
@@ -320,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
@@ -328,6 +528,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.dev"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -525,6 +781,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
@@ -535,4 +807,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.0"
flutter: ">=3.44.0"

View File

@@ -33,6 +33,9 @@ dependencies:
flutter_riverpod: ^3.4.1
go_router: ^17.3.0
dio: ^5.11.0
flutter_secure_storage: ^10.3.1
shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:

View File

@@ -8,23 +8,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tt_desktop/features/auth/presentation/login_page.dart';
import 'package:tt_desktop/features/shell/presentation/app_shell.dart';
import 'package:tt_desktop/main.dart';
void main() {
testWidgets('login leads into the desktop workspace', (
testWidgets('login presents its native sign-in form', (
WidgetTester tester,
) async {
await tester.pumpWidget(const TtTagebuchApp());
await tester.pumpWidget(const MaterialApp(home: LoginPage()));
expect(find.text('Willkommen zurück'), findsOneWidget);
expect(find.text('Anmelden'), findsOneWidget);
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(find.text('Start'), findsWidgets);
expect(find.text('Dein Überblick für heute.'), findsOneWidget);
});
testWidgets('workspace switches to the club product', (

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -59,13 +59,13 @@ Bestehendes Node/Express-Backend
### Phase 2 Anmeldung, Sitzung und Vereinskontext
- [ ] Login gegen `POST /api/auth/login` implementieren, inklusive „angemeldet bleiben“.
- [ ] Token sicher speichern, beim Start wiederherstellen und Sitzung mit `GET /api/session/status` prüfen.
- [ ] Logout über `POST /api/auth/logout` implementieren und lokale Zugangsdaten zuverlässig löschen.
- [ ] Passwort-vergessen- und Passwort-zurücksetzen-Flows als native Formulare ergänzen.
- [ ] Vereinsliste über `GET /api/clubs` laden und aktiven Verein persistent auswählen.
- [ ] Berechtigungen über `GET /api/permissions/:clubId` laden und die Navigation rollenabhängig aufbauen.
- [ ] Unit- und Integrationstests für Login, Tokenablauf, 401 und Vereinswechsel schreiben.
- [x] Login gegen `POST /api/auth/login` implementieren, inklusive „angemeldet bleiben“.
- [x] Token sicher speichern, beim Start wiederherstellen und Sitzung mit `GET /api/session/status` prüfen.
- [x] Logout über `POST /api/auth/logout` implementieren und lokale Zugangsdaten zuverlässig löschen.
- [x] Passwort-vergessen- und Passwort-zurücksetzen-Flows als native Formulare ergänzen.
- [x] Vereinsliste über `GET /api/clubs` laden und aktiven Verein persistent auswählen.
- [x] Berechtigungen über `GET /api/permissions/:clubId` laden und die Navigation rollenabhängig aufbauen.
- [x] Unit- und Integrationstests für Login, Tokenablauf, 401 und Vereinswechsel schreiben.
### Phase 3 Desktop-MVP: persönliche Organisation