From 889d8f67c5684f585d9a285d2cd9faa83111e941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 9 Aug 2026 12:19:57 +0200 Subject: [PATCH] show classname instead of teacher name in teacher timetable view --- .../mobile/client/widgets/WidgetData.kt | 2 + ios/TimetableWidgetExtension/WidgetData.swift | 2 + lib/api/demo/data/demo_capabilities.dart | 3 + .../get_capabilities_response.dart | 5 + .../get_capabilities_response.g.dart | 2 + lib/app.dart | 4 + lib/background/widget_background_task.dart | 7 +- .../capabilities/bloc/capabilities_cubit.dart | 5 + .../capabilities/bloc/capabilities_state.dart | 2 + .../bloc/capabilities_state.freezed.dart | 45 +++++---- .../bloc/capabilities_state.g.dart | 2 + .../pages/timetable/data/lesson_labels.dart | 19 ++++ .../pages/timetable/data/lesson_merger.dart | 5 + .../data/timetable_appointment_factory.dart | 41 ++++---- lib/view/pages/timetable/timetable.dart | 8 +- .../widgets/timetable_calendar_view.dart | 27 ++++-- lib/widget_data/widget_data.dart | 3 + lib/widget_data/widget_data_mapper.dart | 50 ++++++++-- lib/widget_data/widget_publisher.dart | 12 ++- lib/widget_data/widget_sync.dart | 36 ++++--- .../timetable/teacher_class_label_test.dart | 93 +++++++++++++++++++ 21 files changed, 292 insertions(+), 81 deletions(-) create mode 100644 lib/view/pages/timetable/data/lesson_labels.dart create mode 100644 test/view/timetable/teacher_class_label_test.dart diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt index 402d503..5c0ccd2 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt @@ -33,6 +33,8 @@ data class WidgetLesson( val subjectShort: String, val subjectLong: String?, val room: String?, + // On teacher accounts this carries the class label ("7a") instead of the + // teacher short name (originalTeacher is null then) — mapped in Dart. val teacher: String?, val originalTeacher: String?, val status: WidgetLessonStatus, diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift index ae0f0a3..721e8a4 100644 --- a/ios/TimetableWidgetExtension/WidgetData.swift +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -27,6 +27,8 @@ struct WidgetLesson: Codable { let subjectShort: String let subjectLong: String? let room: String? + // On teacher accounts this carries the class label ("7a") instead of the + // teacher short name (originalTeacher is nil then) — mapped in Dart. let teacher: String? let originalTeacher: String? let status: WidgetLessonStatus diff --git a/lib/api/demo/data/demo_capabilities.dart b/lib/api/demo/data/demo_capabilities.dart index 7e13043..305f112 100644 --- a/lib/api/demo/data/demo_capabilities.dart +++ b/lib/api/demo/data/demo_capabilities.dart @@ -12,6 +12,9 @@ class DemoCapabilities { pushNotifications: true, timetablePastDays: null, timetableFutureDays: null, + // Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das + // Feld nicht" (siehe CapabilitiesResponse.userType). + userType: 'STUDENT', loaded: true, ); } diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart index b11af56..deda1df 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart @@ -23,11 +23,16 @@ class CapabilitiesResponse { final int? timetableFutureDays; + /// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend + /// predates the field or has no LDAP record for the user. + final String? userType; + CapabilitiesResponse({ required this.viewForeignTimetables, required this.pushNotifications, this.timetablePastDays, this.timetableFutureDays, + this.userType, }); factory CapabilitiesResponse.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart index 7257827..2f5ac3e 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart @@ -13,6 +13,7 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson( pushNotifications: json['pushNotifications'] as bool? ?? false, timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), + userType: json['userType'] as String?, ); Map _$CapabilitiesResponseToJson( @@ -22,4 +23,5 @@ Map _$CapabilitiesResponseToJson( 'pushNotifications': instance.pushNotifications, 'timetablePastDays': instance.timetablePastDays, 'timetableFutureDays': instance.timetableFutureDays, + 'userType': instance.userType, }; diff --git a/lib/app.dart b/lib/app.dart index 7d27af4..c78f748 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -18,6 +18,7 @@ import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; @@ -173,6 +174,7 @@ class _AppState extends State with WidgetsBindingObserver { // Mirror BLoC updates into the home-screen widget without waiting // for the periodic background refresh. final settingsCubit = context.read(); + final capabilitiesCubit = context.read(); _timetableWidgetSync?.cancel(); _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; @@ -181,6 +183,7 @@ class _AppState extends State with WidgetsBindingObserver { WidgetPublisher.publishFromBlocState( data, settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, ), ); } @@ -192,6 +195,7 @@ class _AppState extends State with WidgetsBindingObserver { WidgetPublisher.publishFromBlocState( initialData, settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, ), ); } diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 30560a8..82f71f3 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -195,7 +195,10 @@ Future _refresh() async { final lessons = timetable.entries; - final connectDouble = await WidgetSync.getConnectDoubleLessons(); + final [connectDouble, isTeacher] = await Future.wait([ + WidgetSync.getConnectDoubleLessons(), + WidgetSync.getIsTeacher(), + ]); final dayData = WidgetDataMapper.buildDayData( now: now, lessons: lessons, @@ -205,6 +208,7 @@ Future _refresh() async { timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, + showClassInsteadOfTeacher: isTeacher, ); final weekData = WidgetDataMapper.buildWeekData( now: now, @@ -215,6 +219,7 @@ Future _refresh() async { timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, + showClassInsteadOfTeacher: isTeacher, ); await WidgetSync.writeDayData(dayData); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 20ad5a0..8290af6 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -21,6 +21,10 @@ class CapabilitiesCubit extends HydratedCubit { int? get timetableFutureDays => state.timetableFutureDays; + /// Teacher accounts get the class shown on timetable tiles instead of their + /// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher). + bool get isTeacher => state.userType == 'TEACHER'; + /// Refreshes capabilities from the server. On any failure (endpoint not yet /// live, network error, 4xx) the previously hydrated flags are kept but the /// state is marked `loaded` — a failed fetch never silently grants a @@ -38,6 +42,7 @@ class CapabilitiesCubit extends HydratedCubit { pushNotifications: response.pushNotifications, timetablePastDays: response.timetablePastDays, timetableFutureDays: response.timetableFutureDays, + userType: response.userType, loaded: true, ), ); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart index 11a9f43..3722d5a 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -12,6 +12,8 @@ abstract class CapabilitiesState with _$CapabilitiesState { // client-side clamp; the (server-narrowed) school year alone governs. int? timetablePastDays, int? timetableFutureDays, + // LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. + String? userType, // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart index 64dcb7e..538128a 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart @@ -17,7 +17,8 @@ mixin _$CapabilitiesState { bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no // client-side clamp; the (server-narrowed) school year alone governs. - int? get timetablePastDays; int? get timetableFutureDays;// Whether a capability response (or a definitive failure) has been + int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. + String? get userType;// Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". bool get loaded; @@ -33,16 +34,16 @@ $CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStat @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; } @@ -53,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> { factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded }); @@ -70,13 +71,14 @@ class _$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? loaded = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { return _then(_self.copyWith( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable -as int?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable +as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -162,10 +164,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return orElse(); } @@ -183,10 +185,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this; switch (_that) { case _CapabilitiesState(): -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: throw StateError('Unexpected subclass'); } @@ -203,10 +205,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return null; } @@ -218,7 +220,7 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta @JsonSerializable() class _CapabilitiesState implements CapabilitiesState { - const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.loaded = false}); + const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false}); factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); @override@JsonKey() final bool viewForeignTimetables; @@ -227,6 +229,8 @@ class _CapabilitiesState implements CapabilitiesState { // client-side clamp; the (server-narrowed) school year alone governs. @override final int? timetablePastDays; @override final int? timetableFutureDays; +// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. +@override final String? userType; // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". @@ -245,16 +249,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; } @@ -265,7 +269,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; @override @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded }); @@ -282,13 +286,14 @@ class __$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? loaded = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { return _then(_CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable -as int?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable +as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart index 3be0776..c50fed3 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart @@ -12,6 +12,7 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map json) => pushNotifications: json['pushNotifications'] as bool? ?? false, timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), + userType: json['userType'] as String?, loaded: json['loaded'] as bool? ?? false, ); @@ -21,5 +22,6 @@ Map _$CapabilitiesStateToJson(_CapabilitiesState instance) => 'pushNotifications': instance.pushNotifications, 'timetablePastDays': instance.timetablePastDays, 'timetableFutureDays': instance.timetableFutureDays, + 'userType': instance.userType, 'loaded': instance.loaded, }; diff --git a/lib/view/pages/timetable/data/lesson_labels.dart b/lib/view/pages/timetable/data/lesson_labels.dart new file mode 100644 index 0000000..a2c05b5 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_labels.dart @@ -0,0 +1,19 @@ +import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; + +final RegExp _whitespaceRun = RegExp(r'\s+'); + +/// 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 values like "A30\n4" — this normalizes those so labels +/// render on a single line. +String? collapseWhitespace(String? s) { + if (s == null) return null; + final cleaned = s.replaceAll(_whitespaceRun, ' ').trim(); + return cleaned.isEmpty ? null : cleaned; +} + +/// "7a, 7b" — shared by the calendar tile factory and the home-widget mapper +/// so both surfaces render identical class labels on teacher plans. +extension LessonClassLabel on McTimetableEntry { + String? get classLabel => collapseWhitespace(classNames.join(', ')); +} diff --git a/lib/view/pages/timetable/data/lesson_merger.dart b/lib/view/pages/timetable/data/lesson_merger.dart index 0441713..bb5ab06 100644 --- a/lib/view/pages/timetable/data/lesson_merger.dart +++ b/lib/view/pages/timetable/data/lesson_merger.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; /// Combines back-to-back lessons with identical subject/room/teacher/status @@ -44,6 +46,9 @@ class LessonMerger { b.teachers.firstOrNull?.shortName) { return false; } + // Relevant für Lehrerpläne: gleicher Lehrer/Fach/Raum, aber verschiedene + // Klassen dürfen nicht zu einem Block verschmelzen. + if (!listEquals(a.classNames, b.classNames)) 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. diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 9af54cc..307ef28 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart' import '../../../../storage/timetable_settings.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; +import 'lesson_labels.dart'; import 'lesson_merger.dart'; import 'lesson_status.dart'; import 'lesson_type_label.dart'; @@ -23,6 +24,10 @@ class TimetableAppointmentFactory { final TimetableSettings settings; final DateTime now; + /// Teacher plans (a teacher's own plan or a foreign teacher view) show the + /// class on the tile instead of the teacher's own name. + final bool showClassInsteadOfTeacher; + TimetableAppointmentFactory({ required this.lessons, required this.customEvents, @@ -30,6 +35,7 @@ class TimetableAppointmentFactory { required this.settings, required this.now, this.holidays = const [], + this.showClassInsteadOfTeacher = false, }); List build() { @@ -130,7 +136,7 @@ class TimetableAppointmentFactory { location: event.description.trim().isEmpty ? null : event.description.trim(), - subject: _collapseWhitespace(event.title) ?? event.title, + subject: collapseWhitespace(event.title) ?? event.title, recurrenceRule: parsed.rule, recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates, color: @@ -222,7 +228,7 @@ class TimetableAppointmentFactory { TimetableNameMode.longName => lookup?.longName ?? subjectShort, TimetableNameMode.alternateName => lookup?.longName ?? subjectShort, }; - final collapsed = _collapseWhitespace(name); + final collapsed = collapseWhitespace(name); if (collapsed != null) return collapsed; } // Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc. @@ -233,10 +239,13 @@ class TimetableAppointmentFactory { String _locationLabel(McTimetableEntry lesson) { final roomName = - _collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; - final teacherName = - _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt'; - return '$roomName\n$teacherName'; + collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; + // Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück. + final secondLine = + (showClassInsteadOfTeacher ? lesson.classLabel : null) ?? + _teacherLabel(lesson.teachers.firstOrNull) ?? + 'Unbekannt'; + return '$roomName\n$secondLine'; } /// Backend serves teachers with their full display name ("Stefan Müller"), @@ -245,27 +254,11 @@ class TimetableAppointmentFactory { /// 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); + 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 - /// the tile renders the room on a single line. - static String? _collapseWhitespace(String? s) { - if (s == null) return null; - final cleaned = s - .replaceAll('\r\n', ' ') - .replaceAll('\n', ' ') - .replaceAll('\r', ' ') - .replaceAll('\t', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - return cleaned.isEmpty ? null : cleaned; + return collapseWhitespace(teacher.shortName); } } diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index da3f848..46f4f74 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -101,9 +101,8 @@ class _TimetableState extends State { final loadableState = context.watch().state; final innerState = loadableState.data; final atToday = innerState != null && _isOnInitialWeek(innerState); - final canViewForeign = context - .watch() - .canViewForeignTimetables; + final capabilities = context.watch(); + final canViewForeign = capabilities.canViewForeignTimetables; return Scaffold( appBar: AppBar( // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen @@ -166,6 +165,7 @@ class _TimetableState extends State { ), onCreateEvent: _onCreateEventAt, customEvents: state.customEvents?.events ?? const [], + showClassInsteadOfTeacher: capabilities.isTeacher, ), ), ); @@ -217,6 +217,8 @@ class _TimetableState extends State { onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, state, apt), customEvents: const [], + showClassInsteadOfTeacher: + selected.type == TimetableElementType.teacher, ), ), ), diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index 0a11865..e7287a8 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -28,6 +28,10 @@ class TimetableCalendarView extends StatefulWidget { final void Function(DateTime start, DateTime end)? onCreateEvent; final List customEvents; + /// True for teacher plans — tiles then show the class instead of the + /// teacher name (see [TimetableAppointmentFactory.showClassInsteadOfTeacher]). + final bool showClassInsteadOfTeacher; + const TimetableCalendarView({ super.key, required this.state, @@ -35,6 +39,7 @@ class TimetableCalendarView extends StatefulWidget { required this.onAppointmentTap, this.onCreateEvent, this.customEvents = const [], + this.showClassInsteadOfTeacher = false, }); @override @@ -46,9 +51,9 @@ class TimetableCalendarViewState extends State { GlobalKey(); List? _cachedAppointments; - int? _lastDataVersion; - TimetableSettings? _lastTimetableSettings; - List? _lastCustomEvents; + // TimetableSettings and List define no `==`, so record equality degrades to + // the same identity checks the cache always used. + (int, TimetableSettings, List, bool)? _cacheKey; DateTime _initialDisplayDate() => DateTime.now().addDays(2); @@ -63,15 +68,16 @@ class TimetableCalendarViewState extends State { .watch() .val() .timetableSettings; - if (_cachedAppointments != null && - _lastDataVersion == state.dataVersion && - identical(_lastTimetableSettings, timetableSettings) && - identical(_lastCustomEvents, widget.customEvents)) { + final key = ( + state.dataVersion, + timetableSettings, + widget.customEvents, + widget.showClassInsteadOfTeacher, + ); + if (_cachedAppointments != null && _cacheKey == key) { return _cachedAppointments!; } - _lastDataVersion = state.dataVersion; - _lastTimetableSettings = timetableSettings; - _lastCustomEvents = widget.customEvents; + _cacheKey = key; return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), @@ -80,6 +86,7 @@ class TimetableCalendarViewState extends State { holidays: state.schoolHolidays?.result ?? const [], settings: timetableSettings, now: DateTime.now(), + showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher, ).build(); } diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 1549eb3..8ccd1a4 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -28,6 +28,9 @@ abstract class WidgetLesson with _$WidgetLesson { required String subjectShort, String? subjectLong, String? room, + /// On teacher accounts this carries the class label ("7a") instead of the + /// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; + /// [originalTeacher] is null in that case. String? teacher, String? originalTeacher, required WidgetLessonStatus status, diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 45cda8f..af8a063 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -10,6 +10,7 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_res import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../extensions/date_time.dart'; +import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; @@ -58,6 +59,7 @@ class WidgetDataMapper { TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, bool connectDoubleLessons = true, + bool showClassInsteadOfTeacher = false, }) { final anchor = resolveDayAnchor(now); final holiday = _findHoliday(anchor, holidays); @@ -68,7 +70,13 @@ class WidgetDataMapper { ? LessonMerger.merge(dayLessons) : dayLessons; final mapped = [ - ...source.map((l) => _mapLesson(l, now, subjects, rooms)), + ..._mapAll( + source, + now, + subjects, + rooms, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), ..._expandCustomEvents(customEvents, dayStart, dayEnd), ]..sort((a, b) => a.start.compareTo(b.start)); return WidgetTimetableData( @@ -90,6 +98,7 @@ class WidgetDataMapper { TimetableGetTimegridResponse? timegrid, GetCustomTimetableEventResponse? customEvents, bool connectDoubleLessons = true, + bool showClassInsteadOfTeacher = false, }) { final anchor = resolveWeekAnchor(now); // The window is anchored at the *current* calendar week, not the @@ -107,7 +116,13 @@ class WidgetDataMapper { ? _mergePerDay(weekLessons) : weekLessons; final mapped = [ - ...source.map((l) => _mapLesson(l, now, subjects, rooms)), + ..._mapAll( + source, + now, + subjects, + rooms, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), ..._expandCustomEvents(customEvents, windowStart, endExclusive), ]..sort((a, b) => a.start.compareTo(b.start)); final days = [ @@ -282,12 +297,29 @@ class WidgetDataMapper { return [for (final group in byDay.values) ...LessonMerger.merge(group)]; } + static Iterable _mapAll( + Iterable source, + DateTime now, + TimetableGetSubjectsResponse? subjects, + TimetableGetRoomsResponse? rooms, { + required bool showClassInsteadOfTeacher, + }) => source.map( + (l) => _mapLesson( + l, + now, + subjects, + rooms, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), + ); + static WidgetLesson _mapLesson( McTimetableEntry lesson, DateTime now, TimetableGetSubjectsResponse? subjects, - TimetableGetRoomsResponse? rooms, - ) { + TimetableGetRoomsResponse? rooms, { + bool showClassInsteadOfTeacher = false, + }) { final start = lesson.startDateTime; final end = lesson.endDateTime; final status = _mapStatus( @@ -314,8 +346,14 @@ class WidgetDataMapper { roomName; } final teacher = lesson.teachers.firstOrNull; - final teacherName = teacher?.shortName; - final originalTeacher = teacher?.originalShortName; + // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen + // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten + // den Lehrer als Fallback. + final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null; + final teacherName = classLabel ?? teacher?.shortName; + final originalTeacher = classLabel != null + ? null + : teacher?.originalShortName; return WidgetLesson( start: start, end: end, diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index c022260..9cac9a5 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -22,14 +22,18 @@ class WidgetPublisher { static Future publishFromBlocState( TimetableState state, { Settings? settings, + bool isTeacher = false, }) async { try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; // Mirror into widget storage so the background isolate sees the same - // value the user just toggled. - await WidgetSync.setConnectDoubleLessons(connectDouble); - await WidgetSync.setThemeMode(_themeName(settings?.appTheme)); + // values the user just toggled — concurrently, they are independent. + await Future.wait([ + WidgetSync.setConnectDoubleLessons(connectDouble), + WidgetSync.setThemeMode(_themeName(settings?.appTheme)), + WidgetSync.setIsTeacher(isTeacher), + ]); final lessons = state.getAllKnownLessons(); final now = widgetNow(); final dayData = WidgetDataMapper.buildDayData( @@ -41,6 +45,7 @@ class WidgetPublisher { timegrid: state.timegrid, customEvents: state.customEvents, connectDoubleLessons: connectDouble, + showClassInsteadOfTeacher: isTeacher, ); final weekData = WidgetDataMapper.buildWeekData( now: now, @@ -51,6 +56,7 @@ class WidgetPublisher { timegrid: state.timegrid, customEvents: state.customEvents, connectDoubleLessons: connectDouble, + showClassInsteadOfTeacher: isTeacher, ); await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index 8ad02a0..500fc36 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -31,6 +31,9 @@ class WidgetSync { static const String connectDoubleLessonsKey = 'widget_setting_connect_double_lessons_v1'; static const String themeModeKey = 'widget_setting_theme_mode_v1'; + // Mirrored from CapabilitiesCubit so the background isolate can render + // teacher plans (class instead of teacher name) without bloc storage. + static const String isTeacherKey = 'widget_setting_is_teacher_v1'; // Mirrored so the background isolate hits the same Marianum-Connect base // URL the in-app settings cubit currently has selected. static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1'; @@ -58,25 +61,30 @@ class WidgetSync { ); } - static Future setLoggedIn(bool loggedIn) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(loggedInKey, loggedIn); - } + static Future setLoggedIn(bool loggedIn) => + _setBool(loggedInKey, loggedIn); - static Future setConnectDoubleLessons(bool value) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(connectDoubleLessonsKey, value); - } + static Future setConnectDoubleLessons(bool value) => + _setBool(connectDoubleLessonsKey, value); /// Default `true` matches `default_settings.dart` — fresh install behaves /// like the in-app calendar. - static Future getConnectDoubleLessons() async { + static Future getConnectDoubleLessons() => + _getBool(connectDoubleLessonsKey, defaultValue: true); + + static Future setIsTeacher(bool value) => _setBool(isTeacherKey, value); + + static Future getIsTeacher() => + _getBool(isTeacherKey, defaultValue: false); + + static Future _setBool(String key, bool value) async { await ensureInitialized(); - final value = await HomeWidget.getWidgetData( - connectDoubleLessonsKey, - defaultValue: true, - ); - return value ?? true; + await HomeWidget.saveWidgetData(key, value); + } + + static Future _getBool(String key, {required bool defaultValue}) async { + await ensureInitialized(); + return await HomeWidget.getWidgetData(key) ?? defaultValue; } static Future setThemeMode(String mode) async { diff --git a/test/view/timetable/teacher_class_label_test.dart b/test/view/timetable/teacher_class_label_test.dart new file mode 100644 index 0000000..f6cf531 --- /dev/null +++ b/test/view/timetable/teacher_class_label_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/storage/timetable_settings.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_merger.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/timetable_appointment_factory.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/timetable_name_mode.dart'; + +McTimetableEntry _lesson({ + int id = 1, + int hour = 8, + int minute = 0, + List classNames = const ['7a'], + List subjects = const ['M'], + List rooms = const ['A101'], +}) => McTimetableEntry( + id: id, + date: DateTime(2026, 5, 4), + startTime: DateTime(1970, 1, 1, hour, minute), + endTime: DateTime(1970, 1, 1, hour, minute + 45), + subjects: subjects, + teachers: [McTimetableTeacher(shortName: 'MUE', displayName: 'Stefan Müller')], + rooms: rooms, + classNames: classNames, + lessonType: 'LESSON', + status: 'REGULAR', + substitutionText: null, + lessonText: null, + infoText: null, +); + +final _settings = TimetableSettings( + connectDoubleLessons: false, + timetableNameMode: TimetableNameMode.name, +); + +String _location({ + required bool showClassInsteadOfTeacher, + List classNames = const ['7a'], +}) => TimetableAppointmentFactory( + lessons: [_lesson(classNames: classNames)], + customEvents: const [], + subjects: const [], + settings: _settings, + now: DateTime(2026, 5, 4), + showClassInsteadOfTeacher: showClassInsteadOfTeacher, +).build().single.location!; + +void main() { + group('teacher plan tile label', () { + test('shows the teacher surname by default', () { + expect(_location(showClassInsteadOfTeacher: false), 'A101\nMüller'); + }); + + test('shows the class instead of the teacher on teacher plans', () { + expect(_location(showClassInsteadOfTeacher: true), 'A101\n7a'); + }); + + test('joins multiple classes', () { + expect( + _location( + showClassInsteadOfTeacher: true, + classNames: const ['7a', '7b'], + ), + 'A101\n7a, 7b', + ); + }); + + test('falls back to the teacher when the entry has no class', () { + expect( + _location(showClassInsteadOfTeacher: true, classNames: const []), + 'A101\nMüller', + ); + }); + }); + + group('lesson merger class separation', () { + test('does not merge back-to-back lessons of different classes', () { + final merged = LessonMerger.merge([ + _lesson(id: 1, hour: 8, classNames: const ['7a']), + _lesson(id: 2, hour: 8, minute: 45, classNames: const ['7b']), + ]); + expect(merged, hasLength(2)); + }); + + test('still merges back-to-back lessons of the same class', () { + final merged = LessonMerger.merge([ + _lesson(id: 1, hour: 8), + _lesson(id: 2, hour: 8, minute: 45), + ]); + expect(merged, hasLength(1)); + }); + }); +}