migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic.

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow cleartext HTTP in debug builds so developers can point the
Marianum-Connect custom endpoint at a local backend (e.g.
http://10.0.2.2:8080 from the Android emulator). Release builds
keep the default cleartext block. -->
<application android:usesCleartextTraffic="true" />
</manifest>
-4
View File
@@ -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();
-31
View File
@@ -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}).';
}
}
}
@@ -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<bool>? _pendingReLogin;
MarianumConnectAuthInterceptor({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? retryDio,
AuthLogin? loginClient,
}) : _tokenStorage = tokenStorage,
_retryDio = retryDio ?? Dio(),
_loginClient = loginClient ?? AuthLogin();
@override
Future<void> 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<void> 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<bool> _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<bool> _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<Response<dynamic>> _retryWithFreshToken(
RequestOptions originalOptions,
) async {
final freshToken = await _tokenStorage.readToken();
final headers = Map<String, dynamic>.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<dynamic>(clone);
}
}
@@ -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<String> resolve() async {
if (_cached != null) return _cached!;
final device = await _deviceLabel();
_cached = device.isEmpty ? _appName : '$_appName ($device)';
return _cached!;
}
static Future<String> _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 '';
}
}
@@ -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<void> probeStored({
required Future<void> 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');
}
}
}
@@ -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<String?> readToken() => _storage.read(key: _tokenKey);
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async {
final raw = await _storage.read(key: _expiresAtKey);
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
Future<void> 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<void> clear() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey);
await _storage.delete(key: _expiresAtKey);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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';
}
}
@@ -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<AuthLoginResponse> run({
required String username,
required String password,
required String tokenName,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
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);
}
}
}
@@ -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<String, dynamic> json) =>
_$AuthLoginUserFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$AuthLoginResponseFromJson(json);
Map<String, dynamic> toJson() => _$AuthLoginResponseToJson(this);
static DateTime? _expiresFromJson(Object? value) {
if (value == null) return null;
if (value is String) return DateTime.tryParse(value);
return null;
}
}
@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_login_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> 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<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
<String, dynamic>{
'id': instance.id,
'username': instance.username,
'firstName': instance.firstName,
'lastName': instance.lastName,
'userType': instance.userType,
'className': instance.className,
};
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
AuthLoginResponse(
token: json['token'] as String,
tokenId: json['tokenId'] as String,
expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']),
user: AuthLoginUser.fromJson(json['user'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
<String, dynamic>{
'token': instance.token,
'tokenId': instance.tokenId,
'expiresAt': instance.expiresAt?.toIso8601String(),
'user': instance.user,
};
@@ -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<void> run() async {
try {
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
} on DioException catch (_) {
// ignore — local clear below still happens
} finally {
await _tokenStorage.clear();
}
}
}
@@ -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<void> 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<void>(
MarianumConnectEndpoint.resolve('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<TimetableGetHolidaysResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<String, dynamic> json) =>
_$McHolidayFromJson(json);
Map<String, dynamic> 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<McHoliday> result;
TimetableGetHolidaysResponse({required this.result});
factory TimetableGetHolidaysResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetHolidaysResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetHolidaysResponseToJson(this);
}
@@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_holidays_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McHoliday _$McHolidayFromJson(Map<String, dynamic> 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<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
'shortName': instance.shortName,
'longName': instance.longName,
'startDate': McHoliday._dateToJson(instance.startDate),
'endDate': McHoliday._dateToJson(instance.endDate),
};
TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetHolidaysResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetHolidaysResponseToJson(
TimetableGetHolidaysResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -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<TimetableGetRoomsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/rooms'),
);
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<String, dynamic> json) => _$McRoomFromJson(json);
Map<String, dynamic> toJson() => _$McRoomToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetRoomsResponse extends ApiResponse {
final List<McRoom> result;
TimetableGetRoomsResponse({required this.result});
factory TimetableGetRoomsResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetRoomsResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetRoomsResponseToJson(this);
}
@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_rooms_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McRoom _$McRoomFromJson(Map<String, dynamic> json) => McRoom(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
);
Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
};
TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetRoomsResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetRoomsResponseToJson(
TimetableGetRoomsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -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<TimetableGetSchoolyearResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<String, dynamic> json) =>
_$TimetableGetSchoolyearResponseFromJson(json);
Map<String, dynamic> 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')}';
}
@@ -0,0 +1,34 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_schoolyear_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TimetableGetSchoolyearResponse _$TimetableGetSchoolyearResponseFromJson(
Map<String, dynamic> 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<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetSchoolyearResponseToJson(
TimetableGetSchoolyearResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'id': instance.id,
'name': instance.name,
'startDate': TimetableGetSchoolyearResponse._dateToJson(instance.startDate),
'endDate': TimetableGetSchoolyearResponse._dateToJson(instance.endDate),
};
@@ -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<TimetableGetSubjectsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<String, dynamic> json) =>
_$McSubjectFromJson(json);
Map<String, dynamic> toJson() => _$McSubjectToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetSubjectsResponse extends ApiResponse {
final List<McSubject> result;
TimetableGetSubjectsResponse({required this.result});
factory TimetableGetSubjectsResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetSubjectsResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetSubjectsResponseToJson(this);
}
@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_subjects_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
);
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
};
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetSubjectsResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetSubjectsResponseToJson(
TimetableGetSubjectsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -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<TimetableGetTimegridResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<String, dynamic> json) =>
_$McTimegridUnitFromJson(json);
Map<String, dynamic> 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<McTimegridUnit> result;
TimetableGetTimegridResponse({required this.result});
factory TimetableGetTimegridResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetTimegridResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetTimegridResponseToJson(this);
}
@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_timegrid_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimegridUnit _$McTimegridUnitFromJson(Map<String, dynamic> 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<String, dynamic> _$McTimegridUnitToJson(McTimegridUnit instance) =>
<String, dynamic>{
'dayOfWeek': _dayToJson(instance.dayOfWeek),
'label': instance.label,
'startTime': McTimegridUnit._timeToJson(instance.startTime),
'endTime': McTimegridUnit._timeToJson(instance.endTime),
};
TimetableGetTimegridResponse _$TimetableGetTimegridResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetTimegridResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetTimegridResponseToJson(
TimetableGetTimegridResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -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<TimetableGetWeekResponse> run({
required DateTime from,
required DateTime until,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
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')}';
}
@@ -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<String, dynamic> json) =>
_$McTimetableTeacherFromJson(json);
Map<String, dynamic> 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<String> subjects;
final List<McTimetableTeacher> teachers;
final List<String> rooms;
final List<String> 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<String, dynamic> json) =>
_$McTimetableEntryFromJson(json);
Map<String, dynamic> 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<McTimetableEntry> entries;
TimetableGetWeekResponse({
required this.from,
required this.until,
required this.entries,
});
factory TimetableGetWeekResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetWeekResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetWeekResponseToJson(this);
}
@@ -0,0 +1,86 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_week_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
McTimetableTeacher(
shortName: json['shortName'] as String,
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?,
);
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
<String, dynamic>{
'shortName': instance.shortName,
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> 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<dynamic>)
.map((e) => e as String)
.toList(),
teachers: (json['teachers'] as List<dynamic>)
.map((e) => McTimetableTeacher.fromJson(e as Map<String, dynamic>))
.toList(),
rooms: (json['rooms'] as List<dynamic>).map((e) => e as String).toList(),
classNames: (json['classNames'] as List<dynamic>)
.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<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
<String, dynamic>{
'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<String, dynamic> json,
) =>
TimetableGetWeekResponse(
from: McTimetableEntry._dateFromJson(json['from'] as String),
until: McTimetableEntry._dateFromJson(json['until'] as String),
entries: (json['entries'] as List<dynamic>)
.map((e) => McTimetableEntry.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetWeekResponseToJson(
TimetableGetWeekResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'from': McTimetableEntry._dateToJson(instance.from),
'until': McTimetableEntry._dateToJson(instance.until),
'entries': instance.entries.map((e) => e.toJson()).toList(),
};
@@ -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<AuthenticateResponse> run() async {
awaitingResponse = true;
try {
final rawAnswer = await query(this);
final decoded = jsonDecode(rawAnswer) as Map<String, dynamic>;
final response = finalize(
AuthenticateResponse.fromJson(
decoded['result'] as Map<String, dynamic>,
),
);
_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<void>();
rethrow;
} finally {
awaitingResponse = false;
}
}
static bool awaitingResponse = false;
static Completer<void> awaitedResponse = Completer<void>();
static AuthenticateResponse? _lastResponse;
static Future<void> createSession() async {
_lastResponse = await Authenticate(
AuthenticateParams(
user: AccountData().getUsername(),
password: AccountData().getPassword(),
),
).run();
}
static Future<AuthenticateResponse> getSession() async {
if (awaitingResponse) {
await awaitedResponse.future;
}
if (_lastResponse == null) {
awaitingResponse = true;
await createSession();
}
return _lastResponse!;
}
}
@@ -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<String, dynamic> json) =>
_$AuthenticateParamsFromJson(json);
Map<String, dynamic> toJson() => _$AuthenticateParamsToJson(this);
}
@@ -1,16 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticate_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthenticateParams _$AuthenticateParamsFromJson(Map<String, dynamic> json) =>
AuthenticateParams(
user: json['user'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$AuthenticateParamsToJson(AuthenticateParams instance) =>
<String, dynamic>{'user': instance.user, 'password': instance.password};
@@ -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<String, dynamic> json) =>
_$AuthenticateResponseFromJson(json);
Map<String, dynamic> toJson() => _$AuthenticateResponseToJson(this);
}
@@ -1,30 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticate_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthenticateResponse _$AuthenticateResponseFromJson(
Map<String, dynamic> 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<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$AuthenticateResponseToJson(
AuthenticateResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'sessionId': instance.sessionId,
'personType': instance.personType,
'personId': instance.personId,
'klasseId': instance.klasseId,
};
@@ -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<GetCurrentSchoolyearResponse> run() async {
final rawAnswer = await query(this);
return finalize(
GetCurrentSchoolyearResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
}
}
@@ -1,15 +0,0 @@
import '../../../request_cache.dart';
import 'get_current_schoolyear.dart';
import 'get_current_schoolyear_response.dart';
class GetCurrentSchoolyearCache
extends SimpleCache<GetCurrentSchoolyearResponse> {
GetCurrentSchoolyearCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheDay,
loader: () => GetCurrentSchoolyear().run(),
fromJson: GetCurrentSchoolyearResponse.fromJson,
) {
start('wu-current-schoolyear');
}
}
@@ -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<String, dynamic> json) =>
_$GetCurrentSchoolyearResponseFromJson(json);
Map<String, dynamic> 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<String, dynamic> json,
) => _$GetCurrentSchoolyearResponseObjectFromJson(json);
Map<String, dynamic> toJson() =>
_$GetCurrentSchoolyearResponseObjectToJson(this);
}
@@ -1,44 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_current_schoolyear_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetCurrentSchoolyearResponse _$GetCurrentSchoolyearResponseFromJson(
Map<String, dynamic> json,
) =>
GetCurrentSchoolyearResponse(
GetCurrentSchoolyearResponseObject.fromJson(
json['result'] as Map<String, dynamic>,
),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetCurrentSchoolyearResponseToJson(
GetCurrentSchoolyearResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.toJson(),
};
GetCurrentSchoolyearResponseObject _$GetCurrentSchoolyearResponseObjectFromJson(
Map<String, dynamic> json,
) => GetCurrentSchoolyearResponseObject(
(json['id'] as num).toInt(),
json['name'] as String,
(json['startDate'] as num).toInt(),
(json['endDate'] as num).toInt(),
);
Map<String, dynamic> _$GetCurrentSchoolyearResponseObjectToJson(
GetCurrentSchoolyearResponseObject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'startDate': instance.startDate,
'endDate': instance.endDate,
};
@@ -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<GetHolidaysResponse> run() async {
final rawAnswer = await query(this);
return finalize(
GetHolidaysResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
}
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;
}
}
@@ -1,14 +0,0 @@
import '../../../request_cache.dart';
import 'get_holidays.dart';
import 'get_holidays_response.dart';
class GetHolidaysCache extends SimpleCache<GetHolidaysResponse> {
GetHolidaysCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheDay,
loader: () => GetHolidays().run(),
fromJson: GetHolidaysResponse.fromJson,
) {
start('wu-holidays');
}
}
@@ -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<GetHolidaysResponseObject> result;
GetHolidaysResponse(this.result);
factory GetHolidaysResponse.fromJson(Map<String, dynamic> json) =>
_$GetHolidaysResponseFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GetHolidaysResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetHolidaysResponseObjectToJson(this);
}
@@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_holidays_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetHolidaysResponse _$GetHolidaysResponseFromJson(Map<String, dynamic> json) =>
GetHolidaysResponse(
(json['result'] as List<dynamic>)
.map(
(e) =>
GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetHolidaysResponseToJson(
GetHolidaysResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson(
Map<String, dynamic> 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<String, dynamic> _$GetHolidaysResponseObjectToJson(
GetHolidaysResponseObject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longName': instance.longName,
'startDate': instance.startDate,
'endDate': instance.endDate,
};
@@ -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<GetRoomsResponse> run() async {
final rawAnswer = await query(this);
try {
return finalize(
GetRoomsResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
} 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');
}
}
@@ -1,14 +0,0 @@
import '../../../request_cache.dart';
import 'get_rooms.dart';
import 'get_rooms_response.dart';
class GetRoomsCache extends SimpleCache<GetRoomsResponse> {
GetRoomsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheHour,
loader: () => GetRooms().run(),
fromJson: GetRoomsResponse.fromJson,
) {
start('wu-rooms');
}
}
@@ -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<GetRoomsResponseObject> result;
GetRoomsResponse(this.result);
factory GetRoomsResponse.fromJson(Map<String, dynamic> json) =>
_$GetRoomsResponseFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GetRoomsResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetRoomsResponseObjectToJson(this);
}
@@ -1,45 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_rooms_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetRoomsResponse _$GetRoomsResponseFromJson(Map<String, dynamic> json) =>
GetRoomsResponse(
(json['result'] as List<dynamic>)
.map(
(e) => GetRoomsResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetRoomsResponseToJson(GetRoomsResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
GetRoomsResponseObject _$GetRoomsResponseObjectFromJson(
Map<String, dynamic> json,
) => GetRoomsResponseObject(
(json['id'] as num).toInt(),
json['name'] as String,
json['longName'] as String,
json['active'] as bool,
json['building'] as String,
);
Map<String, dynamic> _$GetRoomsResponseObjectToJson(
GetRoomsResponseObject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longName': instance.longName,
'active': instance.active,
'building': instance.building,
};
@@ -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<GetSubjectsResponse> run() async {
final rawAnswer = await query(this);
return finalize(
GetSubjectsResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
}
}
@@ -1,14 +0,0 @@
import '../../../request_cache.dart';
import 'get_subjects.dart';
import 'get_subjects_response.dart';
class GetSubjectsCache extends SimpleCache<GetSubjectsResponse> {
GetSubjectsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheHour,
loader: () => GetSubjects().run(),
fromJson: GetSubjectsResponse.fromJson,
) {
start('wu-subjects');
}
}
@@ -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<GetSubjectsResponseObject> result;
GetSubjectsResponse(this.result);
factory GetSubjectsResponse.fromJson(Map<String, dynamic> json) =>
_$GetSubjectsResponseFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GetSubjectsResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetSubjectsResponseObjectToJson(this);
}
@@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_subjects_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetSubjectsResponse _$GetSubjectsResponseFromJson(Map<String, dynamic> json) =>
GetSubjectsResponse(
(json['result'] as List<dynamic>)
.map(
(e) =>
GetSubjectsResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetSubjectsResponseToJson(
GetSubjectsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
GetSubjectsResponseObject _$GetSubjectsResponseObjectFromJson(
Map<String, dynamic> json,
) => GetSubjectsResponseObject(
(json['id'] as num).toInt(),
json['name'] as String,
json['longName'] as String,
json['alternateName'] as String,
json['active'] as bool,
);
Map<String, dynamic> _$GetSubjectsResponseObjectToJson(
GetSubjectsResponseObject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longName': instance.longName,
'alternateName': instance.alternateName,
'active': instance.active,
};
@@ -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<GetTimegridUnitsResponse> run() async {
final rawAnswer = await query(this);
try {
return finalize(
GetTimegridUnitsResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
} 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',
);
}
}
@@ -1,14 +0,0 @@
import '../../../request_cache.dart';
import 'get_timegrid_units.dart';
import 'get_timegrid_units_response.dart';
class GetTimegridUnitsCache extends SimpleCache<GetTimegridUnitsResponse> {
GetTimegridUnitsCache({super.onUpdate, super.renew})
: super(
cacheTime: RequestCache.cacheDay,
loader: () => GetTimegridUnits().run(),
fromJson: GetTimegridUnitsResponse.fromJson,
) {
start('wu-timegrid');
}
}
@@ -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<GetTimegridUnitsResponseDay> result;
GetTimegridUnitsResponse(this.result);
factory GetTimegridUnitsResponse.fromJson(Map<String, dynamic> json) =>
_$GetTimegridUnitsResponseFromJson(json);
Map<String, dynamic> toJson() => _$GetTimegridUnitsResponseToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GetTimegridUnitsResponseDay {
int day;
List<GetTimegridUnitsResponseUnit> timeUnits;
GetTimegridUnitsResponseDay(this.day, this.timeUnits);
factory GetTimegridUnitsResponseDay.fromJson(Map<String, dynamic> json) =>
_$GetTimegridUnitsResponseDayFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GetTimegridUnitsResponseUnitFromJson(json);
Map<String, dynamic> toJson() => _$GetTimegridUnitsResponseUnitToJson(this);
}
@@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_timegrid_units_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson(
Map<String, dynamic> json,
) =>
GetTimegridUnitsResponse(
(json['result'] as List<dynamic>)
.map(
(e) => GetTimegridUnitsResponseDay.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetTimegridUnitsResponseToJson(
GetTimegridUnitsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson(
Map<String, dynamic> json,
) => GetTimegridUnitsResponseDay(
(json['day'] as num).toInt(),
(json['timeUnits'] as List<dynamic>)
.map(
(e) => GetTimegridUnitsResponseUnit.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$GetTimegridUnitsResponseDayToJson(
GetTimegridUnitsResponseDay instance,
) => <String, dynamic>{
'day': instance.day,
'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(),
};
GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson(
Map<String, dynamic> json,
) => GetTimegridUnitsResponseUnit(
json['name'] as String,
(json['startTime'] as num).toInt(),
(json['endTime'] as num).toInt(),
);
Map<String, dynamic> _$GetTimegridUnitsResponseUnitToJson(
GetTimegridUnitsResponseUnit instance,
) => <String, dynamic>{
'name': instance.name,
'startTime': instance.startTime,
'endTime': instance.endTime,
};
@@ -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<GetTimetableResponse> run() async {
final rawAnswer = await query(this);
return finalize(
GetTimetableResponse.fromJson(
jsonDecode(rawAnswer) as Map<String, dynamic>,
),
);
}
}
@@ -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<GetTimetableResponse> {
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<GetTimetableResponse> _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();
}
}
@@ -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<String, dynamic> json) =>
_$GetTimetableParamsFromJson(json);
Map<String, dynamic> 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<GetTimetableParamsOptionsFields>? klasseFields;
@JsonKey(includeIfNull: false)
List<GetTimetableParamsOptionsFields>? roomFields;
@JsonKey(includeIfNull: false)
List<GetTimetableParamsOptionsFields>? subjectFields;
@JsonKey(includeIfNull: false)
List<GetTimetableParamsOptionsFields>? 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<String, dynamic> json) =>
_$GetTimetableParamsOptionsFromJson(json);
Map<String, dynamic> toJson() => _$GetTimetableParamsOptionsToJson(this);
}
enum GetTimetableParamsOptionsFields {
@JsonValue('id')
id,
@JsonValue('name')
name,
@JsonValue('longname')
longname,
@JsonValue('externalkey')
externalkey;
static List<GetTimetableParamsOptionsFields> 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<String, dynamic> json,
) => _$GetTimetableParamsOptionsElementFromJson(json);
Map<String, dynamic> toJson() =>
_$GetTimetableParamsOptionsElementToJson(this);
}
enum GetTimetableParamsOptionsElementKeyType {
@JsonValue('id')
id,
@JsonValue('name')
name,
@JsonValue('externalkey')
externalkey,
}
@@ -1,106 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_timetable_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetTimetableParams _$GetTimetableParamsFromJson(Map<String, dynamic> json) =>
GetTimetableParams(
options: GetTimetableParamsOptions.fromJson(
json['options'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$GetTimetableParamsToJson(GetTimetableParams instance) =>
<String, dynamic>{'options': instance.options.toJson()};
GetTimetableParamsOptions _$GetTimetableParamsOptionsFromJson(
Map<String, dynamic> json,
) => GetTimetableParamsOptions(
element: GetTimetableParamsOptionsElement.fromJson(
json['element'] as Map<String, dynamic>,
),
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<dynamic>?)
?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e))
.toList(),
roomFields: (json['roomFields'] as List<dynamic>?)
?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e))
.toList(),
subjectFields: (json['subjectFields'] as List<dynamic>?)
?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e))
.toList(),
teacherFields: (json['teacherFields'] as List<dynamic>?)
?.map((e) => $enumDecode(_$GetTimetableParamsOptionsFieldsEnumMap, e))
.toList(),
);
Map<String, dynamic> _$GetTimetableParamsOptionsToJson(
GetTimetableParamsOptions instance,
) => <String, dynamic>{
'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<String, dynamic> json,
) => GetTimetableParamsOptionsElement(
id: (json['id'] as num).toInt(),
type: (json['type'] as num).toInt(),
keyType: $enumDecodeNullable(
_$GetTimetableParamsOptionsElementKeyTypeEnumMap,
json['keyType'],
),
);
Map<String, dynamic> _$GetTimetableParamsOptionsElementToJson(
GetTimetableParamsOptionsElement instance,
) => <String, dynamic>{
'id': instance.id,
'type': instance.type,
'keyType':
?_$GetTimetableParamsOptionsElementKeyTypeEnumMap[instance.keyType],
};
const _$GetTimetableParamsOptionsElementKeyTypeEnumMap = {
GetTimetableParamsOptionsElementKeyType.id: 'id',
GetTimetableParamsOptionsElementKeyType.name: 'name',
GetTimetableParamsOptionsElementKeyType.externalkey: 'externalkey',
};
@@ -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<GetTimetableResponseObject> result;
GetTimetableResponse(this.result);
factory GetTimetableResponse.fromJson(Map<String, dynamic> json) =>
_$GetTimetableResponseFromJson(json);
Map<String, dynamic> 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<GetTimetableResponseObjectClass> kl;
List<GetTimetableResponseObjectTeacher> te;
List<GetTimetableResponseObjectSubject> su;
List<GetTimetableResponseObjectRoom> 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<String, dynamic> json) =>
_$GetTimetableResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetTimetableResponseObjectToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GetTimetableResponseObjectFields {
List<GetTimetableResponseObjectFieldsObject>? te;
GetTimetableResponseObjectFields(this.te);
factory GetTimetableResponseObjectFields.fromJson(
Map<String, dynamic> json,
) => _$GetTimetableResponseObjectFieldsFromJson(json);
Map<String, dynamic> 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<String, dynamic> json,
) => _$GetTimetableResponseObjectFieldsObjectFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GetTimetableResponseObjectClassFromJson(json);
Map<String, dynamic> 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<String, dynamic> json,
) => _$GetTimetableResponseObjectTeacherFromJson(json);
Map<String, dynamic> toJson() =>
_$GetTimetableResponseObjectTeacherToJson(this);
}
@JsonSerializable()
class GetTimetableResponseObjectSubject {
int id;
String name;
String longname;
GetTimetableResponseObjectSubject(this.id, this.name, this.longname);
factory GetTimetableResponseObjectSubject.fromJson(
Map<String, dynamic> json,
) => _$GetTimetableResponseObjectSubjectFromJson(json);
Map<String, dynamic> toJson() =>
_$GetTimetableResponseObjectSubjectToJson(this);
}
@JsonSerializable()
class GetTimetableResponseObjectRoom {
int id;
String name;
String longname;
GetTimetableResponseObjectRoom(this.id, this.name, this.longname);
factory GetTimetableResponseObjectRoom.fromJson(Map<String, dynamic> json) =>
_$GetTimetableResponseObjectRoomFromJson(json);
Map<String, dynamic> toJson() => _$GetTimetableResponseObjectRoomToJson(this);
}
@@ -1,205 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_timetable_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetTimetableResponse _$GetTimetableResponseFromJson(
Map<String, dynamic> json,
) =>
GetTimetableResponse(
(json['result'] as List<dynamic>)
.map(
(e) => GetTimetableResponseObject.fromJson(
e as Map<String, dynamic>,
),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetTimetableResponseToJson(
GetTimetableResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
GetTimetableResponseObject _$GetTimetableResponseObjectFromJson(
Map<String, dynamic> 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<dynamic>)
.map(
(e) =>
GetTimetableResponseObjectClass.fromJson(e as Map<String, dynamic>),
)
.toList(),
te: (json['te'] as List<dynamic>)
.map(
(e) => GetTimetableResponseObjectTeacher.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
su: (json['su'] as List<dynamic>)
.map(
(e) => GetTimetableResponseObjectSubject.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
ro: (json['ro'] as List<dynamic>)
.map(
(e) =>
GetTimetableResponseObjectRoom.fromJson(e as Map<String, dynamic>),
)
.toList(),
)..bkText = json['bkText'] as String?;
Map<String, dynamic> _$GetTimetableResponseObjectToJson(
GetTimetableResponseObject instance,
) => <String, dynamic>{
'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<String, dynamic> json,
) => GetTimetableResponseObjectFields(
(json['te'] as List<dynamic>?)
?.map(
(e) => GetTimetableResponseObjectFieldsObject.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
Map<String, dynamic> _$GetTimetableResponseObjectFieldsToJson(
GetTimetableResponseObjectFields instance,
) => <String, dynamic>{'te': instance.te?.map((e) => e.toJson()).toList()};
GetTimetableResponseObjectFieldsObject
_$GetTimetableResponseObjectFieldsObjectFromJson(Map<String, dynamic> json) =>
GetTimetableResponseObjectFieldsObject(
id: (json['id'] as num?)?.toInt(),
name: json['name'] as String?,
longname: json['longname'] as String?,
externalkey: json['externalkey'] as String?,
);
Map<String, dynamic> _$GetTimetableResponseObjectFieldsObjectToJson(
GetTimetableResponseObjectFieldsObject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longname': instance.longname,
'externalkey': instance.externalkey,
};
GetTimetableResponseObjectClass _$GetTimetableResponseObjectClassFromJson(
Map<String, dynamic> json,
) => GetTimetableResponseObjectClass(
(json['id'] as num).toInt(),
json['name'] as String,
json['longname'] as String,
json['externalkey'] as String?,
);
Map<String, dynamic> _$GetTimetableResponseObjectClassToJson(
GetTimetableResponseObjectClass instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longname': instance.longname,
'externalkey': instance.externalkey,
};
GetTimetableResponseObjectTeacher _$GetTimetableResponseObjectTeacherFromJson(
Map<String, dynamic> 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<String, dynamic> _$GetTimetableResponseObjectTeacherToJson(
GetTimetableResponseObjectTeacher instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longname': instance.longname,
'orgid': instance.orgid,
'orgname': instance.orgname,
'externalkey': instance.externalkey,
};
GetTimetableResponseObjectSubject _$GetTimetableResponseObjectSubjectFromJson(
Map<String, dynamic> json,
) => GetTimetableResponseObjectSubject(
(json['id'] as num).toInt(),
json['name'] as String,
json['longname'] as String,
);
Map<String, dynamic> _$GetTimetableResponseObjectSubjectToJson(
GetTimetableResponseObjectSubject instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longname': instance.longname,
};
GetTimetableResponseObjectRoom _$GetTimetableResponseObjectRoomFromJson(
Map<String, dynamic> json,
) => GetTimetableResponseObjectRoom(
(json['id'] as num).toInt(),
json['name'] as String,
json['longname'] as String,
);
Map<String, dynamic> _$GetTimetableResponseObjectRoomToJson(
GetTimetableResponseObjectRoom instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'longname': instance.longname,
};
@@ -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 = <String>[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(' ');
}
}
-99
View File
@@ -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<String> 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<String, dynamic> jsonData;
try {
jsonData = jsonDecode(data.body) as Map<String, dynamic>;
} on FormatException catch (e) {
throw ParseException(
technicalDetails: 'WebUntis JSON decode: ${e.message}',
);
}
final error = jsonData['error'] as Map<String, dynamic>?;
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 extends ApiResponse>(T response) {
response.rawResponse = this.response!;
return response;
}
Future<ApiResponse> run();
String _body() => genericParam == null ? '{}' : jsonEncode(genericParam);
Future<http.Response> post(String data, Map<String, String>? 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}',
);
}
}
}
-9
View File
@@ -1,9 +0,0 @@
class WebuntisError implements Exception {
String message;
int code;
WebuntisError(this.message, this.code);
@override
String toString() => 'WebUntis ($code): $message';
}
+38 -43
View File
@@ -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<void> _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<GetSubjectsResponse>(() => GetSubjects().run());
final rooms = await _runOrNull<GetRoomsResponse>(() => GetRooms().run());
final holidays = await _runOrNull<GetHolidaysResponse>(() => GetHolidays().run());
final timegrid = await _runOrNull<GetTimegridUnitsResponse>(
() => GetTimegridUnits().run(),
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(),
);
final rooms = await _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(),
);
final holidays = await _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(),
);
final timegrid = await _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(),
);
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
() => GetCustomTimetableEvent(
@@ -129,7 +124,7 @@ Future<void> _refresh() async {
).run(),
);
final lessons = timetable.result;
final lessons = timetable.entries;
final connectDouble = await WidgetSync.getConnectDoubleLessons();
final dayData = WidgetDataMapper.buildDayData(
+29 -1
View File
@@ -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<Main> {
AccountData().waitForPopulation().then((value) {
if (!mounted) return;
context.read<AccountBloc>().setStatus(
final accountBloc = context.read<AccountBloc>();
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<SettingsCubit, Settings>(
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:
-8
View File
@@ -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'),
@@ -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<String, dynamic> 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<void> _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<String, GetTimetableResponse>.of(s.weekCache);
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
@@ -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(<String, GetTimetableResponse>{})
Map<String, GetTimetableResponse> weekCache,
GetRoomsResponse? rooms,
GetSubjectsResponse? subjects,
GetHolidaysResponse? schoolHolidays,
GetCurrentSchoolyearResponse? schoolyear,
GetTimegridUnitsResponse? timegrid,
@Default(<String, TimetableGetWeekResponse>{})
Map<String, TimetableGetWeekResponse> 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<String, Object?> json) =>
_$TimetableStateFromJson(json);
Iterable<GetTimetableResponseObject> getAllKnownLessons() =>
weekCache.values.expand((response) => response.result);
Iterable<McTimetableEntry> getAllKnownLessons() =>
weekCache.values.expand((response) => response.entries);
bool get hasReferenceData =>
rooms != null &&
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TimetableState {
Map<String, GetTimetableResponse> 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<String, TimetableGetWeekResponse> 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<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate
Map<String, TimetableGetWeekResponse> 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<String, GetTimetableResponse>,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<String, TimetableGetWeekResponse>,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 extends Object?>(TResult Function( Map<String, GetTimetableResponse> 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 extends Object?>(TResult Function( Map<String, TimetableGetWeekResponse> 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 extends Object?>(TResult Function( Map<String, GetTimetableResponse> 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 extends Object?>(TResult Function( Map<String, TimetableGetWeekResponse> 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 extends Object?>(TResult? Function( Map<String, GetTimetableResponse> 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 extends Object?>(TResult? Function( Map<String, TimetableGetWeekResponse> 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<String, GetTimetableResponse> weekCache = const <String, GetTimetableResponse>{}, 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<String, TimetableGetWeekResponse> weekCache = const <String, TimetableGetWeekResponse>{}, 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<String, dynamic> json) => _$TimetableStateFromJson(json);
final Map<String, GetTimetableResponse> _weekCache;
@override@JsonKey() Map<String, GetTimetableResponse> get weekCache {
final Map<String, TimetableGetWeekResponse> _weekCache;
@override@JsonKey() Map<String, TimetableGetWeekResponse> 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<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate
Map<String, TimetableGetWeekResponse> 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<String, GetTimetableResponse>,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<String, TimetableGetWeekResponse>,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
@@ -12,31 +12,33 @@ _TimetableState _$TimetableStateFromJson(Map<String, dynamic> json) =>
(json['weekCache'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
k,
GetTimetableResponse.fromJson(e as Map<String, dynamic>),
TimetableGetWeekResponse.fromJson(e as Map<String, dynamic>),
),
) ??
const <String, GetTimetableResponse>{},
const <String, TimetableGetWeekResponse>{},
rooms: json['rooms'] == null
? null
: GetRoomsResponse.fromJson(json['rooms'] as Map<String, dynamic>),
: TimetableGetRoomsResponse.fromJson(
json['rooms'] as Map<String, dynamic>,
),
subjects: json['subjects'] == null
? null
: GetSubjectsResponse.fromJson(
: TimetableGetSubjectsResponse.fromJson(
json['subjects'] as Map<String, dynamic>,
),
schoolHolidays: json['schoolHolidays'] == null
? null
: GetHolidaysResponse.fromJson(
: TimetableGetHolidaysResponse.fromJson(
json['schoolHolidays'] as Map<String, dynamic>,
),
schoolyear: json['schoolyear'] == null
? null
: GetCurrentSchoolyearResponse.fromJson(
: TimetableGetSchoolyearResponse.fromJson(
json['schoolyear'] as Map<String, dynamic>,
),
timegrid: json['timegrid'] == null
? null
: GetTimegridUnitsResponse.fromJson(
: TimetableGetTimegridResponse.fromJson(
json['timegrid'] as Map<String, dynamic>,
),
customEvents: json['customEvents'] == null
@@ -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<GetTimetableResponse> getWeek(
Future<TimetableGetWeekResponse> getWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetTimetableResponse>(
(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<GetRoomsResponse> getRooms({
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetRoomsResponse>(
(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<GetSubjectsResponse> getSubjects({
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetSubjectsResponse>(
(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<GetHolidaysResponse> getSchoolHolidays({
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetHolidaysResponse>(
(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<GetCurrentSchoolyearResponse> getCurrentSchoolyear({
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetCurrentSchoolyearResponse>(
(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<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) =>
resolveFromCache<GetTimegridUnitsResponse>(
(onUpdate, _) =>
GetTimegridUnitsCache(renew: renew, onUpdate: onUpdate),
operationName: 'getTimegrid',
);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
TimetableGetTimegrid().run();
Future<GetCustomTimetableEventResponse> getCustomEvents({
bool renew = false,
+65
View File
@@ -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<String, dynamic> json) =>
_$DevToolsSettingsFromJson(json);
Map<String, dynamic> toJson() => _$DevToolsSettingsToJson(this);
+21 -5
View File
@@ -6,17 +6,33 @@ part of 'dev_tools_settings.dart';
// JsonSerializableGenerator
// **************************************************************************
DevToolsSettings _$DevToolsSettingsFromJson(Map<String, dynamic> json) =>
DevToolsSettings(
DevToolsSettings _$DevToolsSettingsFromJson(
Map<String, dynamic> json,
) => DevToolsSettings(
showPerformanceOverlay: json['showPerformanceOverlay'] as bool,
checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool,
checkerboardRasterCacheImages:
json['checkerboardRasterCacheImages'] as bool,
);
checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool,
marianumConnectEndpoint:
$enumDecodeNullable(
_$MarianumConnectEndpointEnumMap,
json['marianumConnectEndpoint'],
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
<String, dynamic>{
'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',
};
+75 -3
View File
@@ -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,8 +61,17 @@ class _LoginState extends State<Login> {
minHeight: constraints.maxHeight,
maxWidth: 420,
),
child: IntrinsicHeight(
// 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),
@@ -68,10 +81,13 @@ class _LoginState extends State<Login> {
),
const SizedBox(height: 18),
const LoginDisclaimer(),
const Spacer(),
const LoginFooter(),
],
),
const Column(
mainAxisSize: MainAxisSize.min,
children: [_EndpointLink(), LoginFooter()],
),
],
),
),
),
@@ -80,3 +96,59 @@ class _LoginState extends State<Login> {
),
);
}
/// 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<SettingsCubit, model.Settings>(
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<SettingsCubit>(),
),
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)';
}
}
}
+16 -9
View File
@@ -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.'
@@ -62,6 +62,8 @@ class DefaultSettings {
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
),
);
}
@@ -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<AccountBloc>().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<void> _performLogout() async {
await AuthLogout().run();
await AccountData().removeData();
}
}
@@ -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<DevToolsSection> {
);
},
),
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'),
@@ -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<SettingsCubit, model.Settings>(
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<void> 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<MarianumConnectEndpoint>(
groupValue: _selected,
onChanged: _selectEndpoint,
child: Column(
children: [
const RadioListTile<MarianumConnectEndpoint>(
title: Text('Normal'),
subtitle: Text(DevToolsSettings.liveUrl),
value: MarianumConnectEndpoint.live,
),
const RadioListTile<MarianumConnectEndpoint>(
title: Text('Beta'),
subtitle: Text(DevToolsSettings.betaUrl),
value: MarianumConnectEndpoint.beta,
),
RadioListTile<MarianumConnectEndpoint>(
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<void> _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';
}
}
@@ -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<T>({
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 {
@@ -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;
}
@@ -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;
@@ -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<McTimetableEntry> merge(
List<McTimetableEntry> input, {
Duration maxGap = defaultMaxGap,
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <McTimetableEntry>[];
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,
);
}
@@ -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 MonFri, 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);
}
@@ -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;
@@ -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;
}
}
}
@@ -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<GetTimetableResponseObject> lessons;
final List<McTimetableEntry> lessons;
final List<CustomTimetableEvent> customEvents;
final GetRoomsResponse rooms;
final GetSubjectsResponse subjects;
final List<McSubject> 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';
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
if (subjectShort != null) {
final lookup =
subjects.where((s) => s.shortName == subjectShort).firstOrNull;
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
// 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,
};
return _collapseWhitespace(name) ?? 'Event';
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<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> 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<McTimetableEntry> _mergeAdjacentLessons(
List<McTimetableEntry> 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 = <GetTimetableResponseObject>[];
final merged = <McTimetableEntry>[];
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;
}
}
@@ -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);
}
@@ -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),
);
}
@@ -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: (_) => <Widget>[
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 = <String>[];
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<Widget>(
(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<String> 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<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(McTimetableEntry lesson) {
return <Widget?>[
_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<Widget>().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 = <String>[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<dynamic>;
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 '';
}
}
}
@@ -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: (_) => <Widget>[
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<Widget>(
(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<String> 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<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(GetTimetableResponseObject lesson) {
return <Widget?>[
_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<Widget>().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),
);
}
}
+12 -7
View File
@@ -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<Timetable> {
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<Timetable> {
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<Timetable> {
],
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
// 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<Timetable> {
/// `_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);
@@ -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,
),
@@ -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<TimeRegion> _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 = <String, _HolidayDay>{};
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,
);
}
}
+6 -1
View File
@@ -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,
+4
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More