- android app
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 55s

- code import fix
This commit is contained in:
Torsten Schulz (local)
2026-08-10 15:15:11 +02:00
parent 616bade9a5
commit 5f2de5e671
93 changed files with 693 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
/// Immutable build-time configuration for a TT-Tagebuch app installation.
class AppEnvironment {
const AppEnvironment._({required this.name, required this.apiBaseUrl});
factory AppEnvironment.fromBuildConfiguration() {
const configuredEnvironment = String.fromEnvironment(
'APP_ENV',
defaultValue: 'development',
);
const configuredApiBaseUrl = String.fromEnvironment('API_BASE_URL');
return AppEnvironment._(
name: configuredEnvironment,
apiBaseUrl: configuredApiBaseUrl.isEmpty
? _defaultApiBaseUrl(configuredEnvironment)
: Uri.parse(configuredApiBaseUrl),
);
}
final String name;
final Uri apiBaseUrl;
static Uri _defaultApiBaseUrl(String environment) {
return switch (environment) {
'production' => Uri.parse('https://tt-tagebuch.de/api'),
'staging' => Uri.parse('https://staging.tt-tagebuch.de/api'),
_ => Uri.parse('http://localhost:3005/api'),
};
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/foundation.dart';
/// Minimal logging seam. Feature code must never log credentials or API data.
class AppLogger {
const AppLogger();
void info(String message) {
if (kDebugMode) {
debugPrint('[tt-desktop] $message');
}
}
}

View File

@@ -0,0 +1,14 @@
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/login_page.dart';
import '../../features/shell/presentation/app_shell.dart';
/// Route boundary between public entry points and the future authenticated app.
/// Phase 2 adds the session-based redirect to this router.
final appRouter = GoRouter(
initialLocation: '/login',
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(path: '/app', builder: (context, state) => const AppShell()),
],
);

View File

@@ -0,0 +1,112 @@
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;
/// Reads either object or list responses. Use this for endpoints whose
/// response shape is intentionally determined by the server.
Future<Object?> getData(String path) async {
try {
return await _dio.get<dynamic>(path).then((response) => response.data);
} on DioException catch (error) {
throw _toApiException(error);
}
}
Future<Map<String, dynamic>> getObject(String path) async {
try {
return Map<String, dynamic>.from((await getData(path)) as Map);
} on TypeError {
throw const ApiException(
'Der Server hat ein unerwartetes Datenformat geliefert.',
);
}
}
Future<List<dynamic>> getList(String path) async {
try {
return List<dynamic>.from((await getData(path)) as List);
} on TypeError {
throw const ApiException(
'Der Server hat ein unerwartetes Datenformat geliefert.',
);
}
}
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);
}
}
/// Sends a mutation request where the server response is not part of the UI.
Future<void> post(String path, {Object? data}) async {
try {
await _dio.post<dynamic>(path, data: data);
} on DioException catch (error) {
throw _toApiException(error);
}
}
/// Sends a JSON PUT request to one of the existing write APIs.
Future<Object?> putData(String path, {Object? data}) async {
try {
return (await _dio.put<dynamic>(path, data: data)).data;
} on DioException catch (error) {
throw _toApiException(error);
}
}
/// Sends a JSON PATCH request to one of the existing write APIs.
Future<Object?> patchData(String path, {Object? data}) async {
try {
return (await _dio.patch<dynamic>(path, data: data)).data;
} on DioException catch (error) {
throw _toApiException(error);
}
}
ApiException _toApiException(DioException error) {
final data = error.response?.data;
final message = data is Map
? (data['message'] ??
data['error'] ??
'Die Anfrage konnte nicht verarbeitet werden.')
.toString()
: error.type == DioExceptionType.connectionTimeout
? 'Die Verbindung dauert zu lange.'
: 'Der Server ist derzeit nicht erreichbar.';
return ApiException(message, statusCode: error.response?.statusCode);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
abstract final class AppColors {
static const green = Color(0xFF17614F);
static const greenDark = Color(0xFF10493B);
static const ink = Color(0xFF17211E);
static const canvas = Color(0xFFF7F8F5);
static const surface = Color(0xFFFFFFFF);
static const muted = Color(0xFF6C7670);
static const line = Color(0xFFDDE2DC);
static const coral = Color(0xFFD66D5B);
}
abstract final class AppTheme {
static final light = ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: AppColors.canvas,
colorScheme: const ColorScheme.light(
primary: AppColors.green,
onPrimary: Colors.white,
surface: AppColors.surface,
onSurface: AppColors.ink,
outline: AppColors.line,
error: AppColors.coral,
),
textTheme: const TextTheme(
displaySmall: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: AppColors.ink,
),
headlineSmall: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: AppColors.ink,
),
titleLarge: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.ink,
),
titleMedium: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink,
),
bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: AppColors.ink),
bodyMedium: TextStyle(fontSize: 14, height: 1.4, color: AppColors.ink),
bodySmall: TextStyle(fontSize: 12, height: 1.35, color: AppColors.muted),
labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.green, width: 2),
),
labelStyle: const TextStyle(color: AppColors.muted),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: AppColors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
),
),
dividerColor: AppColors.line,
);
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class AppLoadingView extends StatelessWidget {
const AppLoadingView({super.key, this.label = 'Daten werden geladen …'});
final String label;
@override
Widget build(BuildContext context) {
return Center(
child: Semantics(label: label, child: const CircularProgressIndicator()),
);
}
}
class AppErrorView extends StatelessWidget {
const AppErrorView({super.key, required this.message, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 36),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class ProductMark extends StatelessWidget {
const ProductMark({super.key, this.light = false, this.size = 42});
final bool light;
final double size;
@override
Widget build(BuildContext context) {
final color = light ? Colors.white : AppColors.green;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
Icon(Icons.sports_tennis_rounded, color: color, size: size * .78),
Positioned(
right: size * .05,
top: size * .12,
child: Icon(Icons.circle, color: color, size: size * .18),
),
],
),
);
}
}

View File

@@ -0,0 +1,4 @@
# Data
API-Clients, DTOs, lokale Speicheradapter und Repository-Implementierungen
liegen hier. Diese Schicht enthält keine Widgets.

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import '../../core/network/api_client.dart';
/// Read-only Phase-3 access to the existing club APIs.
class ClubOverviewRepository {
const ClubOverviewRepository(this._api);
final ApiClient _api;
Future<Object?> dashboard(int clubId) =>
_api.getData('/club-dashboard/$clubId');
Future<Object?> memberDashboard(int clubId) =>
_api.getData('/clubmembers/dashboard/$clubId');
Future<Object?> calendarEvents(int clubId) =>
_api.getData('/calendar-events/$clubId');
Future<Object?> schedule(int clubId) =>
_api.getData('/matches/leagues/current/$clubId');
Future<Object?> members(int clubId) =>
_api.getData('/clubmembers/get/$clubId/false');
Future<Object?> training(int clubId) => _api.getData('/diary/$clubId');
}

View File

@@ -0,0 +1,35 @@
import '../../core/network/api_client.dart';
/// Thin adapter for the established write APIs used by the desktop MVP.
class ClubWorkflowsRepository {
const ClubWorkflowsRepository(this._api);
final ApiClient _api;
Future<Object?> members(int clubId) =>
_api.getData('/clubmembers/get/$clubId/false');
Future<Object?> training(int clubId) => _api.getData('/diary/$clubId');
Future<Object?> teams(int clubId) => _api.getData('/club-teams/club/$clubId');
Future<Object?> tournaments(int clubId) => _api.getData('/tournament/$clubId');
Future<Object?> orders(int clubId, int memberId) =>
_api.getData('/member-orders/$clubId/$memberId');
Future<Object?> createTraining(int clubId, Map<String, dynamic> body) =>
_api.postObject('/diary/$clubId', data: body);
Future<Object?> saveMember(int clubId, Map<String, dynamic> body) =>
_api.postObject('/clubmembers/set/$clubId', data: body);
Future<Object?> createOrder(
int clubId,
int memberId,
Map<String, dynamic> body,
) => _api.postObject('/member-orders/$clubId/$memberId', data: body);
Future<Object?> updateOrder(
int clubId,
int memberId,
int orderId,
Map<String, dynamic> body,
) => _api.patchData('/member-orders/$clubId/$memberId/$orderId', data: body);
Future<Object?> createTeam(int clubId, Map<String, dynamic> body) =>
_api.postObject('/club-teams/club/$clubId', data: body);
Future<Object?> createTournament(Map<String, dynamic> body) =>
_api.postObject('/tournament', data: body);
}

View File

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

View File

@@ -0,0 +1,31 @@
import '../../core/network/api_client.dart';
/// Narrow client for the existing diary and participant endpoints.
class TrainingRepository {
const TrainingRepository(this._api);
final ApiClient _api;
Future<List<dynamic>> diaryDates(int clubId) =>
_api.getList('/diary/$clubId');
Future<List<dynamic>> members(int clubId) =>
_api.getList('/clubmembers/get/$clubId/false');
Future<List<dynamic>> participants(int diaryDateId) =>
_api.getList('/participants/$diaryDateId');
Future<void> addAttendance({
required int diaryDateId,
required int memberId,
}) => _api.post(
'/participants/add',
data: {'diaryDateId': diaryDateId, 'memberId': memberId},
);
Future<void> removeAttendance({
required int diaryDateId,
required int memberId,
}) => _api.post(
'/participants/remove',
data: {'diaryDateId': diaryDateId, 'memberId': memberId},
);
}

View File

@@ -0,0 +1,4 @@
# Domain
Fachmodelle, Repository-Abstraktionen und Use-Cases liegen hier. Sie bleiben
von Flutter-Widgets und konkreten HTTP-Implementierungen unabhängig.

View File

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

View File

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

View File

@@ -0,0 +1,327 @@
import 'package:flutter/material.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,
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;
@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
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final spacious = constraints.maxWidth >= 840;
return Row(
children: [
if (spacious) const _WelcomePanel(),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: SizedBox(
width: 420,
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,
),
),
),
),
),
),
],
);
},
),
);
}
}
class _WelcomePanel extends StatelessWidget {
const _WelcomePanel();
@override
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 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,
),
),
],
),
),
);
}
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,
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 VoidCallback? onForgotPassword;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProductMark(size: 38),
const SizedBox(height: 36),
Text('Willkommen zurück', style: text.displaySmall),
const SizedBox(height: 8),
Text(
'Melde dich mit deinem Vereinszugang an.',
style: text.bodyLarge?.copyWith(color: AppColors.muted),
),
if (errorMessage != null) ...[
const SizedBox(height: 24),
_MessagePanel(message: errorMessage!, isError: true),
],
const SizedBox(height: 28),
TextFormField(
controller: emailController,
enabled: !isLoading,
keyboardType: TextInputType.emailAddress,
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),
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: obscurePassword
? 'Passwort anzeigen'
: 'Passwort verbergen',
onPressed: isLoading ? null : onTogglePassword,
icon: Icon(
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) =>
(value?.isEmpty ?? true) ? 'Bitte gib dein Passwort ein.' : null,
),
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: 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),
const Divider(),
const SizedBox(height: 18),
Text(
'Dein Zugang wird von deinem Verein verwaltet.',
style: text.bodySmall,
),
],
);
}
}
class _MessagePanel extends StatelessWidget {
const _MessagePanel({required this.message, required this.isError});
final String message;
final bool isError;
@override
Widget build(BuildContext context) {
final color = isError ? AppColors.coral : AppColors.green;
return Semantics(
liveRegion: true,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: color.withValues(alpha: .09),
border: Border(left: BorderSide(color: color, width: 3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isError
? Icons.info_outline_rounded
: Icons.check_circle_outline_rounded,
color: color,
),
const SizedBox(width: 10),
Expanded(
child: Text(message, style: TextStyle(color: AppColors.ink)),
),
],
),
),
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/async_content.dart';
import '../../../data/club_overview/club_overview_repository.dart';
enum ClubOverviewKind {
dashboard,
memberDashboard,
calendar,
schedule,
members,
training,
}
/// A compact, read-only desktop view for the club data exposed by Phase 3.
class ClubOverviewPage extends StatefulWidget {
const ClubOverviewPage({
super.key,
required this.kind,
required this.clubId,
required this.title,
required this.description,
required this.icon,
required this.api,
});
final ClubOverviewKind kind;
final int clubId;
final String title;
final String description;
final IconData icon;
final ApiClient api;
@override
State<ClubOverviewPage> createState() => _ClubOverviewPageState();
}
class _ClubOverviewPageState extends State<ClubOverviewPage> {
late Future<Object?> _request;
@override
void initState() {
super.initState();
_request = _load();
}
@override
void didUpdateWidget(covariant ClubOverviewPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.kind != widget.kind || oldWidget.clubId != widget.clubId) {
_request = _load();
}
}
Future<Object?> _load() {
final repository = ClubOverviewRepository(widget.api);
return switch (widget.kind) {
ClubOverviewKind.dashboard => repository.dashboard(widget.clubId),
ClubOverviewKind.memberDashboard => repository.memberDashboard(
widget.clubId,
),
ClubOverviewKind.calendar => repository.calendarEvents(widget.clubId),
ClubOverviewKind.schedule => repository.schedule(widget.clubId),
ClubOverviewKind.members => repository.members(widget.clubId),
ClubOverviewKind.training => repository.training(widget.clubId),
};
}
void _retry() => setState(() => _request = _load());
@override
Widget build(BuildContext context) => FutureBuilder<Object?>(
future: _request,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const AppLoadingView();
}
if (snapshot.hasError) {
return AppErrorView(
message: snapshot.error.toString(),
onRetry: _retry,
);
}
final records = _records(snapshot.data);
if (records.isEmpty) return _EmptyOverview(icon: widget.icon);
return _OverviewList(records: records, icon: widget.icon);
},
);
}
List<Map<String, dynamic>> _records(Object? data) {
if (data is List) {
return data
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
if (data is Map) {
final value = Map<String, dynamic>.from(data);
for (final key in const [
'items',
'members',
'events',
'dates',
'leagues',
'sections',
]) {
if (value[key] is List) return _records(value[key]);
}
return [value];
}
return const [];
}
class _OverviewList extends StatelessWidget {
const _OverviewList({required this.records, required this.icon});
final List<Map<String, dynamic>> records;
final IconData icon;
@override
Widget build(BuildContext context) => Column(
children: records.expand((record) {
final title =
_first(record, const [
'title',
'name',
'label',
'fullName',
'subject',
]) ??
'Eintrag';
final subtitle = _summary(record, title);
return [
ListTile(
leading: Icon(icon, color: AppColors.green),
title: Text(title),
subtitle: subtitle == null
? null
: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
),
const Divider(height: 1),
];
}).toList(),
);
}
class _EmptyOverview extends StatelessWidget {
const _EmptyOverview({required this.icon});
final IconData icon;
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 34, color: AppColors.green),
const SizedBox(height: 12),
const Text('Keine Einträge vorhanden.'),
],
),
);
}
String? _first(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString();
}
}
final firstName = data['firstName']?.toString();
final lastName = data['lastName']?.toString();
if (firstName != null || lastName != null) {
return [firstName, lastName].whereType<String>().join(' ');
}
return null;
}
String? _summary(Map<String, dynamic> data, String title) {
final values = <String>[];
for (final key in const [
'date',
'startDate',
'startTime',
'email',
'status',
'meta',
'value',
]) {
final value = data[key];
if (value != null && value.toString() != title) {
values.add(value.toString());
}
}
return values.isEmpty ? null : values.join(' · ');
}

View File

@@ -0,0 +1,674 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/product_mark.dart';
import '../../overview/presentation/club_overview_page.dart';
import '../../training/presentation/training_workbench_page.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key, this.onLogout, this.api, this.clubId});
final VoidCallback? onLogout;
final ApiClient? api;
final int? clubId;
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
Product _product = Product.trainer;
int _selectedIndex = 0;
ProductDefinition get _definition => ProductDefinition.forProduct(_product);
void _changeProduct(Product product) {
setState(() {
_product = product;
_selectedIndex = 0;
});
}
@override
Widget build(BuildContext context) {
final definition = _definition;
final page = definition.pages[_selectedIndex];
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 920;
return Row(
children: [
_NavigationRail(
definition: definition,
selectedIndex: _selectedIndex,
compact: compact,
onSelected: (value) => setState(() => _selectedIndex = value),
),
Expanded(
child: Column(
children: [
_TopBar(
definition: definition,
compact: compact,
onProductSelected: _changeProduct,
onLogout: widget.onLogout,
),
Expanded(
child: _Content(
page: page,
product: definition,
api: widget.api,
clubId: widget.clubId,
),
),
],
),
),
],
);
},
),
);
}
}
enum Product { trainer, club, player }
class ProductDefinition {
const ProductDefinition({
required this.product,
required this.name,
required this.contextLabel,
required this.contextName,
required this.contextIcon,
required this.pages,
required this.settingsLabel,
});
final Product product;
final String name;
final String contextLabel;
final String contextName;
final IconData contextIcon;
final List<PageDefinition> pages;
final String settingsLabel;
static ProductDefinition forProduct(Product product) => switch (product) {
Product.trainer => const ProductDefinition(
product: Product.trainer,
name: 'Trainings-Tagebuch',
contextLabel: 'Trainerbereich',
contextName: 'TTC Musterstadt',
contextIcon: Icons.sports_tennis_outlined,
settingsLabel: 'Einstellungen',
pages: [
PageDefinition(
'Start',
'Dein Überblick für heute.',
Icons.home_outlined,
'Hier entsteht dein persönlicher Trainingsüberblick.',
),
PageDefinition(
'Training',
'Einheiten planen und Teilnahme begleiten.',
Icons.sports_tennis_outlined,
'Noch keine Trainingseinheiten zum Anzeigen.',
),
PageDefinition(
'Mitglieder',
'Spielerinnen und Spieler im Blick.',
Icons.groups_outlined,
'Noch keine Mitglieder zum Anzeigen.',
),
PageDefinition(
'Kalender',
'Termine für Training und Verein.',
Icons.calendar_month_outlined,
'Noch keine Termine zum Anzeigen.',
),
PageDefinition(
'Spielplan',
'Begegnungen, Ergebnisse und Tabellen.',
Icons.sports_score_outlined,
'Noch keine Spiele zum Anzeigen.',
),
PageDefinition(
'Turniere & Statistik',
'Leistung und Wettkämpfe nachvollziehen.',
Icons.query_stats_outlined,
'Noch keine Turnier- oder Statistikdaten zum Anzeigen.',
),
],
),
Product.club => const ProductDefinition(
product: Product.club,
name: 'TT Verein',
contextLabel: 'Vereinsverwaltung',
contextName: 'TTC Musterstadt',
contextIcon: Icons.location_city_outlined,
settingsLabel: 'Verein einstellen',
pages: [
PageDefinition(
'Start',
'Alles Wichtige aus deinem Verein.',
Icons.home_outlined,
'Hier entsteht dein Vereinsüberblick.',
),
PageDefinition(
'Mitglieder',
'Menschen, Rollen und Mitgliedschaften.',
Icons.groups_outlined,
'Noch keine Mitglieder zum Anzeigen.',
),
PageDefinition(
'Kalender & Termine',
'Veranstaltungen und Vereinsalltag koordinieren.',
Icons.calendar_month_outlined,
'Noch keine Termine zum Anzeigen.',
),
PageDefinition(
'Kommunikation',
'Neuigkeiten und Nachrichten im Verein.',
Icons.forum_outlined,
'Noch keine Nachrichten zum Anzeigen.',
),
PageDefinition(
'Aufgaben',
'Was im Verein als Nächstes ansteht.',
Icons.task_alt_outlined,
'Noch keine Aufgaben zum Anzeigen.',
),
PageDefinition(
'Finanzen',
'Beiträge und Vereinsfinanzen im Überblick.',
Icons.account_balance_wallet_outlined,
'Noch keine Finanzdaten zum Anzeigen.',
),
],
),
Product.player => const ProductDefinition(
product: Product.player,
name: 'Mein TT',
contextLabel: 'Mein Tischtennis',
contextName: 'Torsten Muster',
contextIcon: Icons.person_outline_rounded,
settingsLabel: 'Persönliche Einstellungen',
pages: [
PageDefinition(
'Start',
'Dein persönlicher Tischtennis-Alltag.',
Icons.home_outlined,
'Hier entsteht dein persönlicher Überblick.',
),
PageDefinition(
'Kalender',
'Deine Trainings, Spiele und Termine.',
Icons.calendar_month_outlined,
'Noch keine Termine zum Anzeigen.',
),
PageDefinition(
'Verknüpfte Konten',
'Deine verbundenen TT-Zugänge.',
Icons.link_outlined,
'Noch keine Konten zum Anzeigen.',
),
PageDefinition(
'Bestellungen',
'Deine Bestellungen und Anfragen.',
Icons.receipt_long_outlined,
'Noch keine Bestellungen zum Anzeigen.',
),
PageDefinition(
'Persönliche Einstellungen',
'Dein Profil und deine Präferenzen.',
Icons.settings_outlined,
'Noch keine Einstellungen zum Anzeigen.',
),
],
),
};
}
class PageDefinition {
const PageDefinition(
this.title,
this.description,
this.icon,
this.emptyLabel,
);
final String title;
final String description;
final IconData icon;
final String emptyLabel;
}
class _NavigationRail extends StatelessWidget {
const _NavigationRail({
required this.definition,
required this.selectedIndex,
required this.compact,
required this.onSelected,
});
final ProductDefinition definition;
final int selectedIndex;
final bool compact;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) => Container(
width: compact ? 76 : 242,
color: AppColors.greenDark,
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(compact ? 16 : 24, 25, 16, 21),
child: Row(
children: [
const ProductMark(light: true, size: 33),
if (!compact) const SizedBox(width: 10),
if (!compact)
Expanded(
child: Text(
definition.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
),
],
),
),
const Divider(height: 1, color: Color(0x337FFFFF)),
const SizedBox(height: 12),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: definition.pages.length,
itemBuilder: (context, index) => _RailItem(
item: definition.pages[index],
selected: selectedIndex == index,
compact: compact,
onTap: () => onSelected(index),
),
),
),
const Divider(height: 1, color: Color(0x337FFFFF)),
_RailItem(
item: PageDefinition(
definition.settingsLabel,
'',
Icons.settings_outlined,
'',
),
compact: compact,
onTap: () {},
),
const SizedBox(height: 16),
],
),
);
}
class _RailItem extends StatelessWidget {
const _RailItem({
required this.item,
required this.compact,
required this.onTap,
this.selected = false,
});
final PageDefinition item;
final bool compact;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final foreground = selected ? Colors.white : const Color(0xD9FFFFFF);
return Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 10 : 12, vertical: 2),
child: Material(
color: selected ? const Color(0x2AFFFFFF) : Colors.transparent,
borderRadius: BorderRadius.circular(7),
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: onTap,
child: Tooltip(
message: compact ? item.title : '',
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 0 : 12,
vertical: 12,
),
child: Row(
mainAxisAlignment: compact
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
Icon(item.icon, size: 21, color: foreground),
if (!compact) const SizedBox(width: 13),
if (!compact)
Expanded(
child: Text(
item.title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
fontSize: 14,
),
),
),
],
),
),
),
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({
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(
height: 74,
padding: EdgeInsets.symmetric(horizontal: compact ? 20 : 32),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(bottom: BorderSide(color: AppColors.line)),
),
child: Row(
children: [
_ProductSwitcher(
definition: definition,
compact: compact,
onSelected: onProductSelected,
),
const Spacer(),
IconButton(
onPressed: () {},
tooltip: 'Benachrichtigungen',
icon: const Badge(
smallSize: 8,
child: Icon(Icons.notifications_none_rounded),
),
),
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),
child: Text(
'TM',
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
class _ProductSwitcher extends StatelessWidget {
const _ProductSwitcher({
required this.definition,
required this.compact,
required this.onSelected,
});
final ProductDefinition definition;
final bool compact;
final ValueChanged<Product> onSelected;
@override
Widget build(BuildContext context) => PopupMenuButton<Product>(
tooltip: 'Produkt wechseln',
onSelected: onSelected,
itemBuilder: (context) => Product.values.map((product) {
final item = ProductDefinition.forProduct(product);
return PopupMenuItem(
value: product,
child: _ProductMenuItem(
definition: item,
active: product == definition.product,
),
);
}).toList(),
child: Semantics(
button: true,
label: 'Produktkontext: ${definition.name}. Produkt wechseln',
child: Container(
constraints: const BoxConstraints(maxWidth: 340),
padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(definition.contextIcon, size: 20, color: AppColors.green),
if (!compact) const SizedBox(width: 9),
if (!compact)
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Produkt',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
definition.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SizedBox(width: 5),
const Icon(
Icons.unfold_more_rounded,
size: 19,
color: AppColors.muted,
),
],
),
),
),
);
}
class _ProductMenuItem extends StatelessWidget {
const _ProductMenuItem({required this.definition, required this.active});
final ProductDefinition definition;
final bool active;
@override
Widget build(BuildContext context) => SizedBox(
width: 270,
child: Row(
children: [
Icon(
definition.contextIcon,
color: active ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
definition.name,
style: TextStyle(
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
definition.contextLabel,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (active)
const Icon(Icons.check_rounded, size: 19, color: AppColors.green),
],
),
);
}
class _Content extends StatelessWidget {
const _Content({
required this.page,
required this.product,
this.api,
this.clubId,
});
final PageDefinition page;
final ProductDefinition product;
final ApiClient? api;
final int? clubId;
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(40, 38, 40, 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.green,
fontWeight: FontWeight.w700,
letterSpacing: .4,
),
),
const SizedBox(height: 6),
Text(page.title, style: Theme.of(context).textTheme.displaySmall),
const SizedBox(height: 8),
Text(
page.description,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 42),
_pageBody(context),
],
),
),
);
Widget _pageBody(BuildContext context) {
if (page.title == 'Training' &&
product.product == Product.trainer &&
api != null &&
clubId != null) {
return TrainingWorkbenchPage(
key: ValueKey('training-workbench-$clubId'),
clubId: clubId!,
api: api!,
);
}
final kind = _overviewKind();
if (kind != null && api != null && clubId != null) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 300),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: ClubOverviewPage(
key: ValueKey('${kind.name}-$clubId'),
kind: kind,
clubId: clubId!,
title: page.title,
description: page.description,
icon: page.icon,
api: api!,
),
);
}
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 300),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(page.icon, color: AppColors.green, size: 28),
const SizedBox(height: 18),
Text(
'Bereit, wenn du es bist.',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 7),
Text(page.emptyLabel, textAlign: TextAlign.center),
],
),
),
);
}
ClubOverviewKind? _overviewKind() {
if (page.title == 'Start') {
return product.product == Product.player
? ClubOverviewKind.memberDashboard
: ClubOverviewKind.dashboard;
}
return switch (page.title) {
'Training' => ClubOverviewKind.training,
'Mitglieder' => ClubOverviewKind.members,
'Kalender' || 'Kalender & Termine' => ClubOverviewKind.calendar,
'Spielplan' => ClubOverviewKind.schedule,
_ => null,
};
}
}

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/async_content.dart';
import '../../../data/training/training_repository.dart';
/// Native workbench for reviewing a diary day and maintaining attendance.
class TrainingWorkbenchPage extends StatefulWidget {
const TrainingWorkbenchPage({
super.key,
required this.clubId,
required this.api,
});
final int clubId;
final ApiClient api;
@override
State<TrainingWorkbenchPage> createState() => _TrainingWorkbenchPageState();
}
class _TrainingWorkbenchPageState extends State<TrainingWorkbenchPage> {
late final TrainingRepository _repository;
late Future<_InitialTrainingData> _initialRequest;
Future<List<dynamic>>? _participantsRequest;
int? _selectedDateId;
int? _pendingMemberId;
@override
void initState() {
super.initState();
_repository = TrainingRepository(widget.api);
_initialRequest = _loadInitial();
}
Future<_InitialTrainingData> _loadInitial() async {
final values = await Future.wait([
_repository.diaryDates(widget.clubId),
_repository.members(widget.clubId),
]);
return _InitialTrainingData(
dates: _maps(values[0]),
members: _maps(values[1]),
);
}
void _selectDate(int dateId) {
setState(() {
_selectedDateId = dateId;
_participantsRequest = _repository.participants(dateId);
});
}
void _retry() => setState(() {
_selectedDateId = null;
_participantsRequest = null;
_initialRequest = _loadInitial();
});
Future<void> _changeAttendance(int dateId, int memberId, bool present) async {
setState(() => _pendingMemberId = memberId);
try {
if (present) {
await _repository.removeAttendance(
diaryDateId: dateId,
memberId: memberId,
);
} else {
await _repository.addAttendance(
diaryDateId: dateId,
memberId: memberId,
);
}
if (!mounted) return;
setState(() => _participantsRequest = _repository.participants(dateId));
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Anwesenheit konnte nicht geändert werden: $error'),
),
);
} finally {
if (mounted) setState(() => _pendingMemberId = null);
}
}
@override
Widget build(BuildContext context) => FutureBuilder<_InitialTrainingData>(
future: _initialRequest,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(height: 300, child: AppLoadingView());
}
if (snapshot.hasError) {
return SizedBox(
height: 300,
child: AppErrorView(
message: snapshot.error.toString(),
onRetry: _retry,
),
);
}
final data = snapshot.requireData;
if (data.dates.isEmpty) return const _NoTrainingDates();
final currentDateId = _selectedDateId ?? _id(data.dates.first);
if (currentDateId == null) return const _NoTrainingDates();
_participantsRequest ??= _repository.participants(currentDateId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DateSelector(
dates: data.dates,
selectedDateId: currentDateId,
onSelected: _selectDate,
),
const SizedBox(height: 24),
FutureBuilder<List<dynamic>>(
future: _participantsRequest,
builder: (context, participantsSnapshot) {
if (participantsSnapshot.connectionState !=
ConnectionState.done) {
return const SizedBox(height: 220, child: AppLoadingView());
}
if (participantsSnapshot.hasError) {
return SizedBox(
height: 220,
child: AppErrorView(
message: participantsSnapshot.error.toString(),
onRetry: () => _selectDate(currentDateId),
),
);
}
return _AttendanceList(
dateId: currentDateId,
members: data.members,
participants: _maps(participantsSnapshot.data),
pendingMemberId: _pendingMemberId,
onChanged: _changeAttendance,
);
},
),
],
);
},
);
}
class _InitialTrainingData {
const _InitialTrainingData({required this.dates, required this.members});
final List<Map<String, dynamic>> dates;
final List<Map<String, dynamic>> members;
}
class _DateSelector extends StatelessWidget {
const _DateSelector({
required this.dates,
required this.selectedDateId,
required this.onSelected,
});
final List<Map<String, dynamic>> dates;
final int selectedDateId;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trainingstag', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: dates.map((date) {
final id = _id(date);
if (id == null) return const SizedBox.shrink();
return ChoiceChip(
label: Text(_dateLabel(date)),
selected: id == selectedDateId,
onSelected: (_) => onSelected(id),
);
}).toList(),
),
],
);
}
class _AttendanceList extends StatelessWidget {
const _AttendanceList({
required this.dateId,
required this.members,
required this.participants,
required this.pendingMemberId,
required this.onChanged,
});
final int dateId;
final List<Map<String, dynamic>> members;
final List<Map<String, dynamic>> participants;
final int? pendingMemberId;
final Future<void> Function(int, int, bool) onChanged;
@override
Widget build(BuildContext context) {
final presentIds = participants
.where((participant) => participant['attendanceStatus'] == 'present')
.map(_id)
.whereType<int>()
.toSet();
final sortedMembers = [...members]
..sort((a, b) => _memberName(a).compareTo(_memberName(b)));
if (sortedMembers.isEmpty) {
return const _EmptyAttendance(
message: 'Für diesen Verein sind keine Mitglieder vorhanden.',
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Anwesenheit', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'${presentIds.length} anwesend',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppColors.muted),
),
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(10),
),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sortedMembers.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final member = sortedMembers[index];
final memberId = _id(member);
if (memberId == null) return const SizedBox.shrink();
final present = presentIds.contains(memberId);
final pending = pendingMemberId == memberId;
return ListTile(
leading: CircleAvatar(
child: Text(_initials(_memberName(member))),
),
title: Text(_memberName(member)),
subtitle: Text(present ? 'Anwesend' : 'Nicht eingetragen'),
trailing: pending
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Switch(
value: present,
onChanged: (_) => onChanged(dateId, memberId, present),
),
);
},
),
),
],
);
}
}
class _NoTrainingDates extends StatelessWidget {
const _NoTrainingDates();
@override
Widget build(BuildContext context) => const _EmptyAttendance(
message: 'Es sind noch keine Trainingstage im Tagebuch vorhanden.',
);
}
class _EmptyAttendance extends StatelessWidget {
const _EmptyAttendance({required this.message});
final String message;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(message, textAlign: TextAlign.center),
),
);
}
List<Map<String, dynamic>> _maps(Object? value) => value is List
? value
.whereType<Map>()
.map((entry) => Map<String, dynamic>.from(entry))
.toList()
: const [];
int? _id(Map<String, dynamic> value) => int.tryParse('${value['id'] ?? ''}');
String _memberName(Map<String, dynamic> member) {
final name =
[
member['firstName'] ?? member['firstname'],
member['lastName'] ?? member['lastname'],
]
.where((part) => part != null && part.toString().trim().isNotEmpty)
.join(' ')
.trim();
return name.isNotEmpty
? name
: (member['fullName']?.toString() ?? 'Mitglied ${member['id']}');
}
String _initials(String name) => name
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.take(2)
.map((part) => part[0])
.join()
.toUpperCase();
String _dateLabel(Map<String, dynamic> date) {
final raw = date['date']?.toString();
if (raw == null || raw.length < 10) return 'Training';
final day = raw.substring(8, 10);
final month = raw.substring(5, 7);
final year = raw.substring(0, 4);
final start = date['trainingStart']?.toString();
return '$day.$month.$year${start == null || start.isEmpty ? '' : ' · ${start.substring(0, start.length.clamp(0, 5))}'}';
}

142
desktop-app/lib/main.dart Normal file
View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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() {
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, api: api),
),
);
}
class TtTagebuchApp extends StatefulWidget {
const TtTagebuchApp({super.key, required this.session, required this.api});
final AppSession session;
final ApiClient api;
@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(
title: 'TT-Tagebuch',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
home: AnimatedBuilder(
animation: widget.session,
builder: (context, _) =>
_SessionPage(session: widget.session, state: this, api: widget.api),
),
);
}
}
class _SessionPage extends StatelessWidget {
const _SessionPage({
required this.session,
required this.state,
required this.api,
});
final AppSession session;
final _TtTagebuchAppState state;
final ApiClient api;
@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,
api: api,
clubId: session.activeClub!.id,
);
}
if (state._forgotPassword) {
return PasswordResetRequestPage(
isLoading: session.busy,
errorMessage: session.errorMessage,
isSuccess: state._resetSent,
onRequestReset: (email) async {
await session.requestPasswordReset(email);
state.markResetSent();
},
onBackToLogin: state.returnToLogin,
);
}
return LoginPage(
isLoading: session.busy,
errorMessage: session.errorMessage,
onSignIn: session.signIn,
onForgotPassword: state.showForgotPassword,
);
}
}

View File

@@ -0,0 +1,4 @@
# Presentation
Wiederverwendbare, featureübergreifende Präsentationsbausteine liegen hier.
Feature-spezifische Seiten verbleiben bei ihrem jeweiligen Feature.