diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..e7f284f 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,10 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index a5db580..34f125d 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -6,13 +6,11 @@ import 'package:http/http.dart' as http; import '../api_error.dart'; import '../marianumcloud/talk/talk_error.dart'; -import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; import 'network_exception.dart'; import 'parse_exception.dart'; import 'server_exception.dart'; import 'talk_exception.dart'; -import 'webuntis_exception.dart'; const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; @@ -57,7 +55,6 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error is AppException) return error.userMessage; if (error is TalkError) return TalkException(error).userMessage; - if (error is WebuntisError) return WebuntisException(error).userMessage; if (error is DioException) { final mapped = _dioToAppException(error); @@ -90,7 +87,6 @@ String? errorToTechnicalDetails(Object? error) { if (error == null) return null; if (error is AppException) return error.technicalDetails ?? error.toString(); if (error is TalkError) return TalkException(error).technicalDetails; - if (error is WebuntisError) return WebuntisException(error).technicalDetails; if (error is DioException) { final mapped = _dioToAppException(error); if (mapped != null) return mapped.technicalDetails ?? mapped.toString(); diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart deleted file mode 100644 index c09f48a..0000000 --- a/lib/api/errors/webuntis_exception.dart +++ /dev/null @@ -1,31 +0,0 @@ -import '../webuntis/webuntis_error.dart'; -import 'app_exception.dart'; - -class WebuntisException extends AppException { - final WebuntisError source; - - WebuntisException(this.source) - : super( - userMessage: _mapMessage(source), - technicalDetails: 'WebUntis (${source.code}): ${source.message}', - allowRetry: true, - ); - - static String _mapMessage(WebuntisError e) { - switch (e.code) { - case -8504: - case -8502: - return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.'; - case -8520: - return 'Bitte melde dich erneut an.'; - case -7004: - return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.'; - case -32601: - return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.'; - default: - return e.message.isNotEmpty - ? 'WebUntis: ${e.message}' - : 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).'; - } - } -} diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart new file mode 100644 index 0000000..cc301e9 --- /dev/null +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -0,0 +1,110 @@ +import 'package:dio/dio.dart'; + +import '../../../model/account_data.dart'; +import '../queries/auth_login/auth_login.dart'; +import 'device_token_name.dart'; +import 'token_storage.dart'; + +/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, +/// re-logs in once with the credentials in [AccountData] before retrying. +class MarianumConnectAuthInterceptor extends Interceptor { + static const _retriedKey = 'mc_auth_retried'; + + final MarianumConnectTokenStorage _tokenStorage; + final Dio _retryDio; + final AuthLogin _loginClient; + + // Single-flight lock: parallel 401s share the same login Future instead of + // each spawning a fresh row in api_tokens. + Future? _pendingReLogin; + + MarianumConnectAuthInterceptor({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? retryDio, + AuthLogin? loginClient, + }) : _tokenStorage = tokenStorage, + _retryDio = retryDio ?? Dio(), + _loginClient = loginClient ?? AuthLogin(); + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + // Wait for an in-flight re-login so nachrückende Requests den frischen + // Token mitschicken statt ein eigenes 401 einzufangen. + final pending = _pendingReLogin; + if (pending != null) await pending; + final token = await _tokenStorage.readToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final response = err.response; + if (response?.statusCode != 401 || + err.requestOptions.extra[_retriedKey] == true) { + handler.next(err); + return; + } + final refreshed = await _attemptReLogin(); + if (!refreshed) { + handler.next(err); + return; + } + try { + final retried = await _retryWithFreshToken(err.requestOptions); + handler.resolve(retried); + } on DioException catch (retryError) { + handler.next(retryError); + } + } + + Future _attemptReLogin() { + final inFlight = _pendingReLogin; + if (inFlight != null) return inFlight; + final fresh = _performReLogin(); + _pendingReLogin = fresh; + fresh.whenComplete(() { + if (identical(_pendingReLogin, fresh)) _pendingReLogin = null; + }); + return fresh; + } + + Future _performReLogin() async { + if (!AccountData().isPopulated()) return false; + try { + await _loginClient.run( + username: AccountData().getUsername(), + password: AccountData().getPassword(), + tokenName: await DeviceTokenName.resolve(), + ); + return true; + } catch (_) { + await _tokenStorage.clear(); + return false; + } + } + + Future> _retryWithFreshToken( + RequestOptions originalOptions, + ) async { + final freshToken = await _tokenStorage.readToken(); + final headers = Map.of(originalOptions.headers); + if (freshToken != null && freshToken.isNotEmpty) { + headers['Authorization'] = 'Bearer $freshToken'; + } + final clone = originalOptions.copyWith( + headers: headers, + extra: {...originalOptions.extra, _retriedKey: true}, + ); + return _retryDio.fetch(clone); + } +} diff --git a/lib/api/marianumconnect/auth/device_token_name.dart b/lib/api/marianumconnect/auth/device_token_name.dart new file mode 100644 index 0000000..eeb672a --- /dev/null +++ b/lib/api/marianumconnect/auth/device_token_name.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +/// Bearer-token display name shown in the dashboard token list, in the form +/// `"Marianum Fulda App (Pixel 10)"`. Cached because device-info never +/// changes at runtime. +class DeviceTokenName { + static const String _appName = 'Marianum Fulda App'; + + static String? _cached; + + static Future resolve() async { + if (_cached != null) return _cached!; + final device = await _deviceLabel(); + _cached = device.isEmpty ? _appName : '$_appName ($device)'; + return _cached!; + } + + static Future _deviceLabel() async { + try { + final info = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await info.androidInfo; + final model = android.model.trim(); + return model.isNotEmpty ? model : android.device.trim(); + } + if (Platform.isIOS) { + final ios = await info.iosInfo; + // utsname.machine bleibt auch ohne user-zugewiesenen Gerätenamen + // verfügbar; ios.name liefert auf iOS 16+ nur noch Generika. + final machine = ios.utsname.machine.trim(); + if (machine.isNotEmpty) return machine; + return ios.name.trim(); + } + } catch (_) { + // Device-Plugin nicht verfügbar (z.B. Tests). + } + return ''; + } +} diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart new file mode 100644 index 0000000..bea519d --- /dev/null +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -0,0 +1,32 @@ +import 'dart:developer'; + +import '../../../model/account_data.dart'; +import '../../errors/auth_exception.dart'; +import '../queries/auth_logout/auth_logout.dart'; +import '../queries/auth_verify/auth_verify.dart'; +import 'token_storage.dart'; + +/// Background credential probe — a server-side password rotation forces a +/// re-login on the next cold start even when the bearer token would still +/// be accepted. +class SessionValidator { + static Future probeStored({ + required Future Function() onInvalidated, + }) async { + if (!AccountData().isPopulated()) return; + final username = AccountData().getUsername(); + final password = AccountData().getPassword(); + try { + await AuthVerify().run(username: username, password: password); + } on AuthException catch (e) { + if (e.statusCode != 401) return; + log('MC: stored credentials rejected — forcing re-login'); + await AuthLogout().run(); + await const MarianumConnectTokenStorage().clear(); + await AccountData().removeData(); + await onInvalidated(); + } catch (e) { + log('MC: background credential check failed (transient): $e'); + } + } +} diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart new file mode 100644 index 0000000..f5f00e8 --- /dev/null +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -0,0 +1,45 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Persists the Marianum-Connect bearer token in the platform keystore. Kept +/// separate from `AccountData` because the username/password live on (Nextcloud +/// + MHSL still need them) while the MC token is short-lived and per-endpoint. +class MarianumConnectTokenStorage { + static const _tokenKey = 'mc_bearer_token'; + static const _tokenIdKey = 'mc_token_id'; + static const _expiresAtKey = 'mc_token_expires_at'; + + final FlutterSecureStorage _storage; + + const MarianumConnectTokenStorage([ + this._storage = const FlutterSecureStorage(), + ]); + + Future readToken() => _storage.read(key: _tokenKey); + + Future readTokenId() => _storage.read(key: _tokenIdKey); + + Future readExpiresAt() async { + final raw = await _storage.read(key: _expiresAtKey); + if (raw == null || raw.isEmpty) return null; + return DateTime.tryParse(raw); + } + + Future write({ + required String token, + required String tokenId, + required DateTime? expiresAt, + }) async { + await _storage.write(key: _tokenKey, value: token); + await _storage.write(key: _tokenIdKey, value: tokenId); + await _storage.write( + key: _expiresAtKey, + value: expiresAt?.toIso8601String() ?? '', + ); + } + + Future clear() async { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _tokenIdKey); + await _storage.delete(key: _expiresAtKey); + } +} diff --git a/lib/api/marianumconnect/errors/marianumconnect_error.dart b/lib/api/marianumconnect/errors/marianumconnect_error.dart new file mode 100644 index 0000000..4160cc4 --- /dev/null +++ b/lib/api/marianumconnect/errors/marianumconnect_error.dart @@ -0,0 +1,49 @@ +import 'package:dio/dio.dart'; + +import '../../errors/app_exception.dart'; +import '../../errors/auth_exception.dart'; +import '../../errors/network_exception.dart'; +import '../../errors/parse_exception.dart'; +import '../../errors/server_exception.dart'; + +/// Converts a DioException raised against the Marianum-Connect API into one of +/// the app's typed AppExceptions. Keeps the dio dependency out of call sites +/// that just want to render an error message. +AppException mapMarianumConnectError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException.timeout(technicalDetails: error.message); + case DioExceptionType.connectionError: + return NetworkException(technicalDetails: error.message); + case DioExceptionType.badCertificate: + return const NetworkException( + userMessage: + 'Die sichere Verbindung zum Marianum-Connect-Server wurde abgelehnt.', + ); + case DioExceptionType.badResponse: + final status = error.response?.statusCode ?? -1; + if (status == 401) { + return AuthException.unauthorized( + technicalDetails: 'MC 401: ${error.response?.data}', + ); + } + if (status == 403) { + return AuthException.forbidden( + technicalDetails: 'MC 403: ${error.response?.data}', + ); + } + return ServerException( + statusCode: status, + technicalDetails: 'MC HTTP $status: ${error.response?.data}', + ); + case DioExceptionType.cancel: + case DioExceptionType.unknown: + final inner = error.error; + if (inner is FormatException) { + return ParseException(technicalDetails: inner.message); + } + return NetworkException(technicalDetails: error.message); + } +} diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart new file mode 100644 index 0000000..0d7c7bc --- /dev/null +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import 'auth/auth_interceptor.dart'; + +/// Singleton dio instance for the Marianum-Connect mobile API. Wired with the +/// bearer auth interceptor at startup; the base URL is resolved per request +/// through [MarianumConnectEndpoint] so settings changes take effect without +/// recreating the client. +class MarianumConnectApi { + static const Duration _connectTimeout = Duration(seconds: 10); + static const Duration _receiveTimeout = Duration(seconds: 20); + + static final Dio _instance = _build(); + + static Dio dio() => _instance; + + static Dio _build() { + final dio = Dio( + BaseOptions( + connectTimeout: _connectTimeout, + sendTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + responseType: ResponseType.json, + contentType: 'application/json', + ), + ); + dio.interceptors.add(MarianumConnectAuthInterceptor()); + return dio; + } +} diff --git a/lib/api/marianumconnect/marianumconnect_endpoint.dart b/lib/api/marianumconnect/marianumconnect_endpoint.dart new file mode 100644 index 0000000..8a8c68c --- /dev/null +++ b/lib/api/marianumconnect/marianumconnect_endpoint.dart @@ -0,0 +1,22 @@ +import '../../storage/dev_tools_settings.dart'; + +/// Singleton holding the currently active Marianum-Connect base URL. Fed by a +/// SettingsCubit listener in app.dart so every dio call picks up endpoint +/// changes without holding a reference to the cubit. +class MarianumConnectEndpoint { + static String _baseUrl = DevToolsSettings.liveUrl; + + static String current() => _baseUrl; + + static void update(String baseUrl) { + _baseUrl = baseUrl; + } + + /// Joins the base URL with the mobile API prefix and the given path. + static String resolve(String relativePath) { + final path = relativePath.startsWith('/') + ? relativePath.substring(1) + : relativePath; + return '$_baseUrl/api/mobile/v1/$path'; + } +} diff --git a/lib/api/marianumconnect/queries/auth_login/auth_login.dart b/lib/api/marianumconnect/queries/auth_login/auth_login.dart new file mode 100644 index 0000000..7b37304 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_login/auth_login.dart @@ -0,0 +1,61 @@ +import 'package:dio/dio.dart'; + +import '../../auth/token_storage.dart'; +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'auth_login_response.dart'; + +/// Performs the Marianum-Connect bearer login. Used both by the foreground +/// login flow and by the auth interceptor's silent re-auth on 401. Does *not* +/// run through the shared dio instance — that one has the interceptor, which +/// would attempt to re-auth us into a loop if our credentials are wrong. +class AuthLogin { + static const Duration _connectTimeout = Duration(seconds: 10); + static const Duration _receiveTimeout = Duration(seconds: 15); + + final MarianumConnectTokenStorage _tokenStorage; + final Dio _dio; + + AuthLogin({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + sendTimeout: _connectTimeout, + responseType: ResponseType.json, + contentType: 'application/json', + ), + ); + + Future run({ + required String username, + required String password, + required String tokenName, + }) async { + try { + final response = await _dio.post>( + MarianumConnectEndpoint.resolve('auth/login'), + data: { + 'username': username, + 'password': password, + 'tokenName': tokenName, + }, + ); + final payload = AuthLoginResponse.fromJson(response.data!); + await _tokenStorage.write( + token: payload.token, + tokenId: payload.tokenId, + expiresAt: payload.expiresAt, + ); + return payload; + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/auth_login/auth_login_response.dart b/lib/api/marianumconnect/queries/auth_login/auth_login_response.dart new file mode 100644 index 0000000..f7cdf78 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_login/auth_login_response.dart @@ -0,0 +1,54 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'auth_login_response.g.dart'; + +@JsonSerializable() +class AuthLoginUser { + final String id; + final String username; + final String firstName; + final String lastName; + final String? userType; + final String? className; + + AuthLoginUser({ + required this.id, + required this.username, + required this.firstName, + required this.lastName, + required this.userType, + required this.className, + }); + + factory AuthLoginUser.fromJson(Map json) => + _$AuthLoginUserFromJson(json); + Map toJson() => _$AuthLoginUserToJson(this); +} + +@JsonSerializable() +class AuthLoginResponse { + final String token; + final String tokenId; + + @JsonKey(fromJson: _expiresFromJson) + final DateTime? expiresAt; + + final AuthLoginUser user; + + AuthLoginResponse({ + required this.token, + required this.tokenId, + required this.expiresAt, + required this.user, + }); + + factory AuthLoginResponse.fromJson(Map json) => + _$AuthLoginResponseFromJson(json); + Map toJson() => _$AuthLoginResponseToJson(this); + + static DateTime? _expiresFromJson(Object? value) { + if (value == null) return null; + if (value is String) return DateTime.tryParse(value); + return null; + } +} diff --git a/lib/api/marianumconnect/queries/auth_login/auth_login_response.g.dart b/lib/api/marianumconnect/queries/auth_login/auth_login_response.g.dart new file mode 100644 index 0000000..591ccc4 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_login/auth_login_response.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_login_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AuthLoginUser _$AuthLoginUserFromJson(Map json) => + AuthLoginUser( + id: json['id'] as String, + username: json['username'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + userType: json['userType'] as String?, + className: json['className'] as String?, + ); + +Map _$AuthLoginUserToJson(AuthLoginUser instance) => + { + 'id': instance.id, + 'username': instance.username, + 'firstName': instance.firstName, + 'lastName': instance.lastName, + 'userType': instance.userType, + 'className': instance.className, + }; + +AuthLoginResponse _$AuthLoginResponseFromJson(Map json) => + AuthLoginResponse( + token: json['token'] as String, + tokenId: json['tokenId'] as String, + expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']), + user: AuthLoginUser.fromJson(json['user'] as Map), + ); + +Map _$AuthLoginResponseToJson(AuthLoginResponse instance) => + { + 'token': instance.token, + 'tokenId': instance.tokenId, + 'expiresAt': instance.expiresAt?.toIso8601String(), + 'user': instance.user, + }; diff --git a/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart new file mode 100644 index 0000000..d5f9624 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_logout/auth_logout.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +/// Revokes the stored MC bearer token both server-side and locally. Best-effort +/// — a network error still clears the local token so the user isn't stuck with +/// an unusable session. +class AuthLogout { + final MarianumConnectTokenStorage _tokenStorage; + final Dio _dio; + + AuthLogout({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + await _dio.post(MarianumConnectEndpoint.resolve('auth/logout')); + } on DioException catch (_) { + // ignore — local clear below still happens + } finally { + await _tokenStorage.clear(); + } + } +} diff --git a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart new file mode 100644 index 0000000..e60e5e8 --- /dev/null +++ b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart @@ -0,0 +1,62 @@ +import 'package:dio/dio.dart'; + +import '../../../errors/auth_exception.dart'; +import '../../auth/token_storage.dart'; +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_endpoint.dart'; + +/// Probes that the stored bearer token still maps to the given credentials. +/// Server returns 200 only when the credentials belong to the user that the +/// token was issued for — a password rotation on that user's account flips +/// it to 401 even if the token itself would still be accepted. +/// +/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in +/// and obscure a real 401 with a silent re-login. +class AuthVerify { + static const Duration _connectTimeout = Duration(seconds: 10); + static const Duration _receiveTimeout = Duration(seconds: 15); + + final MarianumConnectTokenStorage _tokenStorage; + final Dio _dio; + + AuthVerify({ + MarianumConnectTokenStorage tokenStorage = + const MarianumConnectTokenStorage(), + Dio? dio, + }) : _tokenStorage = tokenStorage, + _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: _connectTimeout, + sendTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + responseType: ResponseType.json, + contentType: 'application/json', + ), + ); + + /// Throws [AuthException] on 401 (credentials no longer match the token's + /// user, token missing, or token rejected), other [AppException]s on + /// network/server errors. Completes silently on success. + Future run({ + required String username, + required String password, + }) async { + final token = await _tokenStorage.readToken(); + if (token == null || token.isEmpty) { + throw AuthException.unauthorized( + technicalDetails: 'AuthVerify: no bearer token in storage', + ); + } + try { + await _dio.post( + MarianumConnectEndpoint.resolve('auth/verify'), + data: {'username': username, 'password': password}, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart new file mode 100644 index 0000000..d120820 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_holidays_response.dart'; + +class TimetableGetHolidays { + final Dio _dio; + + TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/holidays'), + ); + final list = response.data! + .map((e) => McHoliday.fromJson(e as Map)) + .toList(); + return TimetableGetHolidaysResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart new file mode 100644 index 0000000..58360dc --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart @@ -0,0 +1,43 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_holidays_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McHoliday { + final String shortName; + final String longName; + + @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson) + final DateTime startDate; + + @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson) + final DateTime endDate; + + McHoliday({ + required this.shortName, + required this.longName, + required this.startDate, + required this.endDate, + }); + + factory McHoliday.fromJson(Map json) => + _$McHolidayFromJson(json); + Map toJson() => _$McHolidayToJson(this); + + static DateTime _dateFromJson(String raw) => DateTime.parse(raw); + static String _dateToJson(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetHolidaysResponse extends ApiResponse { + final List result; + + TimetableGetHolidaysResponse({required this.result}); + + factory TimetableGetHolidaysResponse.fromJson(Map json) => + _$TimetableGetHolidaysResponseFromJson(json); + Map toJson() => _$TimetableGetHolidaysResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.g.dart new file mode 100644 index 0000000..a49a40f --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_holidays_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McHoliday _$McHolidayFromJson(Map json) => McHoliday( + shortName: json['shortName'] as String, + longName: json['longName'] as String, + startDate: McHoliday._dateFromJson(json['startDate'] as String), + endDate: McHoliday._dateFromJson(json['endDate'] as String), +); + +Map _$McHolidayToJson(McHoliday instance) => { + 'shortName': instance.shortName, + 'longName': instance.longName, + 'startDate': McHoliday._dateToJson(instance.startDate), + 'endDate': McHoliday._dateToJson(instance.endDate), +}; + +TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson( + Map json, +) => + TimetableGetHolidaysResponse( + result: (json['result'] as List) + .map((e) => McHoliday.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetHolidaysResponseToJson( + TimetableGetHolidaysResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart new file mode 100644 index 0000000..ff28f5c --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_rooms_response.dart'; + +class TimetableGetRooms { + final Dio _dio; + + TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/rooms'), + ); + final list = response.data! + .map((e) => McRoom.fromJson(e as Map)) + .toList(); + return TimetableGetRoomsResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart new file mode 100644 index 0000000..32def62 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart @@ -0,0 +1,28 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_rooms_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McRoom { + final int id; + final String shortName; + final String longName; + + McRoom({required this.id, required this.shortName, required this.longName}); + + factory McRoom.fromJson(Map json) => _$McRoomFromJson(json); + Map toJson() => _$McRoomToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetRoomsResponse extends ApiResponse { + final List result; + + TimetableGetRoomsResponse({required this.result}); + + factory TimetableGetRoomsResponse.fromJson(Map json) => + _$TimetableGetRoomsResponseFromJson(json); + Map toJson() => _$TimetableGetRoomsResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.g.dart new file mode 100644 index 0000000..3c0de3c --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_rooms_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McRoom _$McRoomFromJson(Map json) => McRoom( + id: (json['id'] as num).toInt(), + shortName: json['shortName'] as String, + longName: json['longName'] as String, +); + +Map _$McRoomToJson(McRoom instance) => { + 'id': instance.id, + 'shortName': instance.shortName, + 'longName': instance.longName, +}; + +TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson( + Map json, +) => + TimetableGetRoomsResponse( + result: (json['result'] as List) + .map((e) => McRoom.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetRoomsResponseToJson( + TimetableGetRoomsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart new file mode 100644 index 0000000..30d8ffa --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_schoolyear_response.dart'; + +class TimetableGetSchoolyear { + final Dio _dio; + + TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/schoolyear'), + ); + return TimetableGetSchoolyearResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart new file mode 100644 index 0000000..e275c80 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_schoolyear_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class TimetableGetSchoolyearResponse extends ApiResponse { + final int id; + final String name; + + @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson) + final DateTime startDate; + + @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson) + final DateTime endDate; + + TimetableGetSchoolyearResponse({ + required this.id, + required this.name, + required this.startDate, + required this.endDate, + }); + + factory TimetableGetSchoolyearResponse.fromJson(Map json) => + _$TimetableGetSchoolyearResponseFromJson(json); + Map toJson() => + _$TimetableGetSchoolyearResponseToJson(this); + + static DateTime _dateFromJson(String raw) => DateTime.parse(raw); + static String _dateToJson(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; +} diff --git a/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.g.dart new file mode 100644 index 0000000..4046682 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_schoolyear_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TimetableGetSchoolyearResponse _$TimetableGetSchoolyearResponseFromJson( + Map json, +) => + TimetableGetSchoolyearResponse( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + startDate: TimetableGetSchoolyearResponse._dateFromJson( + json['startDate'] as String, + ), + endDate: TimetableGetSchoolyearResponse._dateFromJson( + json['endDate'] as String, + ), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetSchoolyearResponseToJson( + TimetableGetSchoolyearResponse instance, +) => { + 'headers': ?instance.headers, + 'id': instance.id, + 'name': instance.name, + 'startDate': TimetableGetSchoolyearResponse._dateToJson(instance.startDate), + 'endDate': TimetableGetSchoolyearResponse._dateToJson(instance.endDate), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart new file mode 100644 index 0000000..d126326 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_subjects_response.dart'; + +class TimetableGetSubjects { + final Dio _dio; + + TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/subjects'), + ); + final list = response.data! + .map((e) => McSubject.fromJson(e as Map)) + .toList(); + return TimetableGetSubjectsResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart new file mode 100644 index 0000000..a61da36 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_subjects_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McSubject { + final int id; + final String shortName; + final String longName; + + McSubject({ + required this.id, + required this.shortName, + required this.longName, + }); + + factory McSubject.fromJson(Map json) => + _$McSubjectFromJson(json); + Map toJson() => _$McSubjectToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetSubjectsResponse extends ApiResponse { + final List result; + + TimetableGetSubjectsResponse({required this.result}); + + factory TimetableGetSubjectsResponse.fromJson(Map json) => + _$TimetableGetSubjectsResponseFromJson(json); + Map toJson() => _$TimetableGetSubjectsResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart new file mode 100644 index 0000000..78e77b5 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_subjects_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McSubject _$McSubjectFromJson(Map json) => McSubject( + id: (json['id'] as num).toInt(), + shortName: json['shortName'] as String, + longName: json['longName'] as String, +); + +Map _$McSubjectToJson(McSubject instance) => { + 'id': instance.id, + 'shortName': instance.shortName, + 'longName': instance.longName, +}; + +TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson( + Map json, +) => + TimetableGetSubjectsResponse( + result: (json['result'] as List) + .map((e) => McSubject.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetSubjectsResponseToJson( + TimetableGetSubjectsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart new file mode 100644 index 0000000..282dbcb --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_timegrid_response.dart'; + +class TimetableGetTimegrid { + final Dio _dio; + + TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/timegrid'), + ); + final list = response.data! + .map((e) => McTimegridUnit.fromJson(e as Map)) + .toList(); + return TimetableGetTimegridResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart new file mode 100644 index 0000000..13a79a5 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart @@ -0,0 +1,98 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_timegrid_response.g.dart'; + +/// Java DayOfWeek serializes as the enum name (MONDAY, TUESDAY, …). +enum McDayOfWeek { + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + sunday, +} + +McDayOfWeek _dayFromJson(String raw) { + switch (raw.toUpperCase()) { + case 'MONDAY': + return McDayOfWeek.monday; + case 'TUESDAY': + return McDayOfWeek.tuesday; + case 'WEDNESDAY': + return McDayOfWeek.wednesday; + case 'THURSDAY': + return McDayOfWeek.thursday; + case 'FRIDAY': + return McDayOfWeek.friday; + case 'SATURDAY': + return McDayOfWeek.saturday; + case 'SUNDAY': + return McDayOfWeek.sunday; + default: + // Unknown values keep the timetable rendering from crashing; the UI + // falls back to its hardcoded grid in that case. + return McDayOfWeek.monday; + } +} + +String _dayToJson(McDayOfWeek d) { + switch (d) { + case McDayOfWeek.monday: + return 'MONDAY'; + case McDayOfWeek.tuesday: + return 'TUESDAY'; + case McDayOfWeek.wednesday: + return 'WEDNESDAY'; + case McDayOfWeek.thursday: + return 'THURSDAY'; + case McDayOfWeek.friday: + return 'FRIDAY'; + case McDayOfWeek.saturday: + return 'SATURDAY'; + case McDayOfWeek.sunday: + return 'SUNDAY'; + } +} + +@JsonSerializable(explicitToJson: true) +class McTimegridUnit { + @JsonKey(fromJson: _dayFromJson, toJson: _dayToJson) + final McDayOfWeek dayOfWeek; + + final String label; + + @JsonKey(fromJson: _timeFromJson, toJson: _timeToJson) + final DateTime startTime; + + @JsonKey(fromJson: _timeFromJson, toJson: _timeToJson) + final DateTime endTime; + + McTimegridUnit({ + required this.dayOfWeek, + required this.label, + required this.startTime, + required this.endTime, + }); + + factory McTimegridUnit.fromJson(Map json) => + _$McTimegridUnitFromJson(json); + Map toJson() => _$McTimegridUnitToJson(this); + + static DateTime _timeFromJson(String raw) => DateTime.parse('1970-01-01T$raw'); + static String _timeToJson(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetTimegridResponse extends ApiResponse { + final List result; + + TimetableGetTimegridResponse({required this.result}); + + factory TimetableGetTimegridResponse.fromJson(Map json) => + _$TimetableGetTimegridResponseFromJson(json); + Map toJson() => _$TimetableGetTimegridResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.g.dart new file mode 100644 index 0000000..ff96032 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_timegrid_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McTimegridUnit _$McTimegridUnitFromJson(Map json) => + McTimegridUnit( + dayOfWeek: _dayFromJson(json['dayOfWeek'] as String), + label: json['label'] as String, + startTime: McTimegridUnit._timeFromJson(json['startTime'] as String), + endTime: McTimegridUnit._timeFromJson(json['endTime'] as String), + ); + +Map _$McTimegridUnitToJson(McTimegridUnit instance) => + { + 'dayOfWeek': _dayToJson(instance.dayOfWeek), + 'label': instance.label, + 'startTime': McTimegridUnit._timeToJson(instance.startTime), + 'endTime': McTimegridUnit._timeToJson(instance.endTime), + }; + +TimetableGetTimegridResponse _$TimetableGetTimegridResponseFromJson( + Map json, +) => + TimetableGetTimegridResponse( + result: (json['result'] as List) + .map((e) => McTimegridUnit.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetTimegridResponseToJson( + TimetableGetTimegridResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart new file mode 100644 index 0000000..97cdfe7 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_week_response.dart'; + +class TimetableGetWeek { + final Dio _dio; + + TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run({ + required DateTime from, + required DateTime until, + }) async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/me'), + queryParameters: { + 'from': _format(from), + 'until': _format(until), + }, + ); + return TimetableGetWeekResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } + + String _format(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; +} diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart new file mode 100644 index 0000000..48bcc7e --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart @@ -0,0 +1,108 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_week_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McTimetableTeacher { + final String shortName; + final String displayName; + final String? originalShortName; + final String? originalDisplayName; + + McTimetableTeacher({ + required this.shortName, + required this.displayName, + this.originalShortName, + this.originalDisplayName, + }); + + factory McTimetableTeacher.fromJson(Map json) => + _$McTimetableTeacherFromJson(json); + Map toJson() => _$McTimetableTeacherToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class McTimetableEntry { + final int id; + + @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson) + final DateTime date; + + @JsonKey(fromJson: _timeFromJson, toJson: _timeToJson) + final DateTime startTime; + + @JsonKey(fromJson: _timeFromJson, toJson: _timeToJson) + final DateTime endTime; + + final List subjects; + final List teachers; + final List rooms; + final List classNames; + final String lessonType; + final String status; + final String? substitutionText; + final String? lessonText; + final String? infoText; + + McTimetableEntry({ + required this.id, + required this.date, + required this.startTime, + required this.endTime, + required this.subjects, + required this.teachers, + required this.rooms, + required this.classNames, + required this.lessonType, + required this.status, + required this.substitutionText, + required this.lessonText, + required this.infoText, + }); + + factory McTimetableEntry.fromJson(Map json) => + _$McTimetableEntryFromJson(json); + Map toJson() => _$McTimetableEntryToJson(this); + + /// Combines the calendar date with the hour/minute portion of [startTime] + /// (which carries a 1970 placeholder date) into a real DateTime. + DateTime get startDateTime => + DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute); + + DateTime get endDateTime => + DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); + + static DateTime _dateFromJson(String raw) => DateTime.parse(raw); + static String _dateToJson(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + // Backend sends ISO_LOCAL_TIME (e.g. "08:00:00" or "08:00"). Parsed via a + // fixed-date prefix so we get a real DateTime out of it; only hour/minute + // are meaningful for rendering. + static DateTime _timeFromJson(String raw) => DateTime.parse('1970-01-01T$raw'); + static String _timeToJson(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetWeekResponse extends ApiResponse { + @JsonKey(fromJson: McTimetableEntry._dateFromJson, toJson: McTimetableEntry._dateToJson) + final DateTime from; + + @JsonKey(fromJson: McTimetableEntry._dateFromJson, toJson: McTimetableEntry._dateToJson) + final DateTime until; + + final List entries; + + TimetableGetWeekResponse({ + required this.from, + required this.until, + required this.entries, + }); + + factory TimetableGetWeekResponse.fromJson(Map json) => + _$TimetableGetWeekResponseFromJson(json); + Map toJson() => _$TimetableGetWeekResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart new file mode 100644 index 0000000..96edde4 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart @@ -0,0 +1,86 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_week_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McTimetableTeacher _$McTimetableTeacherFromJson(Map json) => + McTimetableTeacher( + shortName: json['shortName'] as String, + displayName: json['displayName'] as String, + originalShortName: json['originalShortName'] as String?, + originalDisplayName: json['originalDisplayName'] as String?, + ); + +Map _$McTimetableTeacherToJson(McTimetableTeacher instance) => + { + 'shortName': instance.shortName, + 'displayName': instance.displayName, + 'originalShortName': instance.originalShortName, + 'originalDisplayName': instance.originalDisplayName, + }; + +McTimetableEntry _$McTimetableEntryFromJson(Map json) => + McTimetableEntry( + id: (json['id'] as num).toInt(), + date: McTimetableEntry._dateFromJson(json['date'] as String), + startTime: McTimetableEntry._timeFromJson(json['startTime'] as String), + endTime: McTimetableEntry._timeFromJson(json['endTime'] as String), + subjects: (json['subjects'] as List) + .map((e) => e as String) + .toList(), + teachers: (json['teachers'] as List) + .map((e) => McTimetableTeacher.fromJson(e as Map)) + .toList(), + rooms: (json['rooms'] as List).map((e) => e as String).toList(), + classNames: (json['classNames'] as List) + .map((e) => e as String) + .toList(), + lessonType: json['lessonType'] as String, + status: json['status'] as String, + substitutionText: json['substitutionText'] as String?, + lessonText: json['lessonText'] as String?, + infoText: json['infoText'] as String?, + ); + +Map _$McTimetableEntryToJson(McTimetableEntry instance) => + { + 'id': instance.id, + 'date': McTimetableEntry._dateToJson(instance.date), + 'startTime': McTimetableEntry._timeToJson(instance.startTime), + 'endTime': McTimetableEntry._timeToJson(instance.endTime), + 'subjects': instance.subjects, + 'teachers': instance.teachers.map((e) => e.toJson()).toList(), + 'rooms': instance.rooms, + 'classNames': instance.classNames, + 'lessonType': instance.lessonType, + 'status': instance.status, + 'substitutionText': instance.substitutionText, + 'lessonText': instance.lessonText, + 'infoText': instance.infoText, + }; + +TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson( + Map json, +) => + TimetableGetWeekResponse( + from: McTimetableEntry._dateFromJson(json['from'] as String), + until: McTimetableEntry._dateFromJson(json['until'] as String), + entries: (json['entries'] as List) + .map((e) => McTimetableEntry.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetWeekResponseToJson( + TimetableGetWeekResponse instance, +) => { + 'headers': ?instance.headers, + 'from': McTimetableEntry._dateToJson(instance.from), + 'until': McTimetableEntry._dateToJson(instance.until), + 'entries': instance.entries.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart deleted file mode 100644 index 183b513..0000000 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import '../../../../model/account_data.dart'; -import '../../webuntis_api.dart'; -import 'authenticate_params.dart'; -import 'authenticate_response.dart'; - -class Authenticate extends WebuntisApi { - AuthenticateParams param; - - Authenticate(this.param) - : super('authenticate', param, authenticatedResponse: false); - - @override - Future run() async { - awaitingResponse = true; - try { - final rawAnswer = await query(this); - final decoded = jsonDecode(rawAnswer) as Map; - final response = finalize( - AuthenticateResponse.fromJson( - decoded['result'] as Map, - ), - ); - _lastResponse = response; - if (!awaitedResponse.isCompleted) awaitedResponse.complete(); - return response; - } catch (e) { - // Surface the error to anyone waiting on the current completer, then - // install a fresh one so a future attempt can succeed. Without this, - // any later call to getSession() would hang forever on a completer - // that is already settled with no listeners (or never settles at all). - if (!awaitedResponse.isCompleted) awaitedResponse.completeError(e); - awaitedResponse = Completer(); - rethrow; - } finally { - awaitingResponse = false; - } - } - - static bool awaitingResponse = false; - static Completer awaitedResponse = Completer(); - static AuthenticateResponse? _lastResponse; - - static Future createSession() async { - _lastResponse = await Authenticate( - AuthenticateParams( - user: AccountData().getUsername(), - password: AccountData().getPassword(), - ), - ).run(); - } - - static Future getSession() async { - if (awaitingResponse) { - await awaitedResponse.future; - } - - if (_lastResponse == null) { - awaitingResponse = true; - await createSession(); - } - return _lastResponse!; - } -} diff --git a/lib/api/webuntis/queries/authenticate/authenticate_params.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.dart deleted file mode 100644 index 4af2cec..0000000 --- a/lib/api/webuntis/queries/authenticate/authenticate_params.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_params.dart'; - -part 'authenticate_params.g.dart'; - -@JsonSerializable() -class AuthenticateParams extends ApiParams { - String user; - String password; - - AuthenticateParams({required this.user, required this.password}); - factory AuthenticateParams.fromJson(Map json) => - _$AuthenticateParamsFromJson(json); - - Map toJson() => _$AuthenticateParamsToJson(this); -} diff --git a/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart deleted file mode 100644 index 9765ad8..0000000 --- a/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart +++ /dev/null @@ -1,16 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'authenticate_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -AuthenticateParams _$AuthenticateParamsFromJson(Map json) => - AuthenticateParams( - user: json['user'] as String, - password: json['password'] as String, - ); - -Map _$AuthenticateParamsToJson(AuthenticateParams instance) => - {'user': instance.user, 'password': instance.password}; diff --git a/lib/api/webuntis/queries/authenticate/authenticate_response.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.dart deleted file mode 100644 index b9c661e..0000000 --- a/lib/api/webuntis/queries/authenticate/authenticate_response.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'authenticate_response.g.dart'; - -@JsonSerializable() -class AuthenticateResponse extends ApiResponse { - String sessionId; - int personType; - int personId; - int klasseId; - - AuthenticateResponse( - this.sessionId, - this.personType, - this.personId, - this.klasseId, - ); - - factory AuthenticateResponse.fromJson(Map json) => - _$AuthenticateResponseFromJson(json); - Map toJson() => _$AuthenticateResponseToJson(this); -} diff --git a/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart deleted file mode 100644 index e7d7dd0..0000000 --- a/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart +++ /dev/null @@ -1,30 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'authenticate_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -AuthenticateResponse _$AuthenticateResponseFromJson( - Map json, -) => - AuthenticateResponse( - json['sessionId'] as String, - (json['personType'] as num).toInt(), - (json['personId'] as num).toInt(), - (json['klasseId'] as num).toInt(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$AuthenticateResponseToJson( - AuthenticateResponse instance, -) => { - 'headers': ?instance.headers, - 'sessionId': instance.sessionId, - 'personType': instance.personType, - 'personId': instance.personId, - 'klasseId': instance.klasseId, -}; diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart deleted file mode 100644 index 195fe14..0000000 --- a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import '../../webuntis_api.dart'; -import 'get_current_schoolyear_response.dart'; - -class GetCurrentSchoolyear extends WebuntisApi { - GetCurrentSchoolyear() : super('getCurrentSchoolyear', null); - - @override - Future run() async { - final rawAnswer = await query(this); - return finalize( - GetCurrentSchoolyearResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } -} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart deleted file mode 100644 index 03afe61..0000000 --- a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart +++ /dev/null @@ -1,15 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_current_schoolyear.dart'; -import 'get_current_schoolyear_response.dart'; - -class GetCurrentSchoolyearCache - extends SimpleCache { - GetCurrentSchoolyearCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetCurrentSchoolyear().run(), - fromJson: GetCurrentSchoolyearResponse.fromJson, - ) { - start('wu-current-schoolyear'); - } -} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart deleted file mode 100644 index 57cefa1..0000000 --- a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_current_schoolyear_response.g.dart'; - -/// Wraps Webuntis' `getCurrentSchoolyear` payload. The server returns a -/// single object with the current school year's bounds (yyyyMMdd integers). -@JsonSerializable(explicitToJson: true) -class GetCurrentSchoolyearResponse extends ApiResponse { - GetCurrentSchoolyearResponseObject result; - - GetCurrentSchoolyearResponse(this.result); - - factory GetCurrentSchoolyearResponse.fromJson(Map json) => - _$GetCurrentSchoolyearResponseFromJson(json); - Map toJson() => _$GetCurrentSchoolyearResponseToJson(this); -} - -@JsonSerializable() -class GetCurrentSchoolyearResponseObject { - int id; - String name; - int startDate; - int endDate; - - GetCurrentSchoolyearResponseObject( - this.id, - this.name, - this.startDate, - this.endDate, - ); - - factory GetCurrentSchoolyearResponseObject.fromJson( - Map json, - ) => _$GetCurrentSchoolyearResponseObjectFromJson(json); - Map toJson() => - _$GetCurrentSchoolyearResponseObjectToJson(this); -} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart deleted file mode 100644 index 6505442..0000000 --- a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart +++ /dev/null @@ -1,44 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_current_schoolyear_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetCurrentSchoolyearResponse _$GetCurrentSchoolyearResponseFromJson( - Map json, -) => - GetCurrentSchoolyearResponse( - GetCurrentSchoolyearResponseObject.fromJson( - json['result'] as Map, - ), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetCurrentSchoolyearResponseToJson( - GetCurrentSchoolyearResponse instance, -) => { - 'headers': ?instance.headers, - 'result': instance.result.toJson(), -}; - -GetCurrentSchoolyearResponseObject _$GetCurrentSchoolyearResponseObjectFromJson( - Map json, -) => GetCurrentSchoolyearResponseObject( - (json['id'] as num).toInt(), - json['name'] as String, - (json['startDate'] as num).toInt(), - (json['endDate'] as num).toInt(), -); - -Map _$GetCurrentSchoolyearResponseObjectToJson( - GetCurrentSchoolyearResponseObject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'startDate': instance.startDate, - 'endDate': instance.endDate, -}; diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays.dart b/lib/api/webuntis/queries/get_holidays/get_holidays.dart deleted file mode 100644 index d314004..0000000 --- a/lib/api/webuntis/queries/get_holidays/get_holidays.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:convert'; - -import '../../webuntis_api.dart'; -import 'get_holidays_response.dart'; - -class GetHolidays extends WebuntisApi { - GetHolidays() : super('getHolidays', null); - - @override - Future run() async { - final rawAnswer = await query(this); - return finalize( - GetHolidaysResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } - - static GetHolidaysResponseObject? find( - GetHolidaysResponse holidaysResponse, { - DateTime? time, - }) { - time ??= DateTime.now(); - time = DateTime(time.year, time.month, time.day, 0, 0, 0, 0, 0); - - for (var element in holidaysResponse.result) { - var start = DateTime.parse(element.startDate.toString()); - var end = DateTime.parse(element.endDate.toString()); - - if (!start.isAfter(time) && !end.isBefore(time)) return element; - } - return null; - } -} diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart deleted file mode 100644 index 1a9393d..0000000 --- a/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart +++ /dev/null @@ -1,14 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_holidays.dart'; -import 'get_holidays_response.dart'; - -class GetHolidaysCache extends SimpleCache { - GetHolidaysCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetHolidays().run(), - fromJson: GetHolidaysResponse.fromJson, - ) { - start('wu-holidays'); - } -} diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart deleted file mode 100644 index 019603e..0000000 --- a/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_holidays_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetHolidaysResponse extends ApiResponse { - Set result; - - GetHolidaysResponse(this.result); - - factory GetHolidaysResponse.fromJson(Map json) => - _$GetHolidaysResponseFromJson(json); - Map toJson() => _$GetHolidaysResponseToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetHolidaysResponseObject { - int id; - String name; - String longName; - int startDate; - int endDate; - - GetHolidaysResponseObject( - this.id, - this.name, - this.longName, - this.startDate, - this.endDate, - ); - - factory GetHolidaysResponseObject.fromJson(Map json) => - _$GetHolidaysResponseObjectFromJson(json); - Map toJson() => _$GetHolidaysResponseObjectToJson(this); -} diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart deleted file mode 100644 index e373642..0000000 --- a/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_holidays_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetHolidaysResponse _$GetHolidaysResponseFromJson(Map json) => - GetHolidaysResponse( - (json['result'] as List) - .map( - (e) => - GetHolidaysResponseObject.fromJson(e as Map), - ) - .toSet(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetHolidaysResponseToJson( - GetHolidaysResponse instance, -) => { - 'headers': ?instance.headers, - 'result': instance.result.map((e) => e.toJson()).toList(), -}; - -GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson( - Map json, -) => GetHolidaysResponseObject( - (json['id'] as num).toInt(), - json['name'] as String, - json['longName'] as String, - (json['startDate'] as num).toInt(), - (json['endDate'] as num).toInt(), -); - -Map _$GetHolidaysResponseObjectToJson( - GetHolidaysResponseObject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longName': instance.longName, - 'startDate': instance.startDate, - 'endDate': instance.endDate, -}; diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms.dart b/lib/api/webuntis/queries/get_rooms/get_rooms.dart deleted file mode 100644 index d7d32b3..0000000 --- a/lib/api/webuntis/queries/get_rooms/get_rooms.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import '../../webuntis_api.dart'; -import 'get_rooms_response.dart'; - -class GetRooms extends WebuntisApi { - GetRooms() : super('getRooms', null); - - @override - Future run() async { - final rawAnswer = await query(this); - try { - return finalize( - GetRoomsResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } catch (e, trace) { - log(trace.toString()); - log('Failed to parse getRoom data with server response: $rawAnswer'); - } - - throw Exception('Failed to parse getRoom server response: $rawAnswer'); - } -} diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart deleted file mode 100644 index df62f87..0000000 --- a/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart +++ /dev/null @@ -1,14 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_rooms.dart'; -import 'get_rooms_response.dart'; - -class GetRoomsCache extends SimpleCache { - GetRoomsCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheHour, - loader: () => GetRooms().run(), - fromJson: GetRoomsResponse.fromJson, - ) { - start('wu-rooms'); - } -} diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart deleted file mode 100644 index 83bff1d..0000000 --- a/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_rooms_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetRoomsResponse extends ApiResponse { - Set result; - - GetRoomsResponse(this.result); - - factory GetRoomsResponse.fromJson(Map json) => - _$GetRoomsResponseFromJson(json); - Map toJson() => _$GetRoomsResponseToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetRoomsResponseObject { - int id; - String name; - String longName; - bool active; - String building; - - GetRoomsResponseObject( - this.id, - this.name, - this.longName, - this.active, - this.building, - ); - - factory GetRoomsResponseObject.fromJson(Map json) => - _$GetRoomsResponseObjectFromJson(json); - Map toJson() => _$GetRoomsResponseObjectToJson(this); -} diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart deleted file mode 100644 index 2bf9998..0000000 --- a/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart +++ /dev/null @@ -1,45 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_rooms_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetRoomsResponse _$GetRoomsResponseFromJson(Map json) => - GetRoomsResponse( - (json['result'] as List) - .map( - (e) => GetRoomsResponseObject.fromJson(e as Map), - ) - .toSet(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetRoomsResponseToJson(GetRoomsResponse instance) => - { - 'headers': ?instance.headers, - 'result': instance.result.map((e) => e.toJson()).toList(), - }; - -GetRoomsResponseObject _$GetRoomsResponseObjectFromJson( - Map json, -) => GetRoomsResponseObject( - (json['id'] as num).toInt(), - json['name'] as String, - json['longName'] as String, - json['active'] as bool, - json['building'] as String, -); - -Map _$GetRoomsResponseObjectToJson( - GetRoomsResponseObject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longName': instance.longName, - 'active': instance.active, - 'building': instance.building, -}; diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects.dart b/lib/api/webuntis/queries/get_subjects/get_subjects.dart deleted file mode 100644 index 736de80..0000000 --- a/lib/api/webuntis/queries/get_subjects/get_subjects.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import '../../webuntis_api.dart'; -import 'get_subjects_response.dart'; - -class GetSubjects extends WebuntisApi { - GetSubjects() : super('getSubjects', null); - - @override - Future run() async { - final rawAnswer = await query(this); - return finalize( - GetSubjectsResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } -} diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart deleted file mode 100644 index 0064607..0000000 --- a/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart +++ /dev/null @@ -1,14 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_subjects.dart'; -import 'get_subjects_response.dart'; - -class GetSubjectsCache extends SimpleCache { - GetSubjectsCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheHour, - loader: () => GetSubjects().run(), - fromJson: GetSubjectsResponse.fromJson, - ) { - start('wu-subjects'); - } -} diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart deleted file mode 100644 index 386933e..0000000 --- a/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_subjects_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetSubjectsResponse extends ApiResponse { - Set result; - - GetSubjectsResponse(this.result); - - factory GetSubjectsResponse.fromJson(Map json) => - _$GetSubjectsResponseFromJson(json); - Map toJson() => _$GetSubjectsResponseToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetSubjectsResponseObject { - int id; - String name; - String longName; - String alternateName; - bool active; - - GetSubjectsResponseObject( - this.id, - this.name, - this.longName, - this.alternateName, - this.active, - ); - - factory GetSubjectsResponseObject.fromJson(Map json) => - _$GetSubjectsResponseObjectFromJson(json); - Map toJson() => _$GetSubjectsResponseObjectToJson(this); -} diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart deleted file mode 100644 index 9ce84d5..0000000 --- a/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_subjects_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetSubjectsResponse _$GetSubjectsResponseFromJson(Map json) => - GetSubjectsResponse( - (json['result'] as List) - .map( - (e) => - GetSubjectsResponseObject.fromJson(e as Map), - ) - .toSet(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetSubjectsResponseToJson( - GetSubjectsResponse instance, -) => { - 'headers': ?instance.headers, - 'result': instance.result.map((e) => e.toJson()).toList(), -}; - -GetSubjectsResponseObject _$GetSubjectsResponseObjectFromJson( - Map json, -) => GetSubjectsResponseObject( - (json['id'] as num).toInt(), - json['name'] as String, - json['longName'] as String, - json['alternateName'] as String, - json['active'] as bool, -); - -Map _$GetSubjectsResponseObjectToJson( - GetSubjectsResponseObject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longName': instance.longName, - 'alternateName': instance.alternateName, - 'active': instance.active, -}; diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart deleted file mode 100644 index a872c5c..0000000 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import '../../webuntis_api.dart'; -import 'get_timegrid_units_response.dart'; - -class GetTimegridUnits extends WebuntisApi { - GetTimegridUnits() : super('getTimegridUnits', null); - - @override - Future run() async { - final rawAnswer = await query(this); - try { - return finalize( - GetTimegridUnitsResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } catch (e, trace) { - log(trace.toString()); - log( - 'Failed to parse getTimegridUnits data with server response: $rawAnswer', - ); - } - - throw Exception( - 'Failed to parse getTimegridUnits server response: $rawAnswer', - ); - } -} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart deleted file mode 100644 index 45ba202..0000000 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart +++ /dev/null @@ -1,14 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_timegrid_units.dart'; -import 'get_timegrid_units_response.dart'; - -class GetTimegridUnitsCache extends SimpleCache { - GetTimegridUnitsCache({super.onUpdate, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetTimegridUnits().run(), - fromJson: GetTimegridUnitsResponse.fromJson, - ) { - start('wu-timegrid'); - } -} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart deleted file mode 100644 index b2cfc43..0000000 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_timegrid_units_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetTimegridUnitsResponse extends ApiResponse { - List result; - - GetTimegridUnitsResponse(this.result); - - factory GetTimegridUnitsResponse.fromJson(Map json) => - _$GetTimegridUnitsResponseFromJson(json); - Map toJson() => _$GetTimegridUnitsResponseToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetTimegridUnitsResponseDay { - int day; - List timeUnits; - - GetTimegridUnitsResponseDay(this.day, this.timeUnits); - - factory GetTimegridUnitsResponseDay.fromJson(Map json) => - _$GetTimegridUnitsResponseDayFromJson(json); - Map toJson() => _$GetTimegridUnitsResponseDayToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetTimegridUnitsResponseUnit { - String name; - int startTime; - int endTime; - - GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime); - - factory GetTimegridUnitsResponseUnit.fromJson(Map json) => - _$GetTimegridUnitsResponseUnitFromJson(json); - Map toJson() => _$GetTimegridUnitsResponseUnitToJson(this); -} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart deleted file mode 100644 index 250b0fd..0000000 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart +++ /dev/null @@ -1,64 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_timegrid_units_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson( - Map json, -) => - GetTimegridUnitsResponse( - (json['result'] as List) - .map( - (e) => GetTimegridUnitsResponseDay.fromJson( - e as Map, - ), - ) - .toList(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetTimegridUnitsResponseToJson( - GetTimegridUnitsResponse instance, -) => { - 'headers': ?instance.headers, - 'result': instance.result.map((e) => e.toJson()).toList(), -}; - -GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson( - Map json, -) => GetTimegridUnitsResponseDay( - (json['day'] as num).toInt(), - (json['timeUnits'] as List) - .map( - (e) => GetTimegridUnitsResponseUnit.fromJson(e as Map), - ) - .toList(), -); - -Map _$GetTimegridUnitsResponseDayToJson( - GetTimegridUnitsResponseDay instance, -) => { - 'day': instance.day, - 'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(), -}; - -GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson( - Map json, -) => GetTimegridUnitsResponseUnit( - json['name'] as String, - (json['startTime'] as num).toInt(), - (json['endTime'] as num).toInt(), -); - -Map _$GetTimegridUnitsResponseUnitToJson( - GetTimegridUnitsResponseUnit instance, -) => { - 'name': instance.name, - 'startTime': instance.startTime, - 'endTime': instance.endTime, -}; diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable.dart b/lib/api/webuntis/queries/get_timetable/get_timetable.dart deleted file mode 100644 index 0c60f88..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import '../../webuntis_api.dart'; -import 'get_timetable_params.dart'; -import 'get_timetable_response.dart'; - -class GetTimetable extends WebuntisApi { - GetTimetableParams params; - - GetTimetable(this.params) : super('getTimetable', params); - - @override - Future run() async { - final rawAnswer = await query(this); - return finalize( - GetTimetableResponse.fromJson( - jsonDecode(rawAnswer) as Map, - ), - ); - } -} diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart deleted file mode 100644 index 440e1bb..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart +++ /dev/null @@ -1,43 +0,0 @@ -import '../../../request_cache.dart'; -import '../authenticate/authenticate.dart'; -import 'get_timetable.dart'; -import 'get_timetable_params.dart'; -import 'get_timetable_response.dart'; - -class GetTimetableCache extends SimpleCache { - GetTimetableCache({ - required void Function(GetTimetableResponse) onUpdate, - super.onError, - required int startdate, - required int enddate, - super.renew, - }) : super( - cacheTime: RequestCache.cacheMinute, - loader: () => _load(startdate, enddate), - fromJson: GetTimetableResponse.fromJson, - onUpdate: onUpdate, - ) { - start('wu-timetable-$startdate-$enddate'); - } - - static Future _load(int startdate, int enddate) async { - final session = await Authenticate.getSession(); - return GetTimetable( - GetTimetableParams( - options: GetTimetableParamsOptions( - element: GetTimetableParamsOptionsElement( - id: session.personId, - type: session.personType, - keyType: GetTimetableParamsOptionsElementKeyType.id, - ), - startDate: startdate, - endDate: enddate, - teacherFields: GetTimetableParamsOptionsFields.all, - subjectFields: GetTimetableParamsOptionsFields.all, - roomFields: GetTimetableParamsOptionsFields.all, - klasseFields: GetTimetableParamsOptionsFields.all, - ), - ), - ).run(); - } -} diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart deleted file mode 100644 index 727ba9f..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_params.dart'; - -part 'get_timetable_params.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetTimetableParams extends ApiParams { - GetTimetableParamsOptions options; - - GetTimetableParams({required this.options}); - - factory GetTimetableParams.fromJson(Map json) => - _$GetTimetableParamsFromJson(json); - Map toJson() => _$GetTimetableParamsToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetTimetableParamsOptions { - GetTimetableParamsOptionsElement element; - @JsonKey(includeIfNull: false) - int? startDate; - @JsonKey(includeIfNull: false) - int? endDate; - @JsonKey(includeIfNull: false) - bool? onlyBaseTimetable; - @JsonKey(includeIfNull: false) - bool? showBooking; - @JsonKey(includeIfNull: false) - bool? showInfo; - @JsonKey(includeIfNull: false) - bool? showSubstText; - @JsonKey(includeIfNull: false) - bool? showLsText; - @JsonKey(includeIfNull: false) - bool? showLsNumber; - @JsonKey(includeIfNull: false) - bool? showStudentgroup; - @JsonKey(includeIfNull: false) - List? klasseFields; - @JsonKey(includeIfNull: false) - List? roomFields; - @JsonKey(includeIfNull: false) - List? subjectFields; - @JsonKey(includeIfNull: false) - List? teacherFields; - - GetTimetableParamsOptions({ - required this.element, - this.startDate, - this.endDate, - this.onlyBaseTimetable, - this.showBooking, - this.showInfo, - this.showSubstText, - this.showLsText, - this.showLsNumber, - this.showStudentgroup, - this.klasseFields, - this.roomFields, - this.subjectFields, - this.teacherFields, - }); - - factory GetTimetableParamsOptions.fromJson(Map json) => - _$GetTimetableParamsOptionsFromJson(json); - Map toJson() => _$GetTimetableParamsOptionsToJson(this); -} - -enum GetTimetableParamsOptionsFields { - @JsonValue('id') - id, - @JsonValue('name') - name, - @JsonValue('longname') - longname, - @JsonValue('externalkey') - externalkey; - - static List all = [ - id, - name, - longname, - externalkey, - ]; -} - -@JsonSerializable() -class GetTimetableParamsOptionsElement { - int id; - int type; - @JsonKey(includeIfNull: false) - GetTimetableParamsOptionsElementKeyType? keyType; - - GetTimetableParamsOptionsElement({ - required this.id, - required this.type, - this.keyType, - }); - factory GetTimetableParamsOptionsElement.fromJson( - Map json, - ) => _$GetTimetableParamsOptionsElementFromJson(json); - Map toJson() => - _$GetTimetableParamsOptionsElementToJson(this); -} - -enum GetTimetableParamsOptionsElementKeyType { - @JsonValue('id') - id, - @JsonValue('name') - name, - @JsonValue('externalkey') - externalkey, -} diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart deleted file mode 100644 index 2d7ff35..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart +++ /dev/null @@ -1,106 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_timetable_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetTimetableParams _$GetTimetableParamsFromJson(Map json) => - GetTimetableParams( - options: GetTimetableParamsOptions.fromJson( - json['options'] as Map, - ), - ); - -Map _$GetTimetableParamsToJson(GetTimetableParams instance) => - {'options': instance.options.toJson()}; - -GetTimetableParamsOptions _$GetTimetableParamsOptionsFromJson( - Map json, -) => GetTimetableParamsOptions( - element: GetTimetableParamsOptionsElement.fromJson( - json['element'] as Map, - ), - startDate: (json['startDate'] as num?)?.toInt(), - endDate: (json['endDate'] as num?)?.toInt(), - onlyBaseTimetable: json['onlyBaseTimetable'] as bool?, - showBooking: json['showBooking'] as bool?, - showInfo: json['showInfo'] as bool?, - showSubstText: json['showSubstText'] as bool?, - showLsText: json['showLsText'] as bool?, - showLsNumber: json['showLsNumber'] as bool?, - showStudentgroup: json['showStudentgroup'] as bool?, - klasseFields: (json['klasseFields'] as List?) - ?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e)) - .toList(), - roomFields: (json['roomFields'] as List?) - ?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e)) - .toList(), - subjectFields: (json['subjectFields'] as List?) - ?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e)) - .toList(), - teacherFields: (json['teacherFields'] as List?) - ?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e)) - .toList(), -); - -Map _$GetTimetableParamsOptionsToJson( - GetTimetableParamsOptions instance, -) => { - 'element': instance.element.toJson(), - 'startDate': ?instance.startDate, - 'endDate': ?instance.endDate, - 'onlyBaseTimetable': ?instance.onlyBaseTimetable, - 'showBooking': ?instance.showBooking, - 'showInfo': ?instance.showInfo, - 'showSubstText': ?instance.showSubstText, - 'showLsText': ?instance.showLsText, - 'showLsNumber': ?instance.showLsNumber, - 'showStudentgroup': ?instance.showStudentgroup, - 'klasseFields': ?instance.klasseFields - ?.map((e) => _$GetTimetableParamsOptionsFieldsEnumMap[e]!) - .toList(), - 'roomFields': ?instance.roomFields - ?.map((e) => _$GetTimetableParamsOptionsFieldsEnumMap[e]!) - .toList(), - 'subjectFields': ?instance.subjectFields - ?.map((e) => _$GetTimetableParamsOptionsFieldsEnumMap[e]!) - .toList(), - 'teacherFields': ?instance.teacherFields - ?.map((e) => _$GetTimetableParamsOptionsFieldsEnumMap[e]!) - .toList(), -}; - -const _$GetTimetableParamsOptionsFieldsEnumMap = { - GetTimetableParamsOptionsFields.id: 'id', - GetTimetableParamsOptionsFields.name: 'name', - GetTimetableParamsOptionsFields.longname: 'longname', - GetTimetableParamsOptionsFields.externalkey: 'externalkey', -}; - -GetTimetableParamsOptionsElement _$GetTimetableParamsOptionsElementFromJson( - Map json, -) => GetTimetableParamsOptionsElement( - id: (json['id'] as num).toInt(), - type: (json['type'] as num).toInt(), - keyType: $enumDecodeNullable( - _$GetTimetableParamsOptionsElementKeyTypeEnumMap, - json['keyType'], - ), -); - -Map _$GetTimetableParamsOptionsElementToJson( - GetTimetableParamsOptionsElement instance, -) => { - 'id': instance.id, - 'type': instance.type, - 'keyType': - ?_$GetTimetableParamsOptionsElementKeyTypeEnumMap[instance.keyType], -}; - -const _$GetTimetableParamsOptionsElementKeyTypeEnumMap = { - GetTimetableParamsOptionsElementKeyType.id: 'id', - GetTimetableParamsOptionsElementKeyType.name: 'name', - GetTimetableParamsOptionsElementKeyType.externalkey: 'externalkey', -}; diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart deleted file mode 100644 index 76ff345..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_timetable_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetTimetableResponse extends ApiResponse { - Set result; - - GetTimetableResponse(this.result); - - factory GetTimetableResponse.fromJson(Map json) => - _$GetTimetableResponseFromJson(json); - Map toJson() => _$GetTimetableResponseToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetTimetableResponseObject { - int id; - int date; - int startTime; - int endTime; - String? lstype; - String? code; - String? info; - String? substText; - String? lstext; - int? lsnumber; - String? statflags; - String? activityType; - String? sg; - String? bkRemark; - String? bkText; - List kl; - List te; - List su; - List ro; - - GetTimetableResponseObject({ - required this.id, - required this.date, - required this.startTime, - required this.endTime, - this.lstype, - this.code, - this.info, - this.substText, - this.lstext, - this.lsnumber, - this.statflags, - this.activityType, - this.sg, - this.bkRemark, - required this.kl, - required this.te, - required this.su, - required this.ro, - }); - - factory GetTimetableResponseObject.fromJson(Map json) => - _$GetTimetableResponseObjectFromJson(json); - Map toJson() => _$GetTimetableResponseObjectToJson(this); -} - -@JsonSerializable(explicitToJson: true) -class GetTimetableResponseObjectFields { - List? te; - - GetTimetableResponseObjectFields(this.te); - - factory GetTimetableResponseObjectFields.fromJson( - Map json, - ) => _$GetTimetableResponseObjectFieldsFromJson(json); - Map toJson() => - _$GetTimetableResponseObjectFieldsToJson(this); -} - -@JsonSerializable() -class GetTimetableResponseObjectFieldsObject { - int? id; - String? name; - String? longname; - String? externalkey; - - GetTimetableResponseObjectFieldsObject({ - this.id, - this.name, - this.longname, - this.externalkey, - }); - - factory GetTimetableResponseObjectFieldsObject.fromJson( - Map json, - ) => _$GetTimetableResponseObjectFieldsObjectFromJson(json); - Map toJson() => - _$GetTimetableResponseObjectFieldsObjectToJson(this); -} - -@JsonSerializable() -class GetTimetableResponseObjectClass { - int id; - String name; - String longname; - String? externalkey; - - GetTimetableResponseObjectClass( - this.id, - this.name, - this.longname, - this.externalkey, - ); - - factory GetTimetableResponseObjectClass.fromJson(Map json) => - _$GetTimetableResponseObjectClassFromJson(json); - Map toJson() => - _$GetTimetableResponseObjectClassToJson(this); -} - -@JsonSerializable() -class GetTimetableResponseObjectTeacher { - int id; - String name; - String longname; - int? orgid; - String? orgname; - String? externalkey; - - GetTimetableResponseObjectTeacher( - this.id, - this.name, - this.longname, - this.orgid, - this.orgname, - this.externalkey, - ); - - factory GetTimetableResponseObjectTeacher.fromJson( - Map json, - ) => _$GetTimetableResponseObjectTeacherFromJson(json); - Map toJson() => - _$GetTimetableResponseObjectTeacherToJson(this); -} - -@JsonSerializable() -class GetTimetableResponseObjectSubject { - int id; - String name; - String longname; - - GetTimetableResponseObjectSubject(this.id, this.name, this.longname); - - factory GetTimetableResponseObjectSubject.fromJson( - Map json, - ) => _$GetTimetableResponseObjectSubjectFromJson(json); - Map toJson() => - _$GetTimetableResponseObjectSubjectToJson(this); -} - -@JsonSerializable() -class GetTimetableResponseObjectRoom { - int id; - String name; - String longname; - - GetTimetableResponseObjectRoom(this.id, this.name, this.longname); - - factory GetTimetableResponseObjectRoom.fromJson(Map json) => - _$GetTimetableResponseObjectRoomFromJson(json); - Map toJson() => _$GetTimetableResponseObjectRoomToJson(this); -} diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart deleted file mode 100644 index 9a3b20b..0000000 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart +++ /dev/null @@ -1,205 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_timetable_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetTimetableResponse _$GetTimetableResponseFromJson( - Map json, -) => - GetTimetableResponse( - (json['result'] as List) - .map( - (e) => GetTimetableResponseObject.fromJson( - e as Map, - ), - ) - .toSet(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetTimetableResponseToJson( - GetTimetableResponse instance, -) => { - 'headers': ?instance.headers, - 'result': instance.result.map((e) => e.toJson()).toList(), -}; - -GetTimetableResponseObject _$GetTimetableResponseObjectFromJson( - Map json, -) => GetTimetableResponseObject( - id: (json['id'] as num).toInt(), - date: (json['date'] as num).toInt(), - startTime: (json['startTime'] as num).toInt(), - endTime: (json['endTime'] as num).toInt(), - lstype: json['lstype'] as String?, - code: json['code'] as String?, - info: json['info'] as String?, - substText: json['substText'] as String?, - lstext: json['lstext'] as String?, - lsnumber: (json['lsnumber'] as num?)?.toInt(), - statflags: json['statflags'] as String?, - activityType: json['activityType'] as String?, - sg: json['sg'] as String?, - bkRemark: json['bkRemark'] as String?, - kl: (json['kl'] as List) - .map( - (e) => - GetTimetableResponseObjectClass.fromJson(e as Map), - ) - .toList(), - te: (json['te'] as List) - .map( - (e) => GetTimetableResponseObjectTeacher.fromJson( - e as Map, - ), - ) - .toList(), - su: (json['su'] as List) - .map( - (e) => GetTimetableResponseObjectSubject.fromJson( - e as Map, - ), - ) - .toList(), - ro: (json['ro'] as List) - .map( - (e) => - GetTimetableResponseObjectRoom.fromJson(e as Map), - ) - .toList(), -)..bkText = json['bkText'] as String?; - -Map _$GetTimetableResponseObjectToJson( - GetTimetableResponseObject instance, -) => { - 'id': instance.id, - 'date': instance.date, - 'startTime': instance.startTime, - 'endTime': instance.endTime, - 'lstype': instance.lstype, - 'code': instance.code, - 'info': instance.info, - 'substText': instance.substText, - 'lstext': instance.lstext, - 'lsnumber': instance.lsnumber, - 'statflags': instance.statflags, - 'activityType': instance.activityType, - 'sg': instance.sg, - 'bkRemark': instance.bkRemark, - 'bkText': instance.bkText, - 'kl': instance.kl.map((e) => e.toJson()).toList(), - 'te': instance.te.map((e) => e.toJson()).toList(), - 'su': instance.su.map((e) => e.toJson()).toList(), - 'ro': instance.ro.map((e) => e.toJson()).toList(), -}; - -GetTimetableResponseObjectFields _$GetTimetableResponseObjectFieldsFromJson( - Map json, -) => GetTimetableResponseObjectFields( - (json['te'] as List?) - ?.map( - (e) => GetTimetableResponseObjectFieldsObject.fromJson( - e as Map, - ), - ) - .toList(), -); - -Map _$GetTimetableResponseObjectFieldsToJson( - GetTimetableResponseObjectFields instance, -) => {'te': instance.te?.map((e) => e.toJson()).toList()}; - -GetTimetableResponseObjectFieldsObject -_$GetTimetableResponseObjectFieldsObjectFromJson(Map json) => - GetTimetableResponseObjectFieldsObject( - id: (json['id'] as num?)?.toInt(), - name: json['name'] as String?, - longname: json['longname'] as String?, - externalkey: json['externalkey'] as String?, - ); - -Map _$GetTimetableResponseObjectFieldsObjectToJson( - GetTimetableResponseObjectFieldsObject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longname': instance.longname, - 'externalkey': instance.externalkey, -}; - -GetTimetableResponseObjectClass _$GetTimetableResponseObjectClassFromJson( - Map json, -) => GetTimetableResponseObjectClass( - (json['id'] as num).toInt(), - json['name'] as String, - json['longname'] as String, - json['externalkey'] as String?, -); - -Map _$GetTimetableResponseObjectClassToJson( - GetTimetableResponseObjectClass instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longname': instance.longname, - 'externalkey': instance.externalkey, -}; - -GetTimetableResponseObjectTeacher _$GetTimetableResponseObjectTeacherFromJson( - Map json, -) => GetTimetableResponseObjectTeacher( - (json['id'] as num).toInt(), - json['name'] as String, - json['longname'] as String, - (json['orgid'] as num?)?.toInt(), - json['orgname'] as String?, - json['externalkey'] as String?, -); - -Map _$GetTimetableResponseObjectTeacherToJson( - GetTimetableResponseObjectTeacher instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longname': instance.longname, - 'orgid': instance.orgid, - 'orgname': instance.orgname, - 'externalkey': instance.externalkey, -}; - -GetTimetableResponseObjectSubject _$GetTimetableResponseObjectSubjectFromJson( - Map json, -) => GetTimetableResponseObjectSubject( - (json['id'] as num).toInt(), - json['name'] as String, - json['longname'] as String, -); - -Map _$GetTimetableResponseObjectSubjectToJson( - GetTimetableResponseObjectSubject instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longname': instance.longname, -}; - -GetTimetableResponseObjectRoom _$GetTimetableResponseObjectRoomFromJson( - Map json, -) => GetTimetableResponseObjectRoom( - (json['id'] as num).toInt(), - json['name'] as String, - json['longname'] as String, -); - -Map _$GetTimetableResponseObjectRoomToJson( - GetTimetableResponseObjectRoom instance, -) => { - 'id': instance.id, - 'name': instance.name, - 'longname': instance.longname, -}; diff --git a/lib/api/webuntis/services/lesson_resolver.dart b/lib/api/webuntis/services/lesson_resolver.dart deleted file mode 100644 index 5403001..0000000 --- a/lib/api/webuntis/services/lesson_resolver.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; - -import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../queries/get_rooms/get_rooms_response.dart'; -import '../queries/get_subjects/get_subjects_response.dart'; - -/// Resolves Webuntis IDs (subject, room) against the cached `TimetableState`. -/// When a record is missing the resolver returns a placeholder fallback -/// instead of `null` so call sites stay branch-free. -class LessonResolver { - static GetSubjectsResponseObject resolveSubject( - TimetableState state, - int? id, - ) { - final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - if (id == null) return fallback; - return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? - fallback; - } - - static GetRoomsResponseObject resolveRoom(TimetableState state, int? id) { - final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); - if (id == null) return fallback; - return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; - } -} - -/// Pure formatting/labelling helpers for Webuntis lessons (status code → -/// icon/label, "Name (Longname) · Extra" lines, subject prefix). No widgets, -/// safe to unit-test. -class LessonFormatter { - static IconData iconForCode(String? code) { - switch (code) { - case 'cancelled': - return Icons.event_busy_outlined; - case 'irregular': - return Icons.swap_horiz; - default: - return Icons.school_outlined; - } - } - - static String statusLabel(String? code) { - switch (code) { - case null: - case '': - return 'Regulär'; - case 'cancelled': - return 'Entfällt'; - case 'irregular': - return 'Geändert'; - default: - return code; - } - } - - static String codePrefix(String? code) { - if (code == 'cancelled') return 'Entfällt: '; - if (code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - /// Builds a single display line from the typical Webuntis triple of name, - /// optional longname (rendered in parentheses if it differs from `name`), - /// and optional extra info (joined with `·`). - static String formatLine(String name, {String? longname, String? extra}) { - final parts = [if (name.isNotEmpty) name else '?']; - final ln = (longname ?? '').trim(); - if (ln.isNotEmpty && ln != name) parts.add('($ln)'); - final ex = (extra ?? '').trim(); - if (ex.isNotEmpty) parts.add('· $ex'); - return parts.join(' '); - } -} diff --git a/lib/api/webuntis/webuntis_api.dart b/lib/api/webuntis/webuntis_api.dart deleted file mode 100644 index 93d7d87..0000000 --- a/lib/api/webuntis/webuntis_api.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; - -import '../../model/endpoint_data.dart'; -import '../api_params.dart'; -import '../api_request.dart'; -import '../api_response.dart'; -import '../errors/network_exception.dart'; -import '../errors/parse_exception.dart'; -import 'queries/authenticate/authenticate.dart'; -import 'webuntis_error.dart'; - -abstract class WebuntisApi extends ApiRequest { - Uri endpoint = Uri.parse( - 'https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda', - ); - String method; - ApiParams? genericParam; - http.Response? response; - - bool authenticatedResponse; - - WebuntisApi( - this.method, - this.genericParam, { - this.authenticatedResponse = true, - }); - - Future query(WebuntisApi untis, {bool retry = false}) async { - final body = - '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; - - var sessionId = '0'; - if (authenticatedResponse) { - sessionId = (await Authenticate.getSession()).sessionId; - } - final data = await post(body, {'Cookie': 'JSESSIONID=$sessionId'}); - response = data; - - final Map jsonData; - try { - jsonData = jsonDecode(data.body) as Map; - } on FormatException catch (e) { - throw ParseException( - technicalDetails: 'WebUntis JSON decode: ${e.message}', - ); - } - final error = jsonData['error'] as Map?; - if (error != null) { - final code = error['code'] as int; - if (code == -8520) { - if (retry) { - throw WebuntisError( - 'Authentication was tried (probably session timeout), but was not successful!', - -8520, - ); - } - await Authenticate.createSession(); - return query(untis, retry: true); - } else { - throw WebuntisError(error['message'] as String, code); - } - } - return data.body; - } - - T finalize(T response) { - response.rawResponse = this.response!; - return response; - } - - Future run(); - - String _body() => genericParam == null ? '{}' : jsonEncode(genericParam); - - Future post(String data, Map? headers) async { - try { - return await http - .post(endpoint, body: data, headers: headers) - .timeout( - const Duration(seconds: 10), - onTimeout: () => throw NetworkException.timeout( - technicalDetails: 'WebUntis $method timed out after 10s', - ), - ); - } on SocketException catch (e) { - throw NetworkException( - technicalDetails: 'WebUntis $method: ${e.message}', - ); - } on http.ClientException catch (e) { - throw NetworkException( - technicalDetails: 'WebUntis $method: ${e.message}', - ); - } - } -} diff --git a/lib/api/webuntis/webuntis_error.dart b/lib/api/webuntis/webuntis_error.dart deleted file mode 100644 index fadcc2e..0000000 --- a/lib/api/webuntis/webuntis_error.dart +++ /dev/null @@ -1,9 +0,0 @@ -class WebuntisError implements Exception { - String message; - int code; - - WebuntisError(this.message, this.code); - - @override - String toString() => 'WebUntis ($code): $message'; -} diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index e43f13c..02b8bfd 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -2,31 +2,30 @@ import 'dart:async'; import 'dart:developer'; import 'package:flutter/widgets.dart'; -import 'package:intl/intl.dart'; import 'package:workmanager/workmanager.dart'; +import '../api/marianumconnect/marianumconnect_endpoint.dart'; +import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; +import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; +import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; +import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; +import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; +import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../api/webuntis/queries/authenticate/authenticate.dart'; -import '../api/webuntis/queries/get_holidays/get_holidays.dart'; -import '../api/webuntis/queries/get_holidays/get_holidays_response.dart'; -import '../api/webuntis/queries/get_rooms/get_rooms.dart'; -import '../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../api/webuntis/queries/get_subjects/get_subjects.dart'; -import '../api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart'; -import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; -import '../api/webuntis/queries/get_timetable/get_timetable.dart'; -import '../api/webuntis/queries/get_timetable/get_timetable_params.dart'; import '../model/account_data.dart'; import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_sync.dart'; -/// Periodic widget refresh in a background Dart isolate. Native HTTP would -/// mean reimplementing WebUntis JSON-RPC (auth, session-timeout retry -8520, -/// payload quirks) twice — Dart isolate keeps that logic in one place. +/// Periodic widget refresh in a background Dart isolate. The Marianum-Connect +/// dio singleton + bearer interceptor handle login/refresh transparently — +/// we only need to pin the endpoint to whatever the user picked in the +/// in-app settings before issuing calls. class WidgetBackgroundTask { static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh'; static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once'; @@ -85,43 +84,39 @@ void _callbackDispatcher() { Future _refresh() async { await WidgetSync.ensureInitialized(); - await Authenticate.createSession(); + // The background isolate doesn't go through main.dart's BlocBuilder, so we + // re-apply the endpoint the foreground last persisted. Without this the + // dio singleton would fall back to its hardcoded live default even when + // the user picked beta/custom in the in-app settings. + final mcBaseUrl = await WidgetSync.getMarianumConnectBaseUrl(); + if (mcBaseUrl != null && mcBaseUrl.isNotEmpty) { + MarianumConnectEndpoint.update(mcBaseUrl); + } final now = WidgetPublisher.widgetNow(); - final dateFormat = DateFormat('yyyyMMdd'); // 14-day window so the week-widget rolls forward into next Monday's // lessons on Friday evening. final weekStart = _startOfWeek(now); final weekEndExclusive = weekStart.add(const Duration(days: 14)); - final session = await Authenticate.getSession(); - final timetable = await GetTimetable( - GetTimetableParams( - options: GetTimetableParamsOptions( - element: GetTimetableParamsOptionsElement( - id: session.personId, - type: session.personType, - keyType: GetTimetableParamsOptionsElementKeyType.id, - ), - startDate: int.parse(dateFormat.format(weekStart)), - endDate: int.parse( - dateFormat.format(weekEndExclusive.subtract(const Duration(days: 1))), - ), - teacherFields: GetTimetableParamsOptionsFields.all, - subjectFields: GetTimetableParamsOptionsFields.all, - roomFields: GetTimetableParamsOptionsFields.all, - klasseFields: GetTimetableParamsOptionsFields.all, - ), - ), - ).run(); + final timetable = await TimetableGetWeek().run( + from: weekStart, + until: weekEndExclusive.subtract(const Duration(days: 1)), + ); // Reference data — failures fall through to null in the mapper rather // than aborting the whole refresh. - final subjects = await _runOrNull(() => GetSubjects().run()); - final rooms = await _runOrNull(() => GetRooms().run()); - final holidays = await _runOrNull(() => GetHolidays().run()); - final timegrid = await _runOrNull( - () => GetTimegridUnits().run(), + final subjects = await _runOrNull( + () => TimetableGetSubjects().run(), + ); + final rooms = await _runOrNull( + () => TimetableGetRooms().run(), + ); + final holidays = await _runOrNull( + () => TimetableGetHolidays().run(), + ); + final timegrid = await _runOrNull( + () => TimetableGetTimegrid().run(), ); final customEvents = await _runOrNull( () => GetCustomTimetableEvent( @@ -129,7 +124,7 @@ Future _refresh() async { ).run(), ); - final lessons = timetable.result; + final lessons = timetable.entries; final connectDouble = await WidgetSync.getConnectDoubleLessons(); final dayData = WidgetDataMapper.buildDayData( diff --git a/lib/main.dart b/lib/main.dart index 8ea5ab0..483b2d2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,8 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import 'api/marianumconnect/auth/session_validator.dart'; +import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; @@ -183,18 +185,44 @@ class _MainState extends State
{ AccountData().waitForPopulation().then((value) { if (!mounted) return; - context.read().setStatus( + final accountBloc = context.read(); + accountBloc.setStatus( value ? AccountStatus.loggedIn : AccountStatus.loggedOut, ); + if (value) _scheduleSessionValidation(accountBloc); }); } + /// Fires a background credential check against Marianum Connect — runs in + /// the background so it never blocks the cold-start path. A 401 means the + /// password has been rotated server-side; the validator wipes the local + /// session and we flip the account bloc back to `loggedOut`, which sends + /// the user to the login screen. + void _scheduleSessionValidation(AccountBloc accountBloc) { + unawaited( + SessionValidator.probeStored( + onInvalidated: () async { + if (!mounted) return; + accountBloc.setStatus(AccountStatus.loggedOut); + }, + ), + ); + } + @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, child: BlocBuilder( builder: (context, settings) { final devToolsSettings = settings.devToolsSettings; + // Keep the MC dio singleton aligned with the currently selected + // endpoint (live / beta / custom). Idempotent when the URL is + // unchanged so it's safe to call on every rebuild. Mirrored into + // WidgetSync so the background isolate refreshes against the same + // endpoint. + final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl(); + MarianumConnectEndpoint.update(mcBaseUrl); + unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl)); return MaterialApp( showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, checkerboardOffscreenLayers: diff --git a/lib/model/endpoint_data.dart b/lib/model/endpoint_data.dart index 6bbe016..4e19214 100644 --- a/lib/model/endpoint_data.dart +++ b/lib/model/endpoint_data.dart @@ -37,14 +37,6 @@ class EndpointData { : EndpointMode.live; } - Endpoint webuntis() => EndpointOptions( - live: Endpoint(domain: 'marianum-fulda.webuntis.com'), - staged: Endpoint( - domain: 'mhsl.eu', - path: '/marianum/marianummobile/webuntis/public/index.php/api', - ), - ).get(getEndpointMode()); - Endpoint nextcloud() => EndpointOptions( live: Endpoint(domain: 'cloud.marianum-fulda.de'), staged: Endpoint(domain: 'mhsl.eu', path: '/marianum/marianummobile/cloud'), diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index bd53fed..d152404 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -2,9 +2,8 @@ import 'dart:developer'; import 'package:intl/intl.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; -import '../../../../../api/webuntis/webuntis_error.dart'; import '../../../../../extensions/date_time.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; @@ -46,12 +45,8 @@ class TimetableBloc } /// Persisted state may carry a stale `startDate`/`endDate` from the user's - /// last view as well as `accessibleStartDate`/`accessibleEndDate` learned - /// from `-7004 no allowed date` errors during scroll. Both must reset on - /// every cold start: otherwise the calendar can mount on a months-old week - /// (e.g. last December's Christmas holidays) or get permanently clamped - /// inside a window Webuntis once refused — even though the server would - /// happily serve the user's current week now. + /// last view. Reset on every cold start so the calendar always mounts on + /// the current week, not on whatever week the user closed the app on. @override TimetableState fromStorage(Map json) { final stored = TimetableState.fromJson(json); @@ -142,55 +137,12 @@ class TimetableBloc ); if (_lastWeekRequestStart.isAfter(requestStart)) return; _writeWeekToCache(startDate, week); - } on WebuntisError catch (e) { - if (e.code == _outOfRangeErrorCode) { - _narrowAccessibleRange(startDate, endDate); - // Out-of-range is expected when the user scrolls into territory - // Webuntis doesn't grant access to — surface to UI as a normal - // empty week instead of letting the loadable state escalate it - // into a red error screen. - return; - } - log( - 'Webuntis getWeek error: code=${e.code} message="${e.message}" ' - 'for $startDate–$endDate', - ); - onError?.call(e); } catch (e) { + log('getWeek error for $startDate–$endDate: $e'); onError?.call(e); } } - /// Webuntis returns this for weeks the user has no access to (typically - /// before the active enrolment / after a teacher's planning window). - static const int _outOfRangeErrorCode = -7004; - - /// Pulls the calendar's permitted scroll range inward based on a denied - /// week. We don't know the exact cutoff — only that *this* week is out - /// of reach — so we always pick the tighter of the existing bound and - /// the newly discovered one. Pre-now denials shrink the lower bound, - /// post-now denials the upper. - void _narrowAccessibleRange(DateTime startDate, DateTime endDate) { - final now = DateTime.now(); - final isPast = endDate.isBefore(now); - add( - Emit((s) { - if (isPast) { - final candidate = endDate.addDays(1); - final current = s.accessibleStartDate; - if (current != null && !candidate.isAfter(current)) return s; - return s.copyWith(accessibleStartDate: candidate); - } - // Treat anything not strictly past as a forward-direction denial, - // including the rare case where startDate == now. - final candidate = startDate.subtractDays(1); - final current = s.accessibleEndDate; - if (current != null && !candidate.isBefore(current)) return s; - return s.copyWith(accessibleEndDate: candidate); - }), - ); - } - Future _loadStaticReferenceData({ void Function(Object)? onError, bool renew = false, @@ -271,11 +223,11 @@ class TimetableBloc .catchError((_) {}); } - void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) { + void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { final key = _weekKeyFormat.format(weekStart); add( Emit((s) { - final updated = Map.of(s.weekCache); + final updated = Map.of(s.weekCache); updated[key] = week; return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); }), diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 1c180ae..a816318 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -1,12 +1,12 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart'; -import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; -import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; -import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; part 'timetable_state.freezed.dart'; part 'timetable_state.g.dart'; @@ -16,18 +16,18 @@ abstract class TimetableState with _$TimetableState { const TimetableState._(); const factory TimetableState({ - @Default({}) - Map weekCache, - GetRoomsResponse? rooms, - GetSubjectsResponse? subjects, - GetHolidaysResponse? schoolHolidays, - GetCurrentSchoolyearResponse? schoolyear, - GetTimegridUnitsResponse? timegrid, + @Default({}) + Map weekCache, + TimetableGetRoomsResponse? rooms, + TimetableGetSubjectsResponse? subjects, + TimetableGetHolidaysResponse? schoolHolidays, + TimetableGetSchoolyearResponse? schoolyear, + TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, required DateTime startDate, required DateTime endDate, @Default(0) int dataVersion, - // Boundaries learned from `-7004 no allowed date` errors during scroll. + // Boundaries learned from past server denials of inaccessible weeks. // Inclusive: weeks whose start is on/before `accessibleEndDate` and // whose end is on/after `accessibleStartDate` are within the user's // permitted range. Null = no upper / lower bound discovered yet. @@ -38,8 +38,8 @@ abstract class TimetableState with _$TimetableState { factory TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); - Iterable getAllKnownLessons() => - weekCache.values.expand((response) => response.result); + Iterable getAllKnownLessons() => + weekCache.values.expand((response) => response.entries); bool get hasReferenceData => rooms != null && diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart index 74f0af4..71005ed 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$TimetableState { - Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCurrentSchoolyearResponse? get schoolyear; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;// Boundaries learned from `-7004 no allowed date` errors during scroll. + Map get weekCache; TimetableGetRoomsResponse? get rooms; TimetableGetSubjectsResponse? get subjects; TimetableGetHolidaysResponse? get schoolHolidays; TimetableGetSchoolyearResponse? get schoolyear; TimetableGetTimegridResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;// Boundaries learned from past server denials of inaccessible weeks. // Inclusive: weeks whose start is on/before `accessibleEndDate` and // whose end is on/after `accessibleStartDate` are within the user's // permitted range. Null = no upper / lower bound discovered yet. @@ -52,7 +52,7 @@ abstract mixin class $TimetableStateCopyWith<$Res> { factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate + Map weekCache, TimetableGetRoomsResponse? rooms, TimetableGetSubjectsResponse? subjects, TimetableGetHolidaysResponse? schoolHolidays, TimetableGetSchoolyearResponse? schoolyear, TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate }); @@ -72,12 +72,12 @@ class _$TimetableStateCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? schoolyear = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,Object? accessibleStartDate = freezed,Object? accessibleEndDate = freezed,}) { return _then(_self.copyWith( weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable -as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable -as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable -as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable -as GetCurrentSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable -as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as TimetableGetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as TimetableGetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as TimetableGetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable +as TimetableGetSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as TimetableGetTimegridResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable @@ -168,7 +168,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, TimetableGetRoomsResponse? rooms, TimetableGetSubjectsResponse? subjects, TimetableGetHolidaysResponse? schoolHolidays, TimetableGetSchoolyearResponse? schoolyear, TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _TimetableState() when $default != null: return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: @@ -189,7 +189,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Map weekCache, TimetableGetRoomsResponse? rooms, TimetableGetSubjectsResponse? subjects, TimetableGetHolidaysResponse? schoolHolidays, TimetableGetSchoolyearResponse? schoolyear, TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate) $default,) {final _that = this; switch (_that) { case _TimetableState(): return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: @@ -209,7 +209,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, TimetableGetRoomsResponse? rooms, TimetableGetSubjectsResponse? subjects, TimetableGetHolidaysResponse? schoolHolidays, TimetableGetSchoolyearResponse? schoolyear, TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,) {final _that = this; switch (_that) { case _TimetableState() when $default != null: return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: @@ -224,26 +224,26 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, @JsonSerializable() class _TimetableState extends TimetableState { - const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.schoolyear, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0, this.accessibleStartDate, this.accessibleEndDate}): _weekCache = weekCache,super._(); + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.schoolyear, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0, this.accessibleStartDate, this.accessibleEndDate}): _weekCache = weekCache,super._(); factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); - final Map _weekCache; -@override@JsonKey() Map get weekCache { + final Map _weekCache; +@override@JsonKey() Map get weekCache { if (_weekCache is EqualUnmodifiableMapView) return _weekCache; // ignore: implicit_dynamic_type return EqualUnmodifiableMapView(_weekCache); } -@override final GetRoomsResponse? rooms; -@override final GetSubjectsResponse? subjects; -@override final GetHolidaysResponse? schoolHolidays; -@override final GetCurrentSchoolyearResponse? schoolyear; -@override final GetTimegridUnitsResponse? timegrid; +@override final TimetableGetRoomsResponse? rooms; +@override final TimetableGetSubjectsResponse? subjects; +@override final TimetableGetHolidaysResponse? schoolHolidays; +@override final TimetableGetSchoolyearResponse? schoolyear; +@override final TimetableGetTimegridResponse? timegrid; @override final GetCustomTimetableEventResponse? customEvents; @override final DateTime startDate; @override final DateTime endDate; @override@JsonKey() final int dataVersion; -// Boundaries learned from `-7004 no allowed date` errors during scroll. +// Boundaries learned from past server denials of inaccessible weeks. // Inclusive: weeks whose start is on/before `accessibleEndDate` and // whose end is on/after `accessibleStartDate` are within the user's // permitted range. Null = no upper / lower bound discovered yet. @@ -283,7 +283,7 @@ abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCo factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; @override @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate + Map weekCache, TimetableGetRoomsResponse? rooms, TimetableGetSubjectsResponse? subjects, TimetableGetHolidaysResponse? schoolHolidays, TimetableGetSchoolyearResponse? schoolyear, TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate }); @@ -303,12 +303,12 @@ class __$TimetableStateCopyWithImpl<$Res> @override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? schoolyear = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,Object? accessibleStartDate = freezed,Object? accessibleEndDate = freezed,}) { return _then(_TimetableState( weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable -as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable -as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable -as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable -as GetCurrentSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable -as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as TimetableGetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as TimetableGetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as TimetableGetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable +as TimetableGetSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as TimetableGetTimegridResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart index 0b60cae..a9daee3 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -12,31 +12,33 @@ _TimetableState _$TimetableStateFromJson(Map json) => (json['weekCache'] as Map?)?.map( (k, e) => MapEntry( k, - GetTimetableResponse.fromJson(e as Map), + TimetableGetWeekResponse.fromJson(e as Map), ), ) ?? - const {}, + const {}, rooms: json['rooms'] == null ? null - : GetRoomsResponse.fromJson(json['rooms'] as Map), + : TimetableGetRoomsResponse.fromJson( + json['rooms'] as Map, + ), subjects: json['subjects'] == null ? null - : GetSubjectsResponse.fromJson( + : TimetableGetSubjectsResponse.fromJson( json['subjects'] as Map, ), schoolHolidays: json['schoolHolidays'] == null ? null - : GetHolidaysResponse.fromJson( + : TimetableGetHolidaysResponse.fromJson( json['schoolHolidays'] as Map, ), schoolyear: json['schoolyear'] == null ? null - : GetCurrentSchoolyearResponse.fromJson( + : TimetableGetSchoolyearResponse.fromJson( json['schoolyear'] as Map, ), timegrid: json['timegrid'] == null ? null - : GetTimegridUnitsResponse.fromJson( + : TimetableGetTimegridResponse.fromJson( json['timegrid'] as Map, ), customEvents: json['customEvents'] == null diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index ce4fff7..425ee84 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -1,5 +1,15 @@ -import 'package:intl/intl.dart'; - +import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; @@ -11,89 +21,77 @@ import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_time import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; import '../../../../../api/request_cache.dart'; -import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart'; -import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart'; -import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; -import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; -import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; -import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../../../../../api/webuntis/queries/get_subjects/get_subjects_cache.dart'; -import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart'; -import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; -import '../../../../../api/webuntis/queries/get_timetable/get_timetable_cache.dart'; -import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../../model/account_data.dart'; +/// Pulls the timetable from the Marianum-Connect mobile API. Each MC endpoint +/// is its own HTTP call; this provider just exposes the lazy futures so the +/// bloc can chain them without seeing the dio layer. Custom events still come +/// from the MHSL backend and are unchanged. class TimetableDataProvider { - static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); - - Future getWeek( + Future getWeek( DateTime startDate, DateTime endDate, { void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => GetTimetableCache( - startdate: int.parse(_dateFormat.format(startDate)), - enddate: int.parse(_dateFormat.format(endDate)), - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getWeek', - ); + }) async { + try { + return await TimetableGetWeek().run(from: startDate, until: endDate); + } catch (e) { + onError?.call(e); + rethrow; + } + } - Future getRooms({ + Future getRooms({ void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => - GetRoomsCache(renew: renew, onUpdate: onUpdate, onError: onError), - onError: onError, - operationName: 'getRooms', - ); + }) async { + try { + return await TimetableGetRooms().run(); + } catch (e) { + onError?.call(e); + rethrow; + } + } - Future getSubjects({ + Future getSubjects({ void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => - GetSubjectsCache(renew: renew, onUpdate: onUpdate, onError: onError), - onError: onError, - operationName: 'getSubjects', - ); + }) async { + try { + return await TimetableGetSubjects().run(); + } catch (e) { + onError?.call(e); + rethrow; + } + } - Future getSchoolHolidays({ + Future getSchoolHolidays({ void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => - GetHolidaysCache(renew: renew, onUpdate: onUpdate, onError: onError), - onError: onError, - operationName: 'getSchoolHolidays', - ); + }) async { + try { + return await TimetableGetHolidays().run(); + } catch (e) { + onError?.call(e); + rethrow; + } + } - Future getCurrentSchoolyear({ + Future getCurrentSchoolyear({ void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => GetCurrentSchoolyearCache( - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getCurrentSchoolyear', - ); + }) async { + try { + return await TimetableGetSchoolyear().run(); + } catch (e) { + onError?.call(e); + rethrow; + } + } - Future getTimegrid({bool renew = false}) => - resolveFromCache( - (onUpdate, _) => - GetTimegridUnitsCache(renew: renew, onUpdate: onUpdate), - operationName: 'getTimegrid', - ); + Future getTimegrid({bool renew = false}) => + TimetableGetTimegrid().run(); Future getCustomEvents({ bool renew = false, diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index d89ffe2..02f59da 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -1,19 +1,84 @@ +import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; part 'dev_tools_settings.g.dart'; +enum MarianumConnectEndpoint { live, beta, custom } + @JsonSerializable() class DevToolsSettings { bool showPerformanceOverlay; bool checkerboardOffscreenLayers; bool checkerboardRasterCacheImages; + @JsonKey(defaultValue: MarianumConnectEndpoint.live) + MarianumConnectEndpoint marianumConnectEndpoint; + + @JsonKey(defaultValue: '') + String marianumConnectCustomUrl; + DevToolsSettings({ required this.showPerformanceOverlay, required this.checkerboardOffscreenLayers, required this.checkerboardRasterCacheImages, + this.marianumConnectEndpoint = MarianumConnectEndpoint.live, + this.marianumConnectCustomUrl = '', }); + // Resolves the effective base URL for the Marianum-Connect mobile API. + // Falls back to live when the custom URL is empty or malformed. HTTP is + // accepted alongside HTTPS only in debug/profile builds (developers can + // point at `http://10.0.2.2:8080` without configuring TLS locally); release + // builds restrict the custom endpoint to HTTPS so a leaked debug URL never + // ships an unencrypted bearer token over the wire. + String resolveMarianumConnectBaseUrl() { + switch (marianumConnectEndpoint) { + case MarianumConnectEndpoint.live: + return liveUrl; + case MarianumConnectEndpoint.beta: + return betaUrl; + case MarianumConnectEndpoint.custom: + final sanitized = sanitizeCustomUrl(marianumConnectCustomUrl); + return sanitized ?? liveUrl; + } + } + + static const String liveUrl = 'https://connect.marianum-fulda.de'; + static const String betaUrl = 'https://connect-beta.marianum-fulda.de'; + + /// `true` in builds where plaintext HTTP custom endpoints are still allowed + /// (debug, profile). Release builds keep this `false` and the picker + /// rejects `http://` entirely. + static bool get allowsHttpCustomEndpoint => !kReleaseMode; + + /// Returns the trimmed URL without a trailing slash, or null when the input + /// is not a usable HTTP/HTTPS URL. HTTP is only accepted when + /// [allowsHttpCustomEndpoint] is `true` — i.e. outside release builds. + static String? sanitizeCustomUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return null; + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.hasScheme) return null; + if (uri.scheme == 'https') { + // always fine + } else if (uri.scheme == 'http' && allowsHttpCustomEndpoint) { + // dev only + } else { + return null; + } + if (uri.host.isEmpty) return null; + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } + + /// `true` when the configured custom URL is plain HTTP — the picker shows + /// this as a warning so developers don't accidentally ship a debug URL. + bool get marianumConnectCustomUrlIsInsecure { + final sanitized = sanitizeCustomUrl(marianumConnectCustomUrl); + return sanitized != null && Uri.parse(sanitized).scheme == 'http'; + } + factory DevToolsSettings.fromJson(Map json) => _$DevToolsSettingsFromJson(json); Map toJson() => _$DevToolsSettingsToJson(this); diff --git a/lib/storage/dev_tools_settings.g.dart b/lib/storage/dev_tools_settings.g.dart index a1cc6de..5b98f13 100644 --- a/lib/storage/dev_tools_settings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -6,17 +6,33 @@ part of 'dev_tools_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -DevToolsSettings _$DevToolsSettingsFromJson(Map json) => - DevToolsSettings( - showPerformanceOverlay: json['showPerformanceOverlay'] as bool, - checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool, - checkerboardRasterCacheImages: - json['checkerboardRasterCacheImages'] as bool, - ); +DevToolsSettings _$DevToolsSettingsFromJson( + Map json, +) => DevToolsSettings( + showPerformanceOverlay: json['showPerformanceOverlay'] as bool, + checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool, + checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool, + marianumConnectEndpoint: + $enumDecodeNullable( + _$MarianumConnectEndpointEnumMap, + json['marianumConnectEndpoint'], + ) ?? + MarianumConnectEndpoint.live, + marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '', +); Map _$DevToolsSettingsToJson(DevToolsSettings instance) => { 'showPerformanceOverlay': instance.showPerformanceOverlay, 'checkerboardOffscreenLayers': instance.checkerboardOffscreenLayers, 'checkerboardRasterCacheImages': instance.checkerboardRasterCacheImages, + 'marianumConnectEndpoint': + _$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!, + 'marianumConnectCustomUrl': instance.marianumConnectCustomUrl, }; + +const _$MarianumConnectEndpointEnumMap = { + MarianumConnectEndpoint.live: 'live', + MarianumConnectEndpoint.beta: 'beta', + MarianumConnectEndpoint.custom: 'custom', +}; diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 80770b2..85d1f4c 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -6,7 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../background/widget_background_task.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../storage/dev_tools_settings.dart'; +import '../../storage/settings.dart' as model; import '../../theming/light_app_theme.dart'; +import '../pages/settings/widgets/endpoint_picker.dart'; import 'login_controller.dart'; import 'widgets/login_branding.dart'; import 'widgets/login_card.dart'; @@ -57,21 +61,33 @@ class _LoginState extends State { minHeight: constraints.maxHeight, maxWidth: 420, ), - child: IntrinsicHeight( - child: Column( - children: [ - const LoginHeader(), - const SizedBox(height: 28), - LoginCard( - controller: _controller, - onSuccess: _onLoginSuccess, - ), - const SizedBox(height: 18), - const LoginDisclaimer(), - const Spacer(), - const LoginFooter(), - ], - ), + // spaceBetween statt Spacer-in-IntrinsicHeight: bei jeder + // Inhaltsänderung im unteren Block (z.B. EndpointLink mit + // dynamischem Label) würde IntrinsicHeight sonst die Column + // an die intrinsic-Höhe pinnen und ein paar Pixel Overflow + // produzieren. spaceBetween fügt nur den verbleibenden Gap + // ein und schrumpft sauber auf 0, wenn der Inhalt zu hoch + // wird — dann übernimmt der äußere ScrollView. + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + const LoginHeader(), + const SizedBox(height: 28), + LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, + ), + const SizedBox(height: 18), + const LoginDisclaimer(), + ], + ), + const Column( + mainAxisSize: MainAxisSize.min, + children: [_EndpointLink(), LoginFooter()], + ), + ], ), ), ), @@ -80,3 +96,59 @@ class _LoginState extends State { ), ); } + +/// Subtle text link above the footer that surfaces the currently selected +/// Marianum-Connect endpoint and opens the picker on tap. Always visible so +/// devs can switch the endpoint before the first login without hunting for a +/// long-press easter egg, but understated enough not to draw regular users +/// into the dev menu. +class _EndpointLink extends StatelessWidget { + const _EndpointLink(); + + @override + Widget build(BuildContext context) => + BlocBuilder( + builder: (context, settings) { + final dev = settings.devToolsSettings; + final label = _label(dev); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white.withValues(alpha: 0.85), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + ), + ), + onPressed: () => MarianumConnectEndpointPicker.show( + context, + context.read(), + ), + child: Text('Server: $label'), + ), + ); + }, + ); + + static String _label(DevToolsSettings dev) { + switch (dev.marianumConnectEndpoint) { + case MarianumConnectEndpoint.live: + return 'Normal'; + case MarianumConnectEndpoint.beta: + return 'Beta'; + case MarianumConnectEndpoint.custom: + final url = DevToolsSettings.sanitizeCustomUrl( + dev.marianumConnectCustomUrl, + ); + return url ?? 'Eigener Server (ungültig)'; + } + } +} diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 9669522..524f572 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -4,8 +4,9 @@ import 'package:flutter/foundation.dart'; import '../../api/errors/auth_exception.dart'; import '../../api/errors/error_mapper.dart'; -import '../../api/marianumcloud/talk/room/get_room.dart'; -import '../../api/marianumcloud/talk/room/get_room_params.dart'; +import '../../api/marianumconnect/auth/device_token_name.dart'; +import '../../api/marianumconnect/auth/token_storage.dart'; +import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../model/account_data.dart'; import '../../widget_data/widget_sync.dart'; @@ -32,22 +33,28 @@ class LoginController extends ChangeNotifier { final user = username.trim().toLowerCase(); try { await AccountData().removeData(); - // Drop any cached widget snapshot from a previous account before the - // new credentials populate it — otherwise a re-login with a different - // user briefly shows the previous owner's timetable on the home screen. + // Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes + // Account hätte sonst noch einen aktiven Token in api_tokens. + await const MarianumConnectTokenStorage().clear(); + // Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der + // Stundenplan des vorigen Users auf dem Home-Bildschirm. await WidgetSync.clear(); await WidgetSync.triggerUpdate(); + // AuthLogin = Credential-Probe + Token-Create in einem Call. + // 401 hier heißt: falsches Passwort. + await AuthLogin().run( + username: user, + password: password, + tokenName: await DeviceTokenName.resolve(), + ); await AccountData().setData(user, password); - await GetRoom(GetRoomParams(includeStatus: false)).run(); _loading = false; notifyListeners(); return true; } catch (e) { log(e.toString()); await AccountData().removeData(); - // 401 from the probe means the credentials were wrong; everything else - // (no network, server down, TLS errors, …) gets the generic mapped - // message so the user knows it isn't their typo. + await const MarianumConnectTokenStorage().clear(); final isWrongCredentials = e is AuthException && e.statusCode == 401; _errorMessage = isWrongCredentials ? 'Benutzername oder Passwort falsch.' diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 84d888b..5191e47 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -62,6 +62,8 @@ class DefaultSettings { checkerboardOffscreenLayers: false, checkerboardRasterCacheImages: false, showPerformanceOverlay: false, + marianumConnectEndpoint: MarianumConnectEndpoint.live, + marianumConnectCustomUrl: '', ), ); } diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index f7b37ea..69c4cdf 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../../../model/account_data.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../../state/app/modules/account/bloc/account_state.dart'; @@ -31,10 +32,18 @@ class AccountSection extends StatelessWidget { title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - onConfirmAsync: AccountData().removeData, + onConfirmAsync: _performLogout, ), ); if (confirmed != true || !context.mounted) return; context.read().setStatus(AccountStatus.loggedOut); } + + // Best-effort revoke of the MC bearer token before we wipe local credentials. + // The token storage itself is cleared inside AuthLogout regardless of network + // success, so an offline logout still gets us into a clean local state. + Future _performLogout() async { + await AuthLogout().run(); + await AccountData().removeData(); + } } diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index f8978ab..df7fab7 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -11,6 +11,7 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/json_viewer.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../widgets/endpoint_picker.dart'; class DevToolsSection extends StatefulWidget { final SettingsCubit settings; @@ -88,6 +89,18 @@ class _DevToolsSectionState extends State { ); }, ), + ListTile( + leading: const CenteredLeading(Icon(Icons.cloud_outlined)), + title: const Text('Marianum-Connect-Server'), + subtitle: Text( + MarianumConnectEndpointPicker.labelFor( + widget.settings.val().devToolsSettings, + ), + ), + trailing: const Icon(Icons.arrow_right), + onTap: () => + MarianumConnectEndpointPicker.show(context, widget.settings), + ), ListTile( leading: const CenteredLeading(Icon(Icons.image_outlined)), title: const Text('Thumb-storage'), diff --git a/lib/view/pages/settings/widgets/endpoint_picker.dart b/lib/view/pages/settings/widgets/endpoint_picker.dart new file mode 100644 index 0000000..11b6ead --- /dev/null +++ b/lib/view/pages/settings/widgets/endpoint_picker.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumconnect/auth/token_storage.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../storage/dev_tools_settings.dart'; +import '../../../../storage/settings.dart' as model; +import '../../../../widget/details_bottom_sheet.dart'; + +/// Bottom-sheet that lets the user switch the active Marianum-Connect +/// endpoint (live / beta / custom). Shared between the dev-tools section and +/// the login screen so both places offer the exact same picker. +/// +/// On commit the change lands in the SettingsCubit (which in turn updates +/// the dio singleton via the BlocBuilder in `main.dart`) and the currently +/// stored bearer token is cleared — that token belongs to the old host and +/// would be rejected by the new one. +class MarianumConnectEndpointPicker { + const MarianumConnectEndpointPicker._(); + + static void show(BuildContext context, SettingsCubit settings) { + showDetailsBottomSheet( + context, + header: const ListTile(title: Text('Marianum-Connect-Server')), + children: (sheetCtx) => [ + BlocBuilder( + bloc: settings, + builder: (_, _) { + final dev = settings.val().devToolsSettings; + return _PickerBody( + current: dev.marianumConnectEndpoint, + customUrl: dev.marianumConnectCustomUrl, + onChanged: (next, custom) async { + final mutable = settings.val(write: true).devToolsSettings; + mutable.marianumConnectEndpoint = next; + if (custom != null) mutable.marianumConnectCustomUrl = custom; + await const MarianumConnectTokenStorage().clear(); + }, + ); + }, + ), + ], + ); + } + + /// Short human-readable label of the currently selected endpoint — used by + /// the settings list tile and the login screen hint. + static String labelFor(DevToolsSettings dev) { + switch (dev.marianumConnectEndpoint) { + case MarianumConnectEndpoint.live: + return 'Normal (${DevToolsSettings.liveUrl})'; + case MarianumConnectEndpoint.beta: + return 'Beta (${DevToolsSettings.betaUrl})'; + case MarianumConnectEndpoint.custom: + final url = DevToolsSettings.sanitizeCustomUrl( + dev.marianumConnectCustomUrl, + ); + return url == null + ? 'Eigener Server (ungültig – Normal wird verwendet)' + : 'Eigener Server ($url)'; + } + } +} + +class _PickerBody extends StatefulWidget { + final MarianumConnectEndpoint current; + final String customUrl; + final Future Function(MarianumConnectEndpoint next, String? customUrl) + onChanged; + + const _PickerBody({ + required this.current, + required this.customUrl, + required this.onChanged, + }); + + @override + State<_PickerBody> createState() => _PickerBodyState(); +} + +class _PickerBodyState extends State<_PickerBody> { + late MarianumConnectEndpoint _selected; + late TextEditingController _customController; + String? _customError; + + @override + void initState() { + super.initState(); + _selected = widget.current; + _customController = TextEditingController(text: widget.customUrl); + } + + @override + void dispose() { + _customController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final allowsHttp = DevToolsSettings.allowsHttpCustomEndpoint; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RadioGroup( + groupValue: _selected, + onChanged: _selectEndpoint, + child: Column( + children: [ + const RadioListTile( + title: Text('Normal'), + subtitle: Text(DevToolsSettings.liveUrl), + value: MarianumConnectEndpoint.live, + ), + const RadioListTile( + title: Text('Beta'), + subtitle: Text(DevToolsSettings.betaUrl), + value: MarianumConnectEndpoint.beta, + ), + RadioListTile( + title: const Text('Eigener Server'), + subtitle: Text( + allowsHttp + ? 'HTTP oder HTTPS, ohne abschließenden Slash. ' + 'HTTP nur für lokale Entwicklung.' + : 'Nur HTTPS-URLs, ohne abschließenden Slash.', + ), + value: MarianumConnectEndpoint.custom, + ), + ], + ), + ), + if (_selected == MarianumConnectEndpoint.custom) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _customController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: allowsHttp ? 'http(s)://...' : 'https://...', + errorText: _customError, + ), + onChanged: (_) => setState(() => _customError = null), + ), + if (allowsHttp && _isHttpUrl(_customController.text)) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '⚠ HTTP überträgt den Bearer-Token unverschlüsselt. ' + 'Nur für lokale Entwicklung benutzen.', + style: TextStyle(fontSize: 12, color: Colors.orange), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + onPressed: _confirm, + child: const Text('Übernehmen'), + ), + ), + ], + ); + } + + void _selectEndpoint(MarianumConnectEndpoint? value) { + if (value == null) return; + setState(() { + _selected = value; + _customError = null; + }); + } + + Future _confirm() async { + if (_selected == MarianumConnectEndpoint.custom) { + final sanitized = DevToolsSettings.sanitizeCustomUrl( + _customController.text, + ); + if (sanitized == null) { + setState( + () => _customError = DevToolsSettings.allowsHttpCustomEndpoint + ? 'Ungültige URL (http(s)://host[:port])' + : 'Ungültige URL — nur HTTPS erlaubt', + ); + return; + } + await widget.onChanged(_selected, sanitized); + } else { + await widget.onChanged(_selected, null); + } + if (!mounted) return; + Navigator.of(context).pop(); + } + + static bool _isHttpUrl(String value) { + final trimmed = value.trim(); + final uri = Uri.tryParse(trimmed); + return uri != null && uri.hasScheme && uri.scheme == 'http'; + } +} diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart index 1f8dd82..9c7797b 100644 --- a/lib/view/pages/timetable/data/arbitrary_appointment.dart +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -1,21 +1,21 @@ +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; sealed class ArbitraryAppointment { const ArbitraryAppointment(); T when({ - required T Function(GetTimetableResponseObject lesson) webuntis, + required T Function(McTimetableEntry lesson) lesson, required T Function(CustomTimetableEvent event) custom, }) => switch (this) { - WebuntisAppointment(:final lesson) => webuntis(lesson), + LessonAppointment(:final entry) => lesson(entry), CustomAppointment(:final event) => custom(event), }; } -class WebuntisAppointment extends ArbitraryAppointment { - final GetTimetableResponseObject lesson; - const WebuntisAppointment(this.lesson); +class LessonAppointment extends ArbitraryAppointment { + final McTimetableEntry entry; + const LessonAppointment(this.entry); } class CustomAppointment extends ArbitraryAppointment { diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index e0a4a03..56def74 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -282,7 +282,7 @@ class LaidOutOverflow extends LaidOutCell { int _appointmentPriority(Appointment a) { final id = a.id; if (id is CustomAppointment) return 0; - if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; + if (id is LessonAppointment && id.entry.status == 'CANCELLED') return 1; return 2; } diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart index 03cda8f..425943e 100644 --- a/lib/view/pages/timetable/data/lesson_color.dart +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -9,6 +9,10 @@ class LessonColor { static const Color irregular = Color(0xff8F19B3); static const Color teacherChanged = Color(0xFF29639B); static const Color event = Color(0xff2E7D32); + // Petrol-Türkis für Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) — + // hebt sie deutlich von regulärem Unterricht (Marianum-Rot) und Events + // (Grün) ab, ohne den Status-übergreifenden Farb-Code zu verwässern. + static const Color duty = Color(0xff00796B); static const Color parseFallback = Color(0xff404040); static Color forStatus(LessonStatus status) { @@ -21,6 +25,8 @@ class LessonColor { return irregular; case LessonStatus.teacherChanged: return teacherChanged; + case LessonStatus.duty: + return duty; case LessonStatus.past: case LessonStatus.regular: return regular; diff --git a/lib/view/pages/timetable/data/lesson_merger.dart b/lib/view/pages/timetable/data/lesson_merger.dart new file mode 100644 index 0000000..0441713 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_merger.dart @@ -0,0 +1,72 @@ +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; + +/// Combines back-to-back lessons with identical subject/room/teacher/status +/// into a single visual block. Shared by the calendar tile builder and the +/// home-widget data mapper so both surfaces show the same merged spans. +/// +/// Built as a new list rather than mutating inputs — earlier in-place merges +/// extended merged blocks further on every rebuild when the same lesson +/// objects were observed again. +class LessonMerger { + const LessonMerger._(); + + static const Duration defaultMaxGap = Duration(minutes: 5); + + static List merge( + List input, { + Duration maxGap = defaultMaxGap, + }) { + if (input.isEmpty) return const []; + + final sorted = [...input] + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + + final merged = []; + for (final current in sorted) { + if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { + final prev = merged.removeLast(); + merged.add(_extendedEnd(prev, current.endTime)); + } else { + merged.add(current); + } + } + return merged; + } + + static bool _canMerge( + McTimetableEntry a, + McTimetableEntry b, + Duration maxGap, + ) { + if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false; + if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false; + if (a.teachers.firstOrNull?.shortName != + b.teachers.firstOrNull?.shortName) { + return false; + } + if (a.status != b.status) return false; + // Lower bound on the gap — without it, two identical-metadata lessons that + // overlap in time would silently collapse into one. + final gap = b.startDateTime.difference(a.endDateTime); + return !gap.isNegative && gap <= maxGap; + } + + static McTimetableEntry _extendedEnd( + McTimetableEntry source, + DateTime newEndTime, + ) => McTimetableEntry( + id: source.id, + date: source.date, + startTime: source.startTime, + endTime: newEndTime, + subjects: source.subjects, + teachers: source.teachers, + rooms: source.rooms, + classNames: source.classNames, + lessonType: source.lessonType, + status: source.status, + substitutionText: source.substitutionText, + lessonText: source.lessonText, + infoText: source.infoText, + ); +} diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart index dfd9b5a..13e0656 100644 --- a/lib/view/pages/timetable/data/lesson_period_schedule.dart +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; class LessonPeriod { @@ -28,22 +28,30 @@ class LessonPeriodSchedule { const LessonPeriodSchedule(this.periods); - static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) { - final canonical = response.result.firstWhere( - (d) => d.day == 1, - orElse: () => response.result.isNotEmpty - ? response.result.first - : GetTimegridUnitsResponseDay(0, []), - ); - if (canonical.timeUnits.isEmpty) return null; + static LessonPeriodSchedule? fromApi(TimetableGetTimegridResponse response) { + // The Marianum-Connect endpoint returns one entry per (weekday, unit). The + // school's bell schedule is identical Mon–Fri, so we pick Monday as the + // canonical day and fall back to the first available weekday if Monday is + // missing. + final monday = response.result + .where((u) => u.dayOfWeek == McDayOfWeek.monday) + .toList(); + final source = monday.isNotEmpty ? monday : response.result; + if (source.isEmpty) return null; final periods = - canonical.timeUnits + source .map( (u) => LessonPeriod( - name: u.name, - start: _fromHHMM(u.startTime), - end: _fromHHMM(u.endTime), + name: u.label, + start: TimeOfDay( + hour: u.startTime.hour, + minute: u.startTime.minute, + ), + end: TimeOfDay( + hour: u.endTime.hour, + minute: u.endTime.minute, + ), ), ) .toList() @@ -144,7 +152,4 @@ class LessonPeriodSchedule { } return LessonPeriodSchedule(result); } - - static TimeOfDay _fromHHMM(int hhmm) => - TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100); } diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 6f63937..9d78ec1 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -1,32 +1,40 @@ -import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; enum LessonStatus { cancelled, event, irregular, teacherChanged, + duty, past, ongoing, regular, } class LessonStatusClassifier { + /// Mirrors the legacy Webuntis classifier: cancelled trumps everything, + /// then event (subject-less lessons such as Wandertag), then irregular + /// (status from the backend or a slot without an assigned teacher), then + /// teacherChanged when the backend reports a substitution swap, then + /// duty (Aufsicht/Sprechstunde/…) so they stand out from regular + /// classroom lessons, then the time-based past/ongoing/regular states. static LessonStatus classify( - GetTimetableResponseObject lesson, + McTimetableEntry entry, DateTime startTime, DateTime endTime, DateTime now, { bool isEvent = false, + bool isDuty = false, }) { - if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (entry.status == 'CANCELLED') return LessonStatus.cancelled; if (isEvent) return LessonStatus.event; - if (lesson.code == 'irregular' || - (lesson.te.isNotEmpty && lesson.te.first.id == 0)) { + if (entry.status == 'IRREGULAR' || entry.teachers.isEmpty) { return LessonStatus.irregular; } - if (lesson.te.any((t) => t.orgname != null)) { + if (entry.teachers.any((t) => (t.originalShortName ?? '').isNotEmpty)) { return LessonStatus.teacherChanged; } + if (isDuty) return LessonStatus.duty; if (endTime.isBefore(now)) return LessonStatus.past; if (startTime.isBefore(now) && endTime.isAfter(now)) { return LessonStatus.ongoing; diff --git a/lib/view/pages/timetable/data/lesson_type_label.dart b/lib/view/pages/timetable/data/lesson_type_label.dart new file mode 100644 index 0000000..e5aa2c2 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_type_label.dart @@ -0,0 +1,33 @@ +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; + +/// Derives a human-readable title from the Marianum-Connect `lessonType` for +/// lessons that have no subject of their own (Aufsicht, Sprechstunde, …). +/// Shared between the calendar tile and the lesson detail sheet so both +/// surfaces show the same wording, with the room rendered first so it +/// survives a truncated tile. +class LessonTypeLabel { + static const String fallback = 'Event'; + + static String forEntry(McTimetableEntry lesson) { + final base = baseLabel(lesson.lessonType); + final room = (lesson.rooms.firstOrNull ?? '').trim(); + return room.isEmpty ? base : '$room $base'; + } + + /// Returns just the type wording without the room — useful when the caller + /// renders the room separately (e.g. as a subtitle line). + static String baseLabel(String lessonType) { + switch (lessonType) { + case 'BREAK_SUPERVISION': + return 'Aufsicht'; + case 'OFFICE_HOUR': + return 'Sprechstunde'; + case 'STANDBY': + return 'Bereitschaft'; + case 'EXAM': + return 'Prüfung'; + default: + return fallback; + } + } +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 9847415..744d1aa 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,31 +1,27 @@ -import 'package:collection/collection.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../storage/timetable_settings.dart'; import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; import 'lesson_status.dart'; +import 'lesson_type_label.dart'; import 'rrule_with_exceptions.dart'; import 'timetable_name_mode.dart'; -import 'webuntis_time.dart'; class TimetableAppointmentFactory { - final List lessons; + final List lessons; final List customEvents; - final GetRoomsResponse rooms; - final GetSubjectsResponse subjects; + final List subjects; final TimetableSettings settings; final DateTime now; TimetableAppointmentFactory({ required this.lessons, required this.customEvents, - required this.rooms, required this.subjects, required this.settings, required this.now, @@ -41,37 +37,40 @@ class TimetableAppointmentFactory { ]; } - Appointment _lessonToAppointment(GetTimetableResponseObject lesson) { + Appointment _lessonToAppointment(McTimetableEntry lesson) { try { - final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); - final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); - final subject = subjects.result.firstWhereOrNull( - (s) => s.id == lesson.su.firstOrNull?.id, - ); + final startTime = lesson.startDateTime; + final endTime = lesson.endDateTime; + final subjectShortName = lesson.subjects.firstOrNull; + // "Event"-Status nur, wenn auch kein bekannter Sonder-Lesson-Type vorliegt + // — Aufsicht/Sprechstunde/etc. werden sonst grün statt eigenständig + // eingefärbt. + final isEvent = subjectShortName == null && lesson.lessonType == 'LESSON'; + final isDuty = lesson.lessonType != 'LESSON'; final status = LessonStatusClassifier.classify( lesson, startTime, endTime, now, - isEvent: subject == null, + isEvent: isEvent, + isDuty: isDuty, ); return Appointment( - id: WebuntisAppointment(lesson), + id: LessonAppointment(lesson), startTime: startTime, endTime: endTime, - subject: _subjectName(lesson, subject), + subject: _subjectName(subjectShortName, lesson), location: _locationLabel(lesson), - notes: lesson.activityType, color: LessonColor.forStatus(status), ); } catch (_) { return Appointment( - id: WebuntisAppointment(lesson), - startTime: WebuntisTime.parse(lesson.date, lesson.startTime), - endTime: WebuntisTime.parse(lesson.date, lesson.endTime), + id: LessonAppointment(lesson), + startTime: lesson.startDateTime, + endTime: lesson.endDateTime, subject: 'Änderung', - notes: lesson.info, + notes: lesson.infoText, location: 'Unbekannt', color: LessonColor.parseFallback, startTimeZone: '', @@ -147,32 +146,48 @@ class TimetableAppointmentFactory { e.second == 0; } - String _subjectName( - GetTimetableResponseObject lesson, - GetSubjectsResponseObject? subject, - ) { - if (subject == null) return 'Event'; - final name = switch (settings.timetableNameMode) { - TimetableNameMode.name => subject.name, - TimetableNameMode.longName => subject.longName, - TimetableNameMode.alternateName => subject.alternateName, - }; - return _collapseWhitespace(name) ?? 'Event'; + String _subjectName(String? subjectShort, McTimetableEntry lesson) { + if (subjectShort != null) { + final lookup = + subjects.where((s) => s.shortName == subjectShort).firstOrNull; + final name = switch (settings.timetableNameMode) { + // Backend liefert nur shortName + longName; alternateName fällt auf + // longName zurück. + TimetableNameMode.name => subjectShort, + TimetableNameMode.longName => lookup?.longName ?? subjectShort, + TimetableNameMode.alternateName => lookup?.longName ?? subjectShort, + }; + final collapsed = _collapseWhitespace(name); + if (collapsed != null) return collapsed; + } + // Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc. + // sollen nicht generisch als "Event" auftauchen, sondern ihren Zweck samt + // Ort tragen. + return LessonTypeLabel.forEntry(lesson); } - String _locationLabel(GetTimetableResponseObject lesson) { + String _locationLabel(McTimetableEntry lesson) { final roomName = - _collapseWhitespace( - rooms.result - .firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id) - ?.name, - ) ?? - 'Unbekannt'; + _collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; final teacherName = - _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; + _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt'; return '$roomName\n$teacherName'; } + /// Backend serves teachers with their full display name ("Stefan Müller"), + /// which doesn't fit into a single calendar tile alongside the room. We + /// reduce it to the last whitespace-separated token (the surname) for the + /// overview; the detail sheet still renders the full name as a subtitle. + static String? _teacherLabel(McTimetableTeacher? teacher) { + if (teacher == null) return null; + final display = _collapseWhitespace(teacher.displayName); + if (display != null && display.isNotEmpty) { + final parts = display.split(' '); + return parts.isEmpty ? display : parts.last; + } + return _collapseWhitespace(teacher.shortName); + } + /// Collapses any line-break or whitespace run to a single space and trims. /// Returns null when input is null or fully whitespace. Webuntis sometimes /// returns multi-line room names like "A30\n4" — this normalizes those so @@ -189,66 +204,67 @@ class TimetableAppointmentFactory { return cleaned.isEmpty ? null : cleaned; } - // Pure: returns a new list of fresh objects, does not mutate input. - // (The previous version replaced `previous.endTime` in place, which - // mutated the original lesson object passed in via [input]. Across - // rebuilds those mutated lessons were observed again by the next merge - // pass — extending lessons further or, after the overlap-gap guard was - // added to [_canMerge], even causing the second half of a double lesson - // to be emitted alongside the already-merged block.) - static List _mergeAdjacentLessons( - List input, { + // Pure: builds a new list, does not mutate inputs. The previous version + // mutated `previous.endTime` in place which caused merged blocks to grow + // further on subsequent rebuilds when the same lesson objects were observed + // again by the next merge pass. + static List _mergeAdjacentLessons( + List input, { Duration maxGap = const Duration(minutes: 5), }) { if (input.isEmpty) return const []; final sorted = [...input] - ..sort( - (a, b) => WebuntisTime.parse( - a.date, - a.startTime, - ).compareTo(WebuntisTime.parse(b.date, b.startTime)), - ); + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - final merged = []; + final merged = []; for (final current in sorted) { if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { - // `merged.last` is always a copy we created below, so mutating its - // endTime is safe and keeps the next iteration's gap check correct. - merged.last.endTime = current.endTime; + final prev = merged.removeLast(); + merged.add(_extendedEnd(prev, current.endTime)); } else { - merged.add(_copyLesson(current)); + merged.add(current); } } return merged; } - static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) => - GetTimetableResponseObject.fromJson(l.toJson()); + static McTimetableEntry _extendedEnd( + McTimetableEntry source, + DateTime newEndTime, + ) => McTimetableEntry( + id: source.id, + date: source.date, + startTime: source.startTime, + endTime: newEndTime, + subjects: source.subjects, + teachers: source.teachers, + rooms: source.rooms, + classNames: source.classNames, + lessonType: source.lessonType, + status: source.status, + substitutionText: source.substitutionText, + lessonText: source.lessonText, + infoText: source.infoText, + ); static bool _canMerge( - GetTimetableResponseObject a, - GetTimetableResponseObject b, + McTimetableEntry a, + McTimetableEntry b, Duration maxGap, ) { - final aSubject = a.su.firstOrNull?.id; - final bSubject = b.su.firstOrNull?.id; - if (aSubject == null || bSubject == null || aSubject != bSubject) { + if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false; + if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false; + if (a.teachers.firstOrNull?.shortName != + b.teachers.firstOrNull?.shortName) { return false; } - if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; - if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; - if (a.code != b.code) return false; + if (a.status != b.status) return false; // Merge only sequential lessons (b starts at or after a ends, within the // tolerance). Without the lower bound, identical-metadata lessons that - // overlap in time would silently collapse into one — and because the - // merge sets `previous.endTime = current.endTime`, an overlapping merge - // can even truncate the earlier lesson. - final gap = WebuntisTime.parse( - b.date, - b.startTime, - ).difference(WebuntisTime.parse(a.date, a.endTime)); + // overlap in time would silently collapse into one. + final gap = b.startDateTime.difference(a.endDateTime); return !gap.isNegative && gap <= maxGap; } } diff --git a/lib/view/pages/timetable/data/webuntis_time.dart b/lib/view/pages/timetable/data/webuntis_time.dart deleted file mode 100644 index da9ff04..0000000 --- a/lib/view/pages/timetable/data/webuntis_time.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:intl/intl.dart'; - -class WebuntisTime { - static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); - - static DateTime parse(int date, int time) { - final timeString = time.toString().padLeft(4, '0'); - return DateTime.parse( - '$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}', - ); - } - - static int formatDate(DateTime date) => int.parse(_dateFormat.format(date)); - - static String dateKey(DateTime date) => _dateFormat.format(date); -} diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index f1ce427..512cfe7 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -4,7 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../data/arbitrary_appointment.dart'; import 'custom_event_sheet.dart'; -import 'webuntis_lesson_sheet.dart'; +import 'lesson_sheet.dart'; class AppointmentDetailsDispatcher { static void show( @@ -16,8 +16,8 @@ class AppointmentDetailsDispatcher { if (id is! ArbitraryAppointment) return; id.when( - webuntis: (lesson) => - WebuntisLessonSheet.show(context, bloc, appointment, lesson), + lesson: (entry) => + LessonSheet.show(context, bloc, appointment, entry), custom: (event) => CustomEventSheet.show(context, event), ); } diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart new file mode 100644 index 0000000..9bc731a --- /dev/null +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/lesson_type_label.dart'; + +class LessonSheet { + static void show( + BuildContext context, + TimetableBloc bloc, + Appointment appointment, + McTimetableEntry lesson, + ) { + final state = bloc.state.data; + if (state == null) return; + + final subjectShort = lesson.subjects.firstOrNull; + final headerLong = subjectShort == null + ? null + : state.subjects?.result + .where((s) => s.shortName == subjectShort) + .firstOrNull + ?.longName; + // Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel + // einsetzen — sonst stünde im Header nur ein generisches "?". + final headerTitle = subjectShort != null + ? firstNonEmpty([subjectShort, headerLong, '?']) + : LessonTypeLabel.forEntry(lesson); + final headerLongName = + (headerLong != null && headerLong.isNotEmpty && headerLong != headerTitle) + ? headerLong + : ''; + + final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); + + showDetailsBottomSheet( + context, + header: ListTile( + leading: Icon(_iconForStatus(lesson.status), size: 32), + title: Text( + '${_statusPrefix(lesson.status)}$headerTitle', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange, + ), + isThreeLine: headerLongName.isNotEmpty, + ), + children: (_) => [ + ListTile( + leading: const Icon(Icons.notifications_active), + title: Text('Status: ${_statusLabel(lesson.status)}'), + ), + if (lesson.subjects.length > 1) + _listTile( + icon: Icons.book_outlined, + label: 'Fächer', + entries: lesson.subjects + .map( + (s) => _line(s, longname: _subjectLongName(state.subjects, s)), + ) + .toList(), + ), + _roomTile(context, lesson), + _teacherTile(lesson), + if (lesson.classNames.isNotEmpty) + _listTile( + icon: Icons.people, + label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen', + entries: lesson.classNames.map(_line).toList(), + ), + ..._optionalTextTiles(lesson), + DebugTile(context).jsonData(lesson.toJson()), + ], + ); + } + + static Widget _roomTile(BuildContext context, McTimetableEntry lesson) { + final trailing = IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => AppRoutes.openRoomplan(context), + ); + + if (lesson.rooms.isEmpty) { + return ListTile( + leading: const Icon(Icons.room), + title: const Text('Raum: ?'), + trailing: trailing, + ); + } + + final entries = lesson.rooms + .map((name) => (main: _line(name), sub: null as String?)) + .toList(); + + return _listTileWithSubs( + icon: Icons.room, + label: lesson.rooms.length == 1 ? 'Raum' : 'Räume', + entries: entries, + trailing: trailing, + ); + } + + static Widget _teacherTile(McTimetableEntry lesson) { + if (lesson.teachers.isEmpty) { + return const ListTile( + leading: Icon(Icons.person), + title: Text('Lehrkraft: ?'), + ); + } + + final entries = lesson.teachers.map((t) { + final shortName = t.shortName.isEmpty ? '?' : t.shortName; + final longName = t.displayName.trim(); + final orgShort = (t.originalShortName ?? '').trim(); + final orgLong = (t.originalDisplayName ?? '').trim(); + + final subLines = []; + if (longName.isNotEmpty && longName != shortName) { + subLines.add(longName); + } + if (orgShort.isNotEmpty) { + final label = orgLong.isEmpty || orgLong == orgShort + ? orgShort + : '$orgShort · $orgLong'; + subLines.add('ehemals $label'); + } + + return ( + main: shortName, + sub: subLines.isEmpty ? null : subLines.join('\n'), + ); + }).toList(); + + return _listTileWithSubs( + icon: Icons.person, + label: lesson.teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', + entries: entries, + ); + } + + static Widget _listTileWithSubs({ + required IconData icon, + required String label, + required List<({String main, String? sub})> entries, + Widget? trailing, + }) { + if (entries.length == 1) { + final e = entries.first; + return ListTile( + leading: Icon(icon), + title: Text('$label: ${e.main}'), + subtitle: e.sub != null ? Text(e.sub!) : null, + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries + .expand( + (e) => [ + Text(e.main), + if (e.sub != null) + Padding( + padding: const EdgeInsets.only(left: 12), + child: Text(e.sub!), + ), + ], + ) + .toList(), + ), + trailing: trailing, + ); + } + + static Widget _listTile({ + required IconData icon, + required String label, + required List entries, + Widget? trailing, + }) { + if (entries.length == 1) { + return ListTile( + leading: Icon(icon), + title: Text('$label: ${entries.first}'), + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map(Text.new).toList(), + ), + trailing: trailing, + ); + } + + static List _optionalTextTiles(McTimetableEntry lesson) { + return [ + _textTile(Icons.info_outline, 'Info', lesson.infoText), + _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText), + _textTile(Icons.subject, 'Stundentext', lesson.lessonText), + _textTile( + Icons.category_outlined, + 'Stundentyp', + _lessonTypeLabel(lesson.lessonType), + ), + ].whereType().toList(); + } + + /// Marianum-Connect liefert den Stundentyp immer (Default `LESSON`). Den + /// Standard blenden wir aus — sonst stünde unter jeder regulären Stunde + /// derselbe Eintrag. Sonderfälle bekommen einen deutschen Klartext. + static String? _lessonTypeLabel(String type) { + switch (type) { + case 'LESSON': + return null; + case 'OFFICE_HOUR': + return 'Sprechstunde'; + case 'STANDBY': + return 'Bereitschaft'; + case 'BREAK_SUPERVISION': + return 'Pausenaufsicht'; + case 'EXAM': + return 'Prüfung'; + default: + return type; + } + } + + static Widget? _textTile(IconData icon, String label, String? value) { + final text = (value ?? '').trim(); + if (text.isEmpty || text == '-') return null; + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(text), + ); + } + + static String _line(String name, {String? longname, String? extra}) { + final parts = [if (name.isNotEmpty) name else '?']; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } + + static String? _subjectLongName(dynamic subjects, String shortName) { + if (subjects == null) return null; + final list = subjects.result as Iterable; + for (final s in list) { + if (s.shortName == shortName) return s.longName as String?; + } + return null; + } + + static IconData _iconForStatus(String status) { + switch (status) { + case 'CANCELLED': + return Icons.event_busy_outlined; + case 'IRREGULAR': + return Icons.swap_horiz; + default: + return Icons.school_outlined; + } + } + + static String _statusLabel(String status) { + switch (status) { + case 'CANCELLED': + return 'Entfällt'; + case 'IRREGULAR': + return 'Geändert'; + default: + return 'Regulär'; + } + } + + static String _statusPrefix(String status) { + switch (status) { + case 'CANCELLED': + return 'Entfällt: '; + case 'IRREGULAR': + return 'Änderung: '; + default: + return ''; + } + } +} diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart deleted file mode 100644 index 62c7d15..0000000 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; -import '../../../../api/webuntis/services/lesson_resolver.dart'; -import '../../../../extensions/date_time.dart'; -import '../../../../extensions/text.dart'; -import '../../../../routing/app_routes.dart'; -import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; -import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../widget/debug/debug_tile.dart'; -import '../../../../widget/details_bottom_sheet.dart'; - -class WebuntisLessonSheet { - static void show( - BuildContext context, - TimetableBloc bloc, - Appointment appointment, - GetTimetableResponseObject lesson, - ) { - final state = bloc.state.data; - if (state == null) return; - - final headerSubject = LessonResolver.resolveSubject( - state, - lesson.su.firstOrNull?.id, - ); - final headerTitle = firstNonEmpty([ - headerSubject.alternateName, - headerSubject.name, - headerSubject.longName, - '?', - ]); - final headerLongName = - headerSubject.longName.isNotEmpty && - headerSubject.longName != headerTitle - ? headerSubject.longName - : ''; - - final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); - - showDetailsBottomSheet( - context, - header: ListTile( - leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32), - title: Text( - '${LessonFormatter.codePrefix(lesson.code)}$headerTitle', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange, - ), - isThreeLine: headerLongName.isNotEmpty, - ), - children: (_) => [ - ListTile( - leading: const Icon(Icons.notifications_active), - title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'), - ), - if (lesson.su.length > 1) - _listTile( - icon: Icons.book_outlined, - label: 'Fächer', - entries: lesson.su.map((s) { - final resolved = LessonResolver.resolveSubject(state, s.id); - return LessonFormatter.formatLine( - firstNonEmpty([resolved.name, s.name, '?']), - longname: firstNonEmpty([resolved.longName, s.longname, '']), - ); - }).toList(), - ), - _roomTile(context, state, lesson), - _teacherTile(lesson), - if ((lesson.activityType ?? '').trim().isNotEmpty) - ListTile( - leading: const Icon(Icons.abc), - title: Text('Typ: ${lesson.activityType}'), - ), - if (lesson.kl.isNotEmpty) - _listTile( - icon: Icons.people, - label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', - entries: lesson.kl - .map( - (k) => LessonFormatter.formatLine( - k.name.isNotEmpty ? k.name : '?', - longname: k.longname, - ), - ) - .toList(), - ), - ..._optionalTextTiles(lesson), - DebugTile(context).jsonData(lesson.toJson()), - ], - ); - } - - static Widget _roomTile( - BuildContext context, - TimetableState state, - GetTimetableResponseObject lesson, - ) { - final trailing = IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () => AppRoutes.openRoomplan(context), - ); - - if (lesson.ro.isEmpty) { - return ListTile( - leading: const Icon(Icons.room), - title: const Text('Raum: ?'), - trailing: trailing, - ); - } - - final entries = lesson.ro.map((r) { - final resolved = LessonResolver.resolveRoom(state, r.id); - final name = firstNonEmpty([resolved.name, r.name, '?']); - final longname = firstNonEmpty([resolved.longName, r.longname, '']); - final building = resolved.building.trim(); - final main = LessonFormatter.formatLine( - name, - extra: (building.isNotEmpty && building != '?') ? building : null, - ); - final sub = (longname.isNotEmpty && longname != name) ? longname : null; - return (main: main, sub: sub); - }).toList(); - - return _listTileWithSubs( - icon: Icons.room, - label: lesson.ro.length == 1 ? 'Raum' : 'Räume', - entries: entries, - trailing: trailing, - ); - } - - static Widget _teacherTile(GetTimetableResponseObject lesson) { - if (lesson.te.isEmpty) { - return const ListTile( - leading: Icon(Icons.person), - title: Text('Lehrkraft: ?'), - ); - } - - final entries = lesson.te.map((t) { - final main = LessonFormatter.formatLine( - t.name.isNotEmpty ? t.name : '?', - longname: t.longname, - ); - final orgname = (t.orgname ?? '').trim(); - return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname'); - }).toList(); - - return _listTileWithSubs( - icon: Icons.person, - label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', - entries: entries, - ); - } - - static Widget _listTileWithSubs({ - required IconData icon, - required String label, - required List<({String main, String? sub})> entries, - Widget? trailing, - }) { - if (entries.length == 1) { - final e = entries.first; - return ListTile( - leading: Icon(icon), - title: Text('$label: ${e.main}'), - subtitle: e.sub != null ? Text(e.sub!) : null, - trailing: trailing, - ); - } - return ListTile( - leading: Icon(icon), - title: Text(label), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entries - .expand( - (e) => [ - Text(e.main), - if (e.sub != null) - Padding( - padding: const EdgeInsets.only(left: 12), - child: Text(e.sub!), - ), - ], - ) - .toList(), - ), - trailing: trailing, - ); - } - - static Widget _listTile({ - required IconData icon, - required String label, - required List entries, - Widget? trailing, - }) { - if (entries.length == 1) { - return ListTile( - leading: Icon(icon), - title: Text('$label: ${entries.first}'), - trailing: trailing, - ); - } - return ListTile( - leading: Icon(icon), - title: Text(label), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entries.map(Text.new).toList(), - ), - trailing: trailing, - ); - } - - static List _optionalTextTiles(GetTimetableResponseObject lesson) { - return [ - _textTile(Icons.info_outline, 'Info', lesson.info), - _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText), - _textTile(Icons.subject, 'Stundentext', lesson.lstext), - _textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype), - _textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags), - _textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg), - _textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark), - _textTile(Icons.notes, 'Buchungstext', lesson.bkText), - ].whereType().toList(); - } - - static Widget? _textTile(IconData icon, String label, String? value) { - final text = (value ?? '').trim(); - if (text.isEmpty || text == '-') return null; - return ListTile( - leading: Icon(icon), - title: Text(label), - subtitle: Text(text), - ); - } -} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index c08aaca..b771b39 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -13,7 +13,6 @@ import 'custom_events/custom_event_edit_dialog.dart'; import 'data/arbitrary_appointment.dart'; import 'data/lesson_period_schedule.dart'; import 'data/timetable_appointment_factory.dart'; -import 'data/webuntis_time.dart'; import 'details/appointment_details_dispatcher.dart'; import 'widgets/custom_workweek_calendar.dart'; import 'widgets/special_regions_builder.dart'; @@ -70,8 +69,7 @@ class _TimetableState extends State { return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: state.customEvents?.events ?? const [], - rooms: state.rooms!, - subjects: state.subjects!, + subjects: state.subjects?.result ?? const [], settings: timetableSettings, now: DateTime.now(), ).build(); @@ -79,7 +77,7 @@ class _TimetableState extends State { bool _isCrossedOut(Appointment appointment) { final id = appointment.id; - if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; + if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; return false; } @@ -123,6 +121,13 @@ class _TimetableState extends State { ], ), body: LoadableStateConsumer( + // Without this predicate the consumer treats the freshly-initialised + // empty TimetableState as "has content" and only shows the error bar + // on top — but `_calendar` collapses to `SizedBox.shrink()` while the + // reference data is missing, leaving the user with a blank screen. + // Telling the consumer that "ready" means having reference data + // flips it into the proper error-screen path instead. + isReady: (state) => state.hasReferenceData, child: (state, _) => _calendar(state, bloc), ), ); @@ -192,12 +197,12 @@ class _TimetableState extends State { /// `_mondayOf()` correctly walks back to the Monday of its own week, /// which is the last fully-allowed week. (DateTime, DateTime) _scrollBounds(TimetableState state) { - final year = state.schoolyear?.result; + final year = state.schoolyear; final DateTime baseMin; final DateTime baseMax; if (year != null) { - baseMin = WebuntisTime.parse(year.startDate, 0); - baseMax = WebuntisTime.parse(year.endDate, 0); + baseMin = year.startDate; + baseMax = year.endDate; } else { final now = DateTime.now(); baseMin = now.subtractDays(14); diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 360c780..c3fd17c 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -38,7 +38,10 @@ class _OutsideHoursStrip extends StatelessWidget { (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0); return Container( - color: theme.colorScheme.surfaceContainerLowest, + // Scaffold-Background, damit die Ganztagestermine-Leiste optisch nahtlos + // an Header und Stundenplan-Hintergrund anschließt; surfaceContainerLowest + // ist in M3-Light reinweiß und sticht gegen die getönte Seed-Surface ab. + color: theme.scaffoldBackgroundColor, padding: const EdgeInsets.symmetric( vertical: kOutsideStripVerticalPadding, ), diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index ce408b5..79a764a 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; -import '../data/webuntis_time.dart'; import 'time_region_tile.dart'; class SpecialRegionsBuilder { - final GetHolidaysResponse holidays; + final TimetableGetHolidaysResponse holidays; final LessonPeriodSchedule schedule; final ColorScheme colorScheme; final Color disabledColor; @@ -59,14 +58,22 @@ class SpecialRegionsBuilder { static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}'; Iterable _buildHolidayRegions() { - // Multiple Webuntis holiday entries can cover the same day (e.g. a - // public holiday falling inside a vacation period). Collapse them - // per-day so we emit exactly one TimeRegion per day and the - // overlapping labels don't render on top of each other. + // Multiple holiday entries can cover the same day (e.g. a public holiday + // falling inside a vacation period). Collapse them per-day so we emit + // exactly one TimeRegion per day and the overlapping labels don't render + // on top of each other. final byDay = {}; for (final holiday in holidays.result) { - final startDay = WebuntisTime.parse(holiday.startDate, 0); - final endDay = WebuntisTime.parse(holiday.endDate, 0); + final startDay = DateTime( + holiday.startDate.year, + holiday.startDate.month, + holiday.startDate.day, + ); + final endDay = DateTime( + holiday.endDate.year, + holiday.endDate.month, + holiday.endDate.day, + ); // Webuntis treats endDate inclusively (last day of the break) — the // `+ 1` covers single-day public holidays (where startDate == endDate) // and the final day of a multi-day vacation, both of which would @@ -76,7 +83,7 @@ class SpecialRegionsBuilder { final day = startDay.addDays(i); final key = _dayKey(day); byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add( - holiday.name, + holiday.shortName, ); } } diff --git a/lib/widget/details_bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart index db6aff2..fa13352 100644 --- a/lib/widget/details_bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -16,7 +16,12 @@ void showDetailsBottomSheet( useSafeArea: true, builder: (sheetContext) => SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 16), + // Sheets können TextFields enthalten (z.B. Endpoint-Picker); ohne den + // viewInsets-Offset schiebt sich das Eingabefeld bei aktiver Tastatur + // unter die Tastatur statt darüber zu bleiben. + padding: EdgeInsets.only( + bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 6af2ee2..6849d16 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -14,6 +14,10 @@ enum WidgetLessonStatus { irregular, teacherChanged, event, + // Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) — Native-Widget-Renderer, + // die den Status noch nicht kennen, fallen still auf den regulären Stil + // zurück, sobald der String dort unbekannt ist. + duty, } @freezed diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart index 8a7afeb..f0e3e67 100644 --- a/lib/widget_data/widget_data.g.dart +++ b/lib/widget_data/widget_data.g.dart @@ -42,6 +42,7 @@ const _$WidgetLessonStatusEnumMap = { WidgetLessonStatus.irregular: 'irregular', WidgetLessonStatus.teacherChanged: 'teacherChanged', WidgetLessonStatus.event: 'event', + WidgetLessonStatus.duty: 'duty', }; _WidgetPeriod _$WidgetPeriodFromJson(Map json) => diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 812a057..b1f070c 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -2,16 +2,16 @@ import 'dart:developer'; import 'package:rrule/rrule.dart'; +import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; +import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../api/webuntis/queries/get_holidays/get_holidays_response.dart'; -import '../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; -import '../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; -import '../view/pages/timetable/data/webuntis_time.dart'; import 'widget_data.dart'; class WidgetDataMapper { @@ -42,11 +42,11 @@ class WidgetDataMapper { static WidgetTimetableData buildDayData({ required DateTime now, - required Iterable lessons, - required GetSubjectsResponse? subjects, - required GetRoomsResponse? rooms, - required GetHolidaysResponse? holidays, - GetTimegridUnitsResponse? timegrid, + required Iterable lessons, + required TimetableGetSubjectsResponse? subjects, + required TimetableGetRoomsResponse? rooms, + required TimetableGetHolidaysResponse? holidays, + TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, bool connectDoubleLessons = true, }) { @@ -56,7 +56,7 @@ class WidgetDataMapper { final dayEnd = anchor.add(const Duration(days: 1)); final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList(); final source = connectDoubleLessons - ? _mergeAdjacentLessons(dayLessons) + ? LessonMerger.merge(dayLessons) : dayLessons; final mapped = [ ...source.map((l) => _mapLesson(l, now, subjects, rooms)), @@ -74,18 +74,18 @@ class WidgetDataMapper { static WidgetTimetableData buildWeekData({ required DateTime now, - required Iterable lessons, - required GetSubjectsResponse? subjects, - required GetRoomsResponse? rooms, - required GetHolidaysResponse? holidays, - GetTimegridUnitsResponse? timegrid, + required Iterable lessons, + required TimetableGetSubjectsResponse? subjects, + required TimetableGetRoomsResponse? rooms, + required TimetableGetHolidaysResponse? holidays, + TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, bool connectDoubleLessons = true, }) { final anchor = resolveWeekAnchor(now); final endExclusive = anchor.add(const Duration(days: 5)); final weekLessons = lessons.where((l) { - final dt = WebuntisTime.parse(l.date, l.startTime); + final dt = l.startDateTime; return !dt.isBefore(anchor) && dt.isBefore(endExclusive); }).toList(); // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with @@ -192,7 +192,7 @@ class WidgetDataMapper { static const int _smallBreakThresholdMinutes = 5; static List _resolvePeriods( - GetTimegridUnitsResponse? timegrid, + TimetableGetTimegridResponse? timegrid, ) { final schedule = (timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ?? @@ -232,92 +232,52 @@ class WidgetDataMapper { return result; } - static List _mergePerDay( - List lessons, - ) { - final byDay = >{}; + // Per-Tag-Merge: ohne diese Gruppierung würde eine letzte Stunde am Montag + // mit der ersten Stunde am Dienstag verschmelzen, wenn Fach + Lehrer + // identisch sind. + static List _mergePerDay(List lessons) { + final byDay = >{}; for (final l in lessons) { - byDay.putIfAbsent(l.date, () => []).add(l); + final key = '${l.date.year}-${l.date.month}-${l.date.day}'; + byDay.putIfAbsent(key, () => []).add(l); } - return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)]; - } - - /// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the - /// widget shows the same merged blocks the in-app calendar does. - static List _mergeAdjacentLessons( - List input, { - Duration maxGap = const Duration(minutes: 5), - }) { - if (input.isEmpty) return const []; - final sorted = [...input]..sort( - (a, b) => WebuntisTime.parse( - a.date, - a.startTime, - ).compareTo(WebuntisTime.parse(b.date, b.startTime)), - ); - final merged = []; - for (final current in sorted) { - if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { - merged.last.endTime = current.endTime; - } else { - merged.add(GetTimetableResponseObject.fromJson(current.toJson())); - } - } - return merged; - } - - static bool _canMerge( - GetTimetableResponseObject a, - GetTimetableResponseObject b, - Duration maxGap, - ) { - final aSubject = a.su.firstOrNull?.id; - final bSubject = b.su.firstOrNull?.id; - if (aSubject == null || bSubject == null || aSubject != bSubject) { - return false; - } - if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; - if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; - if (a.code != b.code) return false; - final gap = WebuntisTime.parse( - b.date, - b.startTime, - ).difference(WebuntisTime.parse(a.date, a.endTime)); - return !gap.isNegative && gap <= maxGap; + return [for (final group in byDay.values) ...LessonMerger.merge(group)]; } static WidgetLesson _mapLesson( - GetTimetableResponseObject lesson, + McTimetableEntry lesson, DateTime now, - GetSubjectsResponse? subjects, - GetRoomsResponse? rooms, + TimetableGetSubjectsResponse? subjects, + TimetableGetRoomsResponse? rooms, ) { - final start = WebuntisTime.parse(lesson.date, lesson.startTime); - final end = WebuntisTime.parse(lesson.date, lesson.endTime); + final start = lesson.startDateTime; + final end = lesson.endDateTime; final status = _mapStatus( LessonStatusClassifier.classify(lesson, start, end, now), ); - final subject = lesson.su.firstOrNull; + final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? ''; // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall // back to "Event" so the tile isn't just a dash. - final rawSubjectName = subject?.name.trim() ?? ''; - final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName; + final subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw; String? subjectLong; - if (subjects != null && subject != null) { - final found = subjects.result.where((s) => s.id == subject.id).firstOrNull; - subjectLong = found?.longName; + if (subjects != null && subjectShortRaw.isNotEmpty) { + subjectLong = subjects.result + .where((s) => s.shortName == subjectShortRaw) + .firstOrNull + ?.longName; } - subjectLong ??= subject?.longname; - final room = lesson.ro.firstOrNull; - var roomName = room?.name; - if (rooms != null && room != null) { - final resolved = - rooms.result.where((r) => r.id == room.id).firstOrNull?.name; - roomName = resolved ?? roomName; + final roomShort = lesson.rooms.firstOrNull; + var roomName = roomShort; + if (rooms != null && roomShort != null) { + roomName = rooms.result + .where((r) => r.shortName == roomShort) + .firstOrNull + ?.shortName ?? + roomName; } - final teacher = lesson.te.firstOrNull; - final teacherName = teacher?.id == 0 ? null : teacher?.name; - final originalTeacher = teacher?.orgname; + final teacher = lesson.teachers.firstOrNull; + final teacherName = teacher?.shortName; + final originalTeacher = teacher?.originalShortName; return WidgetLesson( start: start, end: end, @@ -340,6 +300,8 @@ class WidgetDataMapper { return WidgetLessonStatus.irregular; case LessonStatus.teacherChanged: return WidgetLessonStatus.teacherChanged; + case LessonStatus.duty: + return WidgetLessonStatus.duty; case LessonStatus.past: return WidgetLessonStatus.past; case LessonStatus.ongoing: @@ -349,19 +311,22 @@ class WidgetDataMapper { } } - static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) { - final dt = WebuntisTime.parse(lesson.date, lesson.startTime); - return dt.year == day.year && dt.month == day.month && dt.day == day.day; + static bool _onSameDay(McTimetableEntry lesson, DateTime day) { + return lesson.date.year == day.year && + lesson.date.month == day.month && + lesson.date.day == day.day; } - static GetHolidaysResponseObject? _findHoliday( + static McHoliday? _findHoliday( DateTime day, - GetHolidaysResponse? holidays, + TimetableGetHolidaysResponse? holidays, ) { if (holidays == null) return null; - final asInt = WebuntisTime.formatDate(day); + final asDay = DateTime(day.year, day.month, day.day); for (final h in holidays.result) { - if (asInt >= h.startDate && asInt <= h.endDate) return h; + final start = DateTime(h.startDate.year, h.startDate.month, h.startDate.day); + final end = DateTime(h.endDate.year, h.endDate.month, h.endDate.day); + if (!asDay.isBefore(start) && !asDay.isAfter(end)) return h; } return null; } diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index d2ab01b..4200142 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -27,6 +27,9 @@ class WidgetSync { static const String connectDoubleLessonsKey = 'widget_setting_connect_double_lessons_v1'; static const String themeModeKey = 'widget_setting_theme_mode_v1'; + // Mirrored so the background isolate hits the same Marianum-Connect base + // URL the in-app settings cubit currently has selected. + static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1'; static bool _initialised = false; @@ -83,6 +86,16 @@ class WidgetSync { await HomeWidget.saveWidgetData(themeModeKey, mode); } + static Future setMarianumConnectBaseUrl(String url) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(marianumConnectBaseUrlKey, url); + } + + static Future getMarianumConnectBaseUrl() async { + await ensureInitialized(); + return HomeWidget.getWidgetData(marianumConnectBaseUrlKey); + } + static Future clear() async { await ensureInitialized(); await HomeWidget.saveWidgetData(dayDataKey, null); diff --git a/test/api/webuntis/lesson_resolver_test.dart b/test/api/webuntis/lesson_resolver_test.dart deleted file mode 100644 index 01a675b..0000000 --- a/test/api/webuntis/lesson_resolver_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:marianum_mobile/api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import 'package:marianum_mobile/api/webuntis/queries/get_subjects/get_subjects_response.dart'; -import 'package:marianum_mobile/api/webuntis/services/lesson_resolver.dart'; -import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; - -TimetableState _state({ - Set subjects = const {}, - Set rooms = const {}, -}) => TimetableState( - subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), - rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), - startDate: DateTime(2026, 1, 1), - endDate: DateTime(2026, 12, 31), -); - -void main() { - group('LessonResolver.resolveSubject', () { - test('returns the matching subject when the id is found', () { - final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); - final state = _state(subjects: {math}); - - final result = LessonResolver.resolveSubject(state, 7); - expect(result.id, 7); - expect(result.name, 'M'); - expect(result.longName, 'Mathe'); - }); - - test('returns the placeholder fallback when id is null', () { - final state = _state(subjects: const {}); - final result = LessonResolver.resolveSubject(state, null); - expect(result.id, 0); - expect(result.name, '?'); - expect(result.longName, 'Unbekannt'); - }); - - test('returns the placeholder fallback when id is unknown', () { - final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); - final state = _state(subjects: {math}); - - final result = LessonResolver.resolveSubject(state, 999); - expect(result.id, 0); - expect(result.longName, 'Unbekannt'); - }); - }); - - group('LessonResolver.resolveRoom', () { - test('returns the matching room when the id is found', () { - final room = GetRoomsResponseObject( - 3, - 'A1', - 'Aula 1', - true, - 'Hauptgebäude', - ); - final state = _state(rooms: {room}); - - final result = LessonResolver.resolveRoom(state, 3); - expect(result.id, 3); - expect(result.name, 'A1'); - expect(result.building, 'Hauptgebäude'); - }); - - test('returns the placeholder fallback when id is unknown', () { - final state = _state(rooms: const {}); - final result = LessonResolver.resolveRoom(state, 42); - expect(result.id, 0); - expect(result.name, '?'); - }); - }); - - group('LessonFormatter', () { - test('iconForCode picks the right icon per status', () { - expect( - LessonFormatter.iconForCode('cancelled').codePoint, - isNot(LessonFormatter.iconForCode('irregular').codePoint), - ); - expect( - LessonFormatter.iconForCode(null).codePoint, - isNot(LessonFormatter.iconForCode('cancelled').codePoint), - ); - }); - - test('statusLabel maps known codes to German labels', () { - expect(LessonFormatter.statusLabel(null), 'Regulär'); - expect(LessonFormatter.statusLabel(''), 'Regulär'); - expect(LessonFormatter.statusLabel('cancelled'), 'Entfällt'); - expect(LessonFormatter.statusLabel('irregular'), 'Geändert'); - expect(LessonFormatter.statusLabel('something-else'), 'something-else'); - }); - - test('codePrefix prepends a label for known codes', () { - expect(LessonFormatter.codePrefix('cancelled'), 'Entfällt: '); - expect(LessonFormatter.codePrefix('irregular'), 'Änderung: '); - expect(LessonFormatter.codePrefix(null), ''); - }); - - test('formatLine renders name + (longname) + · extra in that order', () { - expect( - LessonFormatter.formatLine( - 'Mathe', - longname: 'Mathematik', - extra: 'Hauptgebäude', - ), - 'Mathe (Mathematik) · Hauptgebäude', - ); - }); - - test('formatLine omits longname when it equals name', () { - expect(LessonFormatter.formatLine('Mathe', longname: 'Mathe'), 'Mathe'); - }); - - test('formatLine substitutes ? when name is empty', () { - expect(LessonFormatter.formatLine(''), '?'); - }); - }); -} diff --git a/test/view/timetable/calendar_logic_test.dart b/test/view/timetable/calendar_logic_test.dart index 5e06910..11350d0 100644 --- a/test/view/timetable/calendar_logic_test.dart +++ b/test/view/timetable/calendar_logic_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart'; import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart'; import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart'; @@ -27,18 +27,21 @@ Appointment _appt({ recurrenceRule: rrule, ); -GetTimetableResponseObject _lesson({String? code}) => - GetTimetableResponseObject( - id: 0, - date: 0, - startTime: 0, - endTime: 0, - kl: const [], - te: const [], - su: const [], - ro: const [], - code: code, - ); +McTimetableEntry _lesson({String status = 'REGULAR'}) => McTimetableEntry( + id: 0, + date: DateTime(2026, 5, 8), + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 9), + subjects: const [], + teachers: const [], + rooms: const [], + classNames: const [], + lessonType: 'LESSON', + status: status, + substitutionText: null, + lessonText: null, + infoText: null, +); CustomTimetableEvent _customEvent() => CustomTimetableEvent( id: 'x', @@ -331,7 +334,7 @@ void main() { end: _at(2026, 5, 8, 10), ); final regular = _appt( - id: WebuntisAppointment(_lesson()), + id: LessonAppointment(_lesson()), start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), ); @@ -345,19 +348,19 @@ void main() { (c) => c.appointment.id is CustomAppointment, ); final regularCell = result.firstWhere( - (c) => c.appointment.id is WebuntisAppointment, + (c) => c.appointment.id is LessonAppointment, ); expect(customCell.lane, lessThan(regularCell.lane)); }); test('cancelled lesson lands left of a non-cancelled one on tie', () { final cancelled = _appt( - id: WebuntisAppointment(_lesson(code: 'cancelled')), + id: LessonAppointment(_lesson(status: 'CANCELLED')), start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), ); final regular = _appt( - id: WebuntisAppointment(_lesson()), + id: LessonAppointment(_lesson()), start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), ); @@ -366,13 +369,13 @@ void main() { regular, cancelled, ], maxLanes: 2).whereType().toList(); - String? codeOf(LaidOutAppointment c) { + String? statusOf(LaidOutAppointment c) { final id = c.appointment.id; - return id is WebuntisAppointment ? id.lesson.code : null; + return id is LessonAppointment ? id.entry.status : null; } - final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled'); - final regularCell = result.firstWhere((c) => codeOf(c) == null); + final cancelledCell = result.firstWhere((c) => statusOf(c) == 'CANCELLED'); + final regularCell = result.firstWhere((c) => statusOf(c) == 'REGULAR'); expect(cancelledCell.lane, lessThan(regularCell.lane)); }); diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart index c9e2626..41ac412 100644 --- a/test/widget_data/widget_data_mapper_test.dart +++ b/test/widget_data/widget_data_mapper_test.dart @@ -1,8 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import 'package:marianum_mobile/api/webuntis/queries/get_holidays/get_holidays_response.dart'; -import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; import 'package:marianum_mobile/widget_data/widget_data.dart'; import 'package:marianum_mobile/widget_data/widget_data_mapper.dart'; @@ -23,49 +23,39 @@ CustomTimetableEvent _event({ updatedAt: start, ); -GetTimetableResponseObject _lesson({ - required int date, - required int startTime, - required int endTime, - String? code, +McTimetableEntry _lesson({ + required DateTime date, + required int startHhmm, + required int endHhmm, + String status = 'REGULAR', String? subjectName, String? room, - int teacherId = 1, String? teacherName, String? teacherOrgname, String? substText, -}) => GetTimetableResponseObject( +}) => McTimetableEntry( id: 1, date: date, - startTime: startTime, - endTime: endTime, - code: code, - substText: substText, - kl: const [], - te: teacherName != null + startTime: DateTime(1970, 1, 1, startHhmm ~/ 100, startHhmm % 100), + endTime: DateTime(1970, 1, 1, endHhmm ~/ 100, endHhmm % 100), + subjects: subjectName != null ? [subjectName] : const [], + teachers: teacherName != null ? [ - GetTimetableResponseObjectTeacher( - teacherId, - teacherName, - teacherName, - teacherOrgname == null ? null : 9, - teacherOrgname, - null, + McTimetableTeacher( + shortName: teacherName, + displayName: teacherName, + originalShortName: teacherOrgname, + originalDisplayName: teacherOrgname, ), ] : const [], - su: subjectName != null - ? [ - GetTimetableResponseObjectSubject( - 5, - subjectName, - subjectName, - ), - ] - : const [], - ro: room != null - ? [GetTimetableResponseObjectRoom(7, room, room)] - : const [], + rooms: room != null ? [room] : const [], + classNames: const [], + lessonType: 'LESSON', + status: status, + substitutionText: substText, + lessonText: null, + infoText: null, ); void main() { @@ -127,8 +117,8 @@ void main() { test('only includes lessons on the anchor day', () { final lessons = [ - _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'), - _lesson(date: 20260507, startTime: 800, endTime: 845, subjectName: 'EN'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), + _lesson(date: DateTime(2026, 5, 7), startHhmm: 800, endHhmm: 845, subjectName: 'EN'), ]; final data = WidgetDataMapper.buildDayData( now: now, @@ -144,23 +134,24 @@ void main() { test('classifies cancelled and irregular lessons', () { final lessons = [ _lesson( - date: 20260506, - startTime: 800, - endTime: 845, + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, subjectName: 'MA', - code: 'cancelled', + status: 'CANCELLED', ), _lesson( - date: 20260506, - startTime: 900, - endTime: 945, + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, subjectName: 'EN', - code: 'irregular', + status: 'IRREGULAR', + teacherName: 'Müller', ), _lesson( - date: 20260506, - startTime: 1000, - endTime: 1045, + date: DateTime(2026, 5, 6), + startHhmm: 1000, + endHhmm: 1045, subjectName: 'BIO', teacherName: 'Müller', teacherOrgname: 'Schmidt', @@ -184,15 +175,16 @@ void main() { }); test('marks day as holiday when in holiday range', () { - final holidays = GetHolidaysResponse({ - GetHolidaysResponseObject( - 1, - 'Pfingsten', - 'Pfingstferien', - 20260506, - 20260510, - ), - }); + final holidays = TimetableGetHolidaysResponse( + result: [ + McHoliday( + shortName: 'Pfingsten', + longName: 'Pfingstferien', + startDate: DateTime(2026, 5, 6), + endDate: DateTime(2026, 5, 10), + ), + ], + ); final data = WidgetDataMapper.buildDayData( now: now, lessons: const [], @@ -207,21 +199,21 @@ void main() { test('lessons are sorted by start time', () { final lessons = [ _lesson( - date: 20260506, - startTime: 1000, - endTime: 1045, + date: DateTime(2026, 5, 6), + startHhmm: 1000, + endHhmm: 1045, subjectName: 'BIO', ), _lesson( - date: 20260506, - startTime: 800, - endTime: 845, + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, subjectName: 'MA', ), _lesson( - date: 20260506, - startTime: 900, - endTime: 945, + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, subjectName: 'EN', ), ]; @@ -244,9 +236,9 @@ void main() { test('long event bumps every regular lesson it covers', () { final lessons = [ - _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'), - _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), - _lesson(date: 20260506, startTime: 1000, endTime: 1045, subjectName: 'BIO'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 1000, endHhmm: 1045, subjectName: 'BIO'), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -271,13 +263,9 @@ void main() { }); test('event + same-slot duplicate regular: kept lesson shows +2', () { - // User scenario: a long custom event covers the slot, and Webuntis - // reports two regular lessons starting at the same time (different - // class group). The user wants "+2" — one for the hidden event, one - // for the parallel regular lesson — not just "+1". final lessons = [ - _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), - _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'MA'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'MA'), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -327,7 +315,7 @@ void main() { test('two events covering the same regular lesson bump it twice', () { final lessons = [ - _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -362,10 +350,10 @@ void main() { test('contains lessons across the school week', () { final lessons = [ - _lesson(date: 20260504, startTime: 800, endTime: 845, subjectName: 'MO'), - _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'WE'), - _lesson(date: 20260508, startTime: 800, endTime: 845, subjectName: 'FR'), - _lesson(date: 20260511, startTime: 800, endTime: 845, subjectName: 'NEXT'), + _lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'), + _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'), + _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), + _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), ]; final data = WidgetDataMapper.buildWeekData( now: now, @@ -374,7 +362,6 @@ void main() { rooms: null, holidays: null, ); - // Anchor is Mon 04.05.; week ends Fri 08.05. exclusive of next Mon expect(data.anchorDate, DateTime(2026, 5, 4)); expect( data.lessons.map((l) => l.subjectShort).toList(),