feat: implement authentication flow with login, password reset, and club selection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
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:
@@ -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
|
||||
|
||||
73
backend/desktop-app/lib/core/network/api_client.dart
Normal file
73
backend/desktop-app/lib/core/network/api_client.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/desktop-app/lib/core/network/api_exception.dart
Normal file
11
backend/desktop-app/lib/core/network/api_exception.dart
Normal 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;
|
||||
}
|
||||
126
backend/desktop-app/lib/core/session/app_session.dart
Normal file
126
backend/desktop-app/lib/core/session/app_session.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
28
backend/desktop-app/lib/data/auth/auth_repository.dart
Normal file
28
backend/desktop-app/lib/data/auth/auth_repository.dart
Normal 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},
|
||||
);
|
||||
}
|
||||
27
backend/desktop-app/lib/data/auth/token_storage.dart
Normal file
27
backend/desktop-app/lib/data/auth/token_storage.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
15
backend/desktop-app/lib/data/clubs/club_repository.dart
Normal file
15
backend/desktop-app/lib/data/clubs/club_repository.dart
Normal 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'));
|
||||
}
|
||||
11
backend/desktop-app/lib/domain/models/club.dart
Normal file
11
backend/desktop-app/lib/domain/models/club.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
14
backend/desktop-app/lib/domain/models/club_permissions.dart
Normal file
14
backend/desktop-app/lib/domain/models/club_permissions.dart
Normal 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 {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user