added teacher-focused lesson sheet with direct class and teacher timetable links
This commit is contained in:
@@ -10,12 +10,17 @@ class McTimetableTeacher {
|
|||||||
final String displayName;
|
final String displayName;
|
||||||
final String? originalShortName;
|
final String? originalShortName;
|
||||||
final String? originalDisplayName;
|
final String? originalDisplayName;
|
||||||
|
// Webuntis element ids; null from servers that predate them.
|
||||||
|
final int? id;
|
||||||
|
final int? originalId;
|
||||||
|
|
||||||
McTimetableTeacher({
|
McTimetableTeacher({
|
||||||
required this.shortName,
|
required this.shortName,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
this.originalShortName,
|
this.originalShortName,
|
||||||
this.originalDisplayName,
|
this.originalDisplayName,
|
||||||
|
this.id,
|
||||||
|
this.originalId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
|
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -45,6 +50,8 @@ class McTimetableEntry {
|
|||||||
final String? substitutionText;
|
final String? substitutionText;
|
||||||
final String? lessonText;
|
final String? lessonText;
|
||||||
final String? infoText;
|
final String? infoText;
|
||||||
|
// Index-aligned with [classNames]; null from servers that predate it.
|
||||||
|
final List<int>? classIds;
|
||||||
|
|
||||||
McTimetableEntry({
|
McTimetableEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -60,6 +67,7 @@ class McTimetableEntry {
|
|||||||
required this.substitutionText,
|
required this.substitutionText,
|
||||||
required this.lessonText,
|
required this.lessonText,
|
||||||
required this.infoText,
|
required this.infoText,
|
||||||
|
this.classIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
|
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
|
|||||||
displayName: json['displayName'] as String,
|
displayName: json['displayName'] as String,
|
||||||
originalShortName: json['originalShortName'] as String?,
|
originalShortName: json['originalShortName'] as String?,
|
||||||
originalDisplayName: json['originalDisplayName'] as String?,
|
originalDisplayName: json['originalDisplayName'] as String?,
|
||||||
|
id: (json['id'] as num?)?.toInt(),
|
||||||
|
originalId: (json['originalId'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
||||||
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
|||||||
'displayName': instance.displayName,
|
'displayName': instance.displayName,
|
||||||
'originalShortName': instance.originalShortName,
|
'originalShortName': instance.originalShortName,
|
||||||
'originalDisplayName': instance.originalDisplayName,
|
'originalDisplayName': instance.originalDisplayName,
|
||||||
|
'id': instance.id,
|
||||||
|
'originalId': instance.originalId,
|
||||||
};
|
};
|
||||||
|
|
||||||
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
||||||
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
|||||||
substitutionText: json['substitutionText'] as String?,
|
substitutionText: json['substitutionText'] as String?,
|
||||||
lessonText: json['lessonText'] as String?,
|
lessonText: json['lessonText'] as String?,
|
||||||
infoText: json['infoText'] as String?,
|
infoText: json['infoText'] as String?,
|
||||||
|
classIds: (json['classIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
||||||
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
|||||||
'substitutionText': instance.substitutionText,
|
'substitutionText': instance.substitutionText,
|
||||||
'lessonText': instance.lessonText,
|
'lessonText': instance.lessonText,
|
||||||
'infoText': instance.infoText,
|
'infoText': instance.infoText,
|
||||||
|
'classIds': instance.classIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
|
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
|
||||||
|
|||||||
+18
-1
@@ -36,6 +36,7 @@ import 'routing/app_routes.dart';
|
|||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
@@ -295,7 +296,9 @@ class _MainState extends State<Main> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.read<CapabilitiesCubit>().load().then((_) {
|
context.read<CapabilitiesCubit>().load().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
|
final capabilities = context.read<CapabilitiesCubit>();
|
||||||
|
_syncPush(settingsCubit, capabilities);
|
||||||
|
_applyRoleDefaults(settingsCubit, capabilities);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
||||||
@@ -331,6 +334,19 @@ class _MainState extends State<Main> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// userType is only known once capabilities are loaded.
|
||||||
|
void _applyRoleDefaults(
|
||||||
|
SettingsCubit settings,
|
||||||
|
CapabilitiesCubit capabilities,
|
||||||
|
) {
|
||||||
|
if (AppModule.applyTeacherDefaults(
|
||||||
|
settings.val().modulesSettings,
|
||||||
|
isTeacher: capabilities.isTeacher,
|
||||||
|
)) {
|
||||||
|
settings.val(write: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Background credential check: a 401 means the password was rotated
|
/// Background credential check: a 401 means the password was rotated
|
||||||
/// server-side, so the validator wipes the local session and flips the
|
/// server-side, so the validator wipes the local session and flips the
|
||||||
/// account bloc to `loggedOut` (sending the user to the login screen).
|
/// account bloc to `loggedOut` (sending the user to the login screen).
|
||||||
@@ -424,6 +440,7 @@ class _MainState extends State<Main> {
|
|||||||
capabilitiesCubit.load().then((_) {
|
capabilitiesCubit.load().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_syncPush(settingsCubit, capabilitiesCubit);
|
_syncPush(settingsCubit, capabilitiesCubit);
|
||||||
|
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -188,6 +188,27 @@ class AppModule {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modules that are of no use to teachers and start out hidden for them.
|
||||||
|
static const Set<Modules> hiddenForTeachersByDefault = {
|
||||||
|
Modules.absenceReport,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher
|
||||||
|
/// can re-enable them in the module settings. Returns whether [settings]
|
||||||
|
/// changed.
|
||||||
|
static bool applyTeacherDefaults(
|
||||||
|
ModulesSettings settings, {
|
||||||
|
required bool isTeacher,
|
||||||
|
}) {
|
||||||
|
if (!isTeacher || settings.teacherDefaultsApplied) return false;
|
||||||
|
settings.hiddenModules = {
|
||||||
|
...settings.hiddenModules,
|
||||||
|
...hiddenForTeachersByDefault,
|
||||||
|
}.toList();
|
||||||
|
settings.teacherDefaultsApplied = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The settings list displays a capability-filtered subset, so reorder
|
// The settings list displays a capability-filtered subset, so reorder
|
||||||
// indices refer to that subset; the move is applied there and merged back
|
// indices refer to that subset; the move is applied there and merged back
|
||||||
// into the full order (non-displayed modules keep their relative slots).
|
// into the full order (non-displayed modules keep their relative slots).
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ class ModulesSettings {
|
|||||||
List<Modules> hiddenModules;
|
List<Modules> hiddenModules;
|
||||||
bool autoFillBottomBar;
|
bool autoFillBottomBar;
|
||||||
int fixedBottomBarSlots;
|
int fixedBottomBarSlots;
|
||||||
|
// Set once the teacher module defaults were applied, so a module the teacher
|
||||||
|
// re-enables afterwards stays visible.
|
||||||
|
bool teacherDefaultsApplied;
|
||||||
|
|
||||||
ModulesSettings({
|
ModulesSettings({
|
||||||
required this.moduleOrder,
|
required this.moduleOrder,
|
||||||
required this.hiddenModules,
|
required this.hiddenModules,
|
||||||
this.autoFillBottomBar = true,
|
this.autoFillBottomBar = true,
|
||||||
this.fixedBottomBarSlots = 3,
|
this.fixedBottomBarSlots = 3,
|
||||||
|
this.teacherDefaultsApplied = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ModulesSettings.fromJson(Map<String, dynamic> json) =>
|
factory ModulesSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ModulesSettings _$ModulesSettingsFromJson(Map<String, dynamic> json) =>
|
|||||||
.toList(),
|
.toList(),
|
||||||
autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true,
|
autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true,
|
||||||
fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3,
|
fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3,
|
||||||
|
teacherDefaultsApplied: json['teacherDefaultsApplied'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ModulesSettingsToJson(
|
Map<String, dynamic> _$ModulesSettingsToJson(
|
||||||
@@ -27,6 +28,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
|
|||||||
.toList(),
|
.toList(),
|
||||||
'autoFillBottomBar': instance.autoFillBottomBar,
|
'autoFillBottomBar': instance.autoFillBottomBar,
|
||||||
'fixedBottomBarSlots': instance.fixedBottomBarSlots,
|
'fixedBottomBarSlots': instance.fixedBottomBarSlots,
|
||||||
|
'teacherDefaultsApplied': instance.teacherDefaultsApplied,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$ModulesEnumMap = {
|
const _$ModulesEnumMap = {
|
||||||
|
|||||||
@@ -73,5 +73,6 @@ class LessonMerger {
|
|||||||
substitutionText: source.substitutionText,
|
substitutionText: source.substitutionText,
|
||||||
lessonText: source.lessonText,
|
lessonText: source.lessonText,
|
||||||
infoText: source.infoText,
|
infoText: source.infoText,
|
||||||
|
classIds: source.classIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,12 +240,14 @@ class TimetableAppointmentFactory {
|
|||||||
String _locationLabel(McTimetableEntry lesson) {
|
String _locationLabel(McTimetableEntry lesson) {
|
||||||
final roomName =
|
final roomName =
|
||||||
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||||
// Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
|
if (showClassInsteadOfTeacher) {
|
||||||
final secondLine =
|
// Klassenlose Einträge (Aufsichten etc.) zeigen nur den Raum — die
|
||||||
(showClassInsteadOfTeacher ? lesson.classLabel : null) ??
|
// Lehrkraft wäre hier die Plan-Inhaberin selbst.
|
||||||
_teacherLabel(lesson.teachers.firstOrNull) ??
|
final classLabel = lesson.classLabel;
|
||||||
'Unbekannt';
|
return classLabel == null ? roomName : '$roomName\n$classLabel';
|
||||||
return '$roomName\n$secondLine';
|
}
|
||||||
|
final teacher = _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
|
||||||
|
return '$roomName\n$teacher';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend serves teachers with their full display name ("Stefan Müller"),
|
/// Backend serves teachers with their full display name ("Stefan Müller"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
import 'custom_event_sheet.dart';
|
import 'custom_event_sheet.dart';
|
||||||
@@ -12,6 +13,8 @@ class AppointmentDetailsDispatcher {
|
|||||||
TimetableState? state,
|
TimetableState? state,
|
||||||
Appointment appointment, {
|
Appointment appointment, {
|
||||||
bool canEditSubjectColor = false,
|
bool canEditSubjectColor = false,
|
||||||
|
bool teacherPlan = false,
|
||||||
|
void Function(TimetableElementRef element)? onOpenElement,
|
||||||
}) {
|
}) {
|
||||||
final id = appointment.id;
|
final id = appointment.id;
|
||||||
if (id is! ArbitraryAppointment) return;
|
if (id is! ArbitraryAppointment) return;
|
||||||
@@ -23,6 +26,8 @@ class AppointmentDetailsDispatcher {
|
|||||||
appointment,
|
appointment,
|
||||||
entry,
|
entry,
|
||||||
canEditSubjectColor: canEditSubjectColor,
|
canEditSubjectColor: canEditSubjectColor,
|
||||||
|
teacherPlan: teacherPlan,
|
||||||
|
onOpenElement: onOpenElement,
|
||||||
),
|
),
|
||||||
custom: (event) => CustomEventSheet.show(context, event),
|
custom: (event) => CustomEventSheet.show(context, event),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
@@ -21,6 +22,8 @@ class LessonSheet {
|
|||||||
Appointment appointment,
|
Appointment appointment,
|
||||||
McTimetableEntry lesson, {
|
McTimetableEntry lesson, {
|
||||||
bool canEditSubjectColor = false,
|
bool canEditSubjectColor = false,
|
||||||
|
bool teacherPlan = false,
|
||||||
|
void Function(TimetableElementRef element)? onOpenElement,
|
||||||
}) {
|
}) {
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ class LessonSheet {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
children: (_) => <Widget>[
|
children: (sheetContext) => <Widget>[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.notifications_active),
|
leading: const Icon(Icons.notifications_active),
|
||||||
title: Text('Status: ${_statusLabel(lesson.status)}'),
|
title: Text('Status: ${_statusLabel(lesson.status)}'),
|
||||||
@@ -88,13 +91,14 @@ class LessonSheet {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
_roomTile(context, lesson),
|
_roomTile(context, lesson),
|
||||||
_teacherTile(context, lesson),
|
..._peopleTiles(
|
||||||
if (lesson.classNames.isNotEmpty)
|
context,
|
||||||
_listTile(
|
lesson,
|
||||||
icon: Icons.people,
|
opener: onOpenElement == null
|
||||||
label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen',
|
? null
|
||||||
entries: lesson.classNames.map(_line).toList(),
|
: _ElementOpener(sheetContext, onOpenElement),
|
||||||
),
|
teacherPlan: teacherPlan,
|
||||||
|
),
|
||||||
..._optionalTextTiles(lesson),
|
..._optionalTextTiles(lesson),
|
||||||
DebugTile(context).jsonData(lesson.toJson()),
|
DebugTile(context).jsonData(lesson.toJson()),
|
||||||
],
|
],
|
||||||
@@ -128,7 +132,59 @@ class LessonSheet {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget _teacherTile(BuildContext context, McTimetableEntry lesson) {
|
// Aus Lehrersicht ist die Klasse die relevante Angabe, die Lehrkraft ist
|
||||||
|
// meist die Plan-Inhaberin selbst — daher dort Klasse zuerst.
|
||||||
|
static List<Widget> _peopleTiles(
|
||||||
|
BuildContext context,
|
||||||
|
McTimetableEntry lesson, {
|
||||||
|
required _ElementOpener? opener,
|
||||||
|
required bool teacherPlan,
|
||||||
|
}) {
|
||||||
|
final classTile = _classTile(lesson, opener);
|
||||||
|
final teacherTile = _teacherTile(
|
||||||
|
context,
|
||||||
|
lesson,
|
||||||
|
opener,
|
||||||
|
teacherPlan: teacherPlan,
|
||||||
|
);
|
||||||
|
return teacherPlan ? [?classTile, teacherTile] : [teacherTile, ?classTile];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget? _classTile(McTimetableEntry lesson, _ElementOpener? opener) {
|
||||||
|
final names = lesson.classNames;
|
||||||
|
if (names.isEmpty) return null;
|
||||||
|
const tooltip = 'Stundenplan der Klasse öffnen';
|
||||||
|
final ids = lesson.classIds?.length == names.length
|
||||||
|
? lesson.classIds
|
||||||
|
: null;
|
||||||
|
TimetableElementRef? element(int i) => ids == null
|
||||||
|
? null
|
||||||
|
: (type: TimetableElementType.schoolClass, id: ids[i], label: names[i]);
|
||||||
|
|
||||||
|
return _listTile(
|
||||||
|
icon: Icons.people,
|
||||||
|
label: names.length == 1 ? 'Klasse' : 'Klassen',
|
||||||
|
entries: names.map(_line).toList(),
|
||||||
|
trailing: names.length == 1
|
||||||
|
? opener?.button(tooltip: tooltip, element: element(0))
|
||||||
|
: null,
|
||||||
|
wrapRow: (i, row) => _openable(opener, row, tooltip, element(i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _openable(
|
||||||
|
_ElementOpener? opener,
|
||||||
|
Widget label,
|
||||||
|
String tooltip,
|
||||||
|
TimetableElementRef? element,
|
||||||
|
) => opener?.row(label: label, tooltip: tooltip, element: element) ?? label;
|
||||||
|
|
||||||
|
static Widget _teacherTile(
|
||||||
|
BuildContext context,
|
||||||
|
McTimetableEntry lesson,
|
||||||
|
_ElementOpener? opener, {
|
||||||
|
required bool teacherPlan,
|
||||||
|
}) {
|
||||||
if (lesson.teachers.isEmpty) {
|
if (lesson.teachers.isEmpty) {
|
||||||
return const ListTile(
|
return const ListTile(
|
||||||
leading: Icon(Icons.person),
|
leading: Icon(Icons.person),
|
||||||
@@ -146,12 +202,17 @@ class LessonSheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte';
|
final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte';
|
||||||
|
const tooltip = 'Stundenplan der Lehrkraft öffnen';
|
||||||
|
|
||||||
// Einzelne, reguläre Lehrkraft kompakt in der Titelzeile.
|
// Einzelne, reguläre Lehrkraft kompakt in der Titelzeile.
|
||||||
if (teachers.length == 1 && teachers.first.isPlain) {
|
if (teachers.length == 1 && teachers.first.isPlain) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.person),
|
leading: const Icon(Icons.person),
|
||||||
title: Text('$label: ${teachers.first.after}'),
|
title: Text('$label: ${teachers.first.after}'),
|
||||||
|
// Im Lehrerplan ist das die Plan-Inhaberin selbst.
|
||||||
|
trailing: teacherPlan
|
||||||
|
? null
|
||||||
|
: opener?.button(tooltip: tooltip, element: teachers.first.target),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +221,10 @@ class LessonSheet {
|
|||||||
title: Text(label),
|
title: Text(label),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [for (final t in teachers) t.buildRow(context)],
|
children: [
|
||||||
|
for (final t in teachers)
|
||||||
|
_openable(opener, t.buildRow(context), tooltip, t.target),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,6 +271,7 @@ class LessonSheet {
|
|||||||
required String label,
|
required String label,
|
||||||
required List<String> entries,
|
required List<String> entries,
|
||||||
Widget? trailing,
|
Widget? trailing,
|
||||||
|
Widget Function(int index, Widget row)? wrapRow,
|
||||||
}) {
|
}) {
|
||||||
if (entries.length == 1) {
|
if (entries.length == 1) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -220,7 +285,10 @@ class LessonSheet {
|
|||||||
title: Text(label),
|
title: Text(label),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: entries.map<Widget>(Text.new).toList(),
|
children: [
|
||||||
|
for (final (i, entry) in entries.indexed)
|
||||||
|
wrapRow?.call(i, Text(entry)) ?? Text(entry),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
);
|
);
|
||||||
@@ -333,7 +401,15 @@ class _TeacherDisplay {
|
|||||||
/// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall.
|
/// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall.
|
||||||
final String after;
|
final String after;
|
||||||
|
|
||||||
const _TeacherDisplay._({this.before = '', this.after = ''});
|
/// Lehrkraft, deren Stundenplan sich öffnen lässt: die aktuelle, bei
|
||||||
|
/// ersatzlosem Entfall die ursprüngliche. Null, wenn beide unbekannt sind.
|
||||||
|
final TimetableElementRef? target;
|
||||||
|
|
||||||
|
const _TeacherDisplay._({
|
||||||
|
this.before = '',
|
||||||
|
this.after = '',
|
||||||
|
this.target,
|
||||||
|
});
|
||||||
|
|
||||||
bool get isPlain => before.isEmpty;
|
bool get isPlain => before.isEmpty;
|
||||||
|
|
||||||
@@ -345,14 +421,32 @@ class _TeacherDisplay {
|
|||||||
t.originalShortName ?? '',
|
t.originalShortName ?? '',
|
||||||
t.originalDisplayName ?? '',
|
t.originalDisplayName ?? '',
|
||||||
);
|
);
|
||||||
|
final target = _target(t, currentKnown: current.isNotEmpty);
|
||||||
|
|
||||||
// Kein (abweichendes) Original → reguläre Lehrkraft.
|
// Kein (abweichendes) Original → reguläre Lehrkraft.
|
||||||
if (original.isEmpty || original == current) {
|
if (original.isEmpty || original == current) {
|
||||||
return _TeacherDisplay._(after: current.isEmpty ? '?' : current);
|
return _TeacherDisplay._(
|
||||||
|
after: current.isEmpty ? '?' : current,
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle
|
// Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle
|
||||||
// Lehrkraft (falls vorhanden) als Ersatz dahinter.
|
// Lehrkraft (falls vorhanden) als Ersatz dahinter.
|
||||||
return _TeacherDisplay._(before: original, after: current);
|
return _TeacherDisplay._(
|
||||||
|
before: original,
|
||||||
|
after: current,
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TimetableElementRef? _target(
|
||||||
|
McTimetableTeacher t, {
|
||||||
|
required bool currentKnown,
|
||||||
|
}) {
|
||||||
|
final id = currentKnown ? t.id : t.originalId;
|
||||||
|
if (id == null) return null;
|
||||||
|
final label = currentKnown ? t.displayName : t.originalDisplayName;
|
||||||
|
return (type: TimetableElementType.teacher, id: id, label: label ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Bullet-Zeile je Lehrkraft: ersetzte Person durchgestrichen, bei
|
/// Eine Bullet-Zeile je Lehrkraft: ersetzte Person durchgestrichen, bei
|
||||||
@@ -404,3 +498,58 @@ class _TeacherDisplay {
|
|||||||
return short;
|
return short;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Baut die Bedienelemente, die aus dem Sheet heraus einen fremden
|
||||||
|
/// Stundenplan öffnen. Ohne Element-Id (älterer Server) entfallen sie.
|
||||||
|
class _ElementOpener {
|
||||||
|
static const IconData icon = Icons.calendar_view_week_outlined;
|
||||||
|
|
||||||
|
final BuildContext sheetContext;
|
||||||
|
final void Function(TimetableElementRef element) onOpen;
|
||||||
|
|
||||||
|
const _ElementOpener(this.sheetContext, this.onOpen);
|
||||||
|
|
||||||
|
void _open(TimetableElementRef element) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
onOpen(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trailing-Button für einzeilige Tiles, analog zum Raumplan-Button.
|
||||||
|
Widget? button({
|
||||||
|
required String tooltip,
|
||||||
|
required TimetableElementRef? element,
|
||||||
|
}) {
|
||||||
|
if (element == null) return null;
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(icon),
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: () => _open(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kompakte, vollständig antippbare Zeile für Aufzählungen — ein
|
||||||
|
/// 48px-Button pro Eintrag würde die Liste auseinanderziehen.
|
||||||
|
Widget row({
|
||||||
|
required Widget label,
|
||||||
|
required String tooltip,
|
||||||
|
required TimetableElementRef? element,
|
||||||
|
}) {
|
||||||
|
if (element == null) return label;
|
||||||
|
final muted = Theme.of(sheetContext).colorScheme.onSurfaceVariant;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _open(element),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: label),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(icon, size: 18, color: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class _TimetableState extends State<Timetable> {
|
|||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
final ref = await AppRoutes.openElementPicker(context);
|
final ref = await AppRoutes.openElementPicker(context);
|
||||||
if (!mounted || ref == null) return;
|
if (!mounted || ref == null) return;
|
||||||
|
_openElement(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openElement(TimetableElementRef ref) {
|
||||||
setState(() => _selected = ref);
|
setState(() => _selected = ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||||
final capabilities = context.watch<CapabilitiesCubit>();
|
final capabilities = context.watch<CapabilitiesCubit>();
|
||||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||||
|
final teacherPlan = capabilities.isTeacher;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||||
@@ -162,10 +167,12 @@ class _TimetableState extends State<Timetable> {
|
|||||||
state,
|
state,
|
||||||
apt,
|
apt,
|
||||||
canEditSubjectColor: true,
|
canEditSubjectColor: true,
|
||||||
|
teacherPlan: teacherPlan,
|
||||||
|
onOpenElement: canViewForeign ? _openElement : null,
|
||||||
),
|
),
|
||||||
onCreateEvent: _onCreateEventAt,
|
onCreateEvent: _onCreateEventAt,
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
customEvents: state.customEvents?.events ?? const [],
|
||||||
showClassInsteadOfTeacher: capabilities.isTeacher,
|
showClassInsteadOfTeacher: teacherPlan,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -179,6 +186,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
final canViewForeign = context
|
final canViewForeign = context
|
||||||
.watch<CapabilitiesCubit>()
|
.watch<CapabilitiesCubit>()
|
||||||
.canViewForeignTimetables;
|
.canViewForeignTimetables;
|
||||||
|
final teacherPlan = selected.type == TimetableElementType.teacher;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
|
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
|
||||||
@@ -214,11 +222,15 @@ class _TimetableState extends State<Timetable> {
|
|||||||
key: _calendarKey,
|
key: _calendarKey,
|
||||||
state: state,
|
state: state,
|
||||||
onWeekChanged: bloc.changeWeek,
|
onWeekChanged: bloc.changeWeek,
|
||||||
onAppointmentTap: (apt) =>
|
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
context,
|
||||||
|
state,
|
||||||
|
apt,
|
||||||
|
teacherPlan: teacherPlan,
|
||||||
|
onOpenElement: canViewForeign ? _openElement : null,
|
||||||
|
),
|
||||||
customEvents: const [],
|
customEvents: const [],
|
||||||
showClassInsteadOfTeacher:
|
showClassInsteadOfTeacher: teacherPlan,
|
||||||
selected.type == TimetableElementType.teacher,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -350,11 +350,12 @@ class WidgetDataMapper {
|
|||||||
}
|
}
|
||||||
final teacher = lesson.teachers.firstOrNull;
|
final teacher = lesson.teachers.firstOrNull;
|
||||||
// Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
|
// Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
|
||||||
// Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten
|
// Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) lassen
|
||||||
// den Lehrer als Fallback.
|
// den Slot leer — die Lehrkraft wäre die Plan-Inhaberin selbst.
|
||||||
final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null;
|
final teacherName = showClassInsteadOfTeacher
|
||||||
final teacherName = classLabel ?? teacher?.shortName;
|
? lesson.classLabel
|
||||||
final originalTeacher = classLabel != null
|
: teacher?.shortName;
|
||||||
|
final originalTeacher = showClassInsteadOfTeacher
|
||||||
? null
|
? null
|
||||||
: teacher?.originalShortName;
|
: teacher?.originalShortName;
|
||||||
return WidgetLesson(
|
return WidgetLesson(
|
||||||
|
|||||||
@@ -92,4 +92,47 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('applyTeacherDefaults', () {
|
||||||
|
test('hides the absence report once for teachers', () {
|
||||||
|
final settings = settingsWith(Modules.values);
|
||||||
|
|
||||||
|
expect(AppModule.applyTeacherDefaults(settings, isTeacher: true), isTrue);
|
||||||
|
expect(settings.hiddenModules, [Modules.absenceReport]);
|
||||||
|
expect(settings.teacherDefaultsApplied, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a module the teacher re-enabled afterwards', () {
|
||||||
|
final settings = settingsWith(Modules.values);
|
||||||
|
AppModule.applyTeacherDefaults(settings, isTeacher: true);
|
||||||
|
settings.hiddenModules.remove(Modules.absenceReport);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppModule.applyTeacherDefaults(settings, isTeacher: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(settings.hiddenModules, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves non-teachers untouched', () {
|
||||||
|
final settings = settingsWith(Modules.values);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AppModule.applyTeacherDefaults(settings, isTeacher: false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(settings.hiddenModules, isEmpty);
|
||||||
|
expect(settings.teacherDefaultsApplied, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not duplicate an already hidden module', () {
|
||||||
|
final settings = ModulesSettings(
|
||||||
|
moduleOrder: Modules.values,
|
||||||
|
hiddenModules: [Modules.absenceReport, Modules.files],
|
||||||
|
);
|
||||||
|
AppModule.applyTeacherDefaults(settings, isTeacher: true);
|
||||||
|
|
||||||
|
expect(settings.hiddenModules, [Modules.absenceReport, Modules.files]);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to the teacher when the entry has no class', () {
|
test('shows only the room when the entry has no class', () {
|
||||||
expect(
|
expect(
|
||||||
_location(showClassInsteadOfTeacher: true, classNames: const []),
|
_location(showClassInsteadOfTeacher: true, classNames: const []),
|
||||||
'A101\nMüller',
|
'A101',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user