- code import fix
This commit is contained in:
30
desktop-app/lib/core/config/app_environment.dart
Normal file
30
desktop-app/lib/core/config/app_environment.dart
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
desktop-app/lib/core/logging/app_logger.dart
Normal file
12
desktop-app/lib/core/logging/app_logger.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
14
desktop-app/lib/core/navigation/app_router.dart
Normal file
14
desktop-app/lib/core/navigation/app_router.dart
Normal 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()),
|
||||
],
|
||||
);
|
||||
112
desktop-app/lib/core/network/api_client.dart
Normal file
112
desktop-app/lib/core/network/api_client.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
11
desktop-app/lib/core/network/api_exception.dart
Normal file
11
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
desktop-app/lib/core/session/app_session.dart
Normal file
126
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();
|
||||
}
|
||||
}
|
||||
84
desktop-app/lib/core/theme/app_theme.dart
Normal file
84
desktop-app/lib/core/theme/app_theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
51
desktop-app/lib/core/widgets/async_content.dart
Normal file
51
desktop-app/lib/core/widgets/async_content.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
desktop-app/lib/core/widgets/product_mark.dart
Normal file
30
desktop-app/lib/core/widgets/product_mark.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
4
desktop-app/lib/data/README.md
Normal file
4
desktop-app/lib/data/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Data
|
||||
|
||||
API-Clients, DTOs, lokale Speicheradapter und Repository-Implementierungen
|
||||
liegen hier. Diese Schicht enthält keine Widgets.
|
||||
28
desktop-app/lib/data/auth/auth_repository.dart
Normal file
28
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
desktop-app/lib/data/auth/token_storage.dart
Normal file
27
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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
15
desktop-app/lib/data/clubs/club_repository.dart
Normal file
15
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'));
|
||||
}
|
||||
31
desktop-app/lib/data/training/training_repository.dart
Normal file
31
desktop-app/lib/data/training/training_repository.dart
Normal 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},
|
||||
);
|
||||
}
|
||||
4
desktop-app/lib/domain/README.md
Normal file
4
desktop-app/lib/domain/README.md
Normal 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.
|
||||
11
desktop-app/lib/domain/models/club.dart
Normal file
11
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
desktop-app/lib/domain/models/club_permissions.dart
Normal file
14
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 {},
|
||||
),
|
||||
);
|
||||
}
|
||||
327
desktop-app/lib/features/auth/presentation/login_page.dart
Normal file
327
desktop-app/lib/features/auth/presentation/login_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
674
desktop-app/lib/features/shell/presentation/app_shell.dart
Normal file
674
desktop-app/lib/features/shell/presentation/app_shell.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
142
desktop-app/lib/main.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
4
desktop-app/lib/presentation/README.md
Normal file
4
desktop-app/lib/presentation/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Presentation
|
||||
|
||||
Wiederverwendbare, featureübergreifende Präsentationsbausteine liegen hier.
|
||||
Feature-spezifische Seiten verbleiben bei ihrem jeweiligen Feature.
|
||||
Reference in New Issue
Block a user