diff --git a/backend/desktop-app/analysis_options.yaml b/backend/desktop-app/analysis_options.yaml index 3c57fe88..82ac86c2 100644 --- a/backend/desktop-app/analysis_options.yaml +++ b/backend/desktop-app/analysis_options.yaml @@ -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 diff --git a/backend/desktop-app/lib/core/network/api_client.dart b/backend/desktop-app/lib/core/network/api_client.dart new file mode 100644 index 00000000..f5d50850 --- /dev/null +++ b/backend/desktop-app/lib/core/network/api_client.dart @@ -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> getObject(String path) async { + try { + final response = await _dio.get(path); + return Map.from(response.data as Map); + } on DioException catch (error) { + throw _toApiException(error); + } + } + + Future> getList(String path) async { + try { + final response = await _dio.get(path); + return List.from(response.data as List); + } on DioException catch (error) { + throw _toApiException(error); + } + } + + Future> postObject(String path, {Object? data}) async { + try { + final response = await _dio.post(path, data: data); + return Map.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); + } +} diff --git a/backend/desktop-app/lib/core/network/api_exception.dart b/backend/desktop-app/lib/core/network/api_exception.dart new file mode 100644 index 00000000..b8482fc4 --- /dev/null +++ b/backend/desktop-app/lib/core/network/api_exception.dart @@ -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; +} diff --git a/backend/desktop-app/lib/core/session/app_session.dart b/backend/desktop-app/lib/core/session/app_session.dart new file mode 100644 index 00000000..d48812d7 --- /dev/null +++ b/backend/desktop-app/lib/core/session/app_session.dart @@ -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 clubs = const []; + Club? activeClub; + ClubPermissions? permissions; + + Future 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 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 requestPasswordReset(String email) => + _auth.requestPasswordReset(email); + Future resetPassword(String token, String password) => + _auth.resetPassword(token, password); + + Future _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 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 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 _run(Future 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(); + } +} diff --git a/backend/desktop-app/lib/data/auth/auth_repository.dart b/backend/desktop-app/lib/data/auth/auth_repository.dart new file mode 100644 index 00000000..68a38c0d --- /dev/null +++ b/backend/desktop-app/lib/data/auth/auth_repository.dart @@ -0,0 +1,28 @@ +import '../../core/network/api_client.dart'; + +class AuthRepository { + const AuthRepository(this._api); + final ApiClient _api; + + Future 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 isSessionValid() async => + (await _api.getObject('/session/status'))['valid'] == true; + Future logout() async => _api.postObject('/auth/logout'); + Future requestPasswordReset(String email) => + _api.postObject('/auth/forgot-password', data: {'email': email}); + Future resetPassword(String token, String password) => _api.postObject( + '/auth/reset-password', + data: {'token': token, 'password': password}, + ); +} diff --git a/backend/desktop-app/lib/data/auth/token_storage.dart b/backend/desktop-app/lib/data/auth/token_storage.dart new file mode 100644 index 00000000..f8f64a09 --- /dev/null +++ b/backend/desktop-app/lib/data/auth/token_storage.dart @@ -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 readToken() async => + _sessionToken ?? _storage.read(key: _tokenKey); + + Future 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 clear() async { + _sessionToken = null; + await _storage.delete(key: _tokenKey); + } +} diff --git a/backend/desktop-app/lib/data/clubs/club_repository.dart b/backend/desktop-app/lib/data/clubs/club_repository.dart new file mode 100644 index 00000000..dffcaf60 --- /dev/null +++ b/backend/desktop-app/lib/data/clubs/club_repository.dart @@ -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() async => (await _api.getList('/clubs')) + .map((item) => Club.fromJson(Map.from(item as Map))) + .toList(); + + Future permissions(int clubId) async => + ClubPermissions.fromJson(await _api.getObject('/permissions/$clubId')); +} diff --git a/backend/desktop-app/lib/domain/models/club.dart b/backend/desktop-app/lib/domain/models/club.dart new file mode 100644 index 00000000..d04fa7a1 --- /dev/null +++ b/backend/desktop-app/lib/domain/models/club.dart @@ -0,0 +1,11 @@ +class Club { + const Club({required this.id, required this.name}); + + final int id; + final String name; + + factory Club.fromJson(Map json) => Club( + id: (json['id'] as num).toInt(), + name: json['name'] as String? ?? 'Unbenannter Verein', + ); +} diff --git a/backend/desktop-app/lib/domain/models/club_permissions.dart b/backend/desktop-app/lib/domain/models/club_permissions.dart new file mode 100644 index 00000000..b25e9f88 --- /dev/null +++ b/backend/desktop-app/lib/domain/models/club_permissions.dart @@ -0,0 +1,14 @@ +class ClubPermissions { + const ClubPermissions({required this.role, required this.permissions}); + + final String? role; + final Map permissions; + + factory ClubPermissions.fromJson(Map json) => + ClubPermissions( + role: json['role'] as String?, + permissions: Map.from( + json['permissions'] as Map? ?? const {}, + ), + ); +} diff --git a/backend/desktop-app/lib/features/auth/presentation/login_page.dart b/backend/desktop-app/lib/features/auth/presentation/login_page.dart index 6f8ff7ba..ae55546d 100644 --- a/backend/desktop-app/lib/features/auth/presentation/login_page.dart +++ b/backend/desktop-app/lib/features/auth/presentation/login_page.dart @@ -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 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 createState() => _LoginPageState(); } class _LoginPageState extends State { + final _formKey = GlobalKey(); + 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 _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 { 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 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)), + ), + ], + ), + ), + ); + } +} diff --git a/backend/desktop-app/lib/features/auth/presentation/password_reset_pages.dart b/backend/desktop-app/lib/features/auth/presentation/password_reset_pages.dart new file mode 100644 index 00000000..b1cd3fb1 --- /dev/null +++ b/backend/desktop-app/lib/features/auth/presentation/password_reset_pages.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/product_mark.dart'; + +typedef ResetRequestCallback = Future Function(String email); +typedef ResetPasswordCallback = Future 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 createState() => + _PasswordResetRequestPageState(); +} + +class _PasswordResetRequestPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _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 createState() => _PasswordResetPageState(); +} + +class _PasswordResetPageState extends State { + final _formKey = GlobalKey(); + final _passwordController = TextEditingController(); + final _confirmationController = TextEditingController(); + + @override + void dispose() { + _passwordController.dispose(); + _confirmationController.dispose(); + super.dispose(); + } + + Future _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), + ), + ); +} diff --git a/backend/desktop-app/lib/features/clubs/presentation/club_selection_page.dart b/backend/desktop-app/lib/features/clubs/presentation/club_selection_page.dart new file mode 100644 index 00000000..151919c4 --- /dev/null +++ b/backend/desktop-app/lib/features/clubs/presentation/club_selection_page.dart @@ -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 clubs; + final String? selectedClubId; + final ValueChanged 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 clubs; + final String? selectedClubId; + final ValueChanged 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'), + ), + ], + ), + ); +} diff --git a/backend/desktop-app/lib/features/shell/presentation/app_shell.dart b/backend/desktop-app/lib/features/shell/presentation/app_shell.dart index 49684f8f..f3785e9f 100644 --- a/backend/desktop-app/lib/features/shell/presentation/app_shell.dart +++ b/backend/desktop-app/lib/features/shell/presentation/app_shell.dart @@ -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 createState() => _AppShellState(); @@ -46,6 +47,7 @@ class _AppShellState extends State { 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 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), diff --git a/backend/desktop-app/lib/main.dart b/backend/desktop-app/lib/main.dart index 3de4165d..a15206cf 100644 --- a/backend/desktop-app/lib/main.dart +++ b/backend/desktop-app/lib/main.dart @@ -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 createState() => _TtTagebuchAppState(); +} + +class _TtTagebuchAppState extends State { + 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, ); } } diff --git a/backend/desktop-app/linux/flutter/generated_plugin_registrant.cc b/backend/desktop-app/linux/flutter/generated_plugin_registrant.cc index e71a16d2..d0e7f797 100644 --- a/backend/desktop-app/linux/flutter/generated_plugin_registrant.cc +++ b/backend/desktop-app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/backend/desktop-app/linux/flutter/generated_plugins.cmake b/backend/desktop-app/linux/flutter/generated_plugins.cmake index 2e1de87a..ce58916f 100644 --- a/backend/desktop-app/linux/flutter/generated_plugins.cmake +++ b/backend/desktop-app/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/backend/desktop-app/macos/Flutter/GeneratedPluginRegistrant.swift b/backend/desktop-app/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a..ee719992 100644 --- a/backend/desktop-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/backend/desktop-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/backend/desktop-app/pubspec.lock b/backend/desktop-app/pubspec.lock index d686e1d0..6f81bed5 100644 --- a/backend/desktop-app/pubspec.lock +++ b/backend/desktop-app/pubspec.lock @@ -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" diff --git a/backend/desktop-app/pubspec.yaml b/backend/desktop-app/pubspec.yaml index ab152c70..7a638e79 100644 --- a/backend/desktop-app/pubspec.yaml +++ b/backend/desktop-app/pubspec.yaml @@ -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: diff --git a/backend/desktop-app/test/widget_test.dart b/backend/desktop-app/test/widget_test.dart index e5250689..637bd415 100644 --- a/backend/desktop-app/test/widget_test.dart +++ b/backend/desktop-app/test/widget_test.dart @@ -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', ( diff --git a/backend/desktop-app/windows/flutter/generated_plugin_registrant.cc b/backend/desktop-app/windows/flutter/generated_plugin_registrant.cc index 8b6d4680..0c507538 100644 --- a/backend/desktop-app/windows/flutter/generated_plugin_registrant.cc +++ b/backend/desktop-app/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/backend/desktop-app/windows/flutter/generated_plugins.cmake b/backend/desktop-app/windows/flutter/generated_plugins.cmake index b93c4c30..d0b33f8d 100644 --- a/backend/desktop-app/windows/flutter/generated_plugins.cmake +++ b/backend/desktop-app/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/docs/FLUTTER_DESKTOP_APP_PLAN.md b/docs/FLUTTER_DESKTOP_APP_PLAN.md index 48a36846..e9c3962e 100644 --- a/docs/FLUTTER_DESKTOP_APP_PLAN.md +++ b/docs/FLUTTER_DESKTOP_APP_PLAN.md @@ -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