import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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 '../../../../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'; import '../data/lesson_labels.dart'; import '../data/lesson_type_label.dart'; import '../subject_colors/subject_color_picker.dart'; class LessonSheet { static void show( BuildContext context, TimetableState? state, Appointment appointment, McTimetableEntry lesson, { bool canEditSubjectColor = false, bool teacherPlan = false, void Function(TimetableElementRef element)? onOpenElement, }) { if (state == null) return; // In Fremdansichten würde der Farb-Button die eigene, globale Fach-Farbe // ändern, ohne dass sich der fremde Plan aktualisiert — daher nur im // eigenen Plan anbieten. Die Einfärbung (eigene Farben) bleibt. final bloc = context.read(); final subjectShort = lesson.subjects.firstOrNull; final subjectEntry = subjectShort == null ? null : state.subjects?.result .where((s) => s.shortName == subjectShort) .firstOrNull; final headerLong = subjectEntry?.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, trailing: canEditSubjectColor && subjectShort != null ? TimetableColorHeaderButton( initialColorName: subjectEntry?.color, pickerTitle: (headerLong != null && headerLong.isNotEmpty) ? 'Farbe: $headerLong' : 'Farbe: $subjectShort', pickerSubtitle: 'Nur reguläre Stunden werden eingefärbt', onSelected: (option) => bloc.setSubjectColor(subjectShort, option.name), onReset: () => bloc.clearSubjectColor(subjectShort), ) : null, ), children: (sheetContext) => [ 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), ..._peopleTiles( context, lesson, opener: onOpenElement == null ? null : _ElementOpener(sheetContext, onOpenElement), teacherPlan: teacherPlan, ), ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), ], ); } static Widget _roomTile(BuildContext context, McTimetableEntry lesson) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), tooltip: 'Raumplan öffnen', 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, ); } // Aus Lehrersicht ist die Klasse die relevante Angabe, die Lehrkraft ist // meist die Plan-Inhaberin selbst — daher dort Klasse zuerst. static List _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) { return const ListTile( leading: Icon(Icons.person), title: Text('Lehrkraft: ?'), ); } // Webuntis liefert entfallende Lehrkräfte bei Vertretungen teils mehrfach — // über die Anzeigewerte deduplizieren. final seen = {}; final teachers = <_TeacherDisplay>[]; for (final t in lesson.teachers) { final display = _TeacherDisplay.from(t); if (seen.add(display.dedupKey)) teachers.add(display); } final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte'; const tooltip = 'Stundenplan der Lehrkraft öffnen'; // Einzelne, reguläre Lehrkraft kompakt in der Titelzeile. if (teachers.length == 1 && teachers.first.isPlain) { return ListTile( leading: const Icon(Icons.person), 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), ); } return ListTile( leading: const Icon(Icons.person), title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final t in teachers) _openable(opener, t.buildRow(context), tooltip, t.target), ], ), ); } static Widget _listTileWithSubs({ required IconData icon, required String label, required List<({String main, String? sub})> entries, Widget? trailing, }) { if (entries.length == 1) { final e = entries.first; return ListTile( leading: Icon(icon), title: Text('$label: ${e.main}'), subtitle: e.sub != null ? Text(e.sub!) : null, trailing: trailing, ); } return ListTile( leading: Icon(icon), title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: entries .expand( (e) => [ Text(e.main), if (e.sub != null) Padding( padding: const EdgeInsets.only(left: 12), child: Text(e.sub!), ), ], ) .toList(), ), trailing: trailing, ); } static Widget _listTile({ required IconData icon, required String label, required List entries, Widget? trailing, Widget Function(int index, Widget row)? wrapRow, }) { 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: [ for (final (i, entry) in entries.indexed) wrapRow?.call(i, Text(entry)) ?? Text(entry), ], ), trailing: trailing, ); } static List _optionalTextTiles(McTimetableEntry lesson) { final sameText = collapseWhitespace(lesson.lessonText) == collapseWhitespace(lesson.substitutionText); return [ _textTile(Icons.info_outline, 'Info', lesson.infoText), _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText), if (!sameText) _textTile(Icons.subject, 'Stundentext', lesson.lessonText), _textTile( Icons.category_outlined, 'Stundentyp', _lessonTypeLabel(lesson.lessonType), ), ].whereType().toList(); } /// Marianum-Connect liefert den Stundentyp immer (Default `LESSON`). Den /// Standard blenden wir aus — sonst stünde unter jeder regulären Stunde /// derselbe Eintrag. Sonderfälle bekommen einen deutschen Klartext. static String? _lessonTypeLabel(String type) { switch (type) { case 'LESSON': return null; case 'OFFICE_HOUR': return 'Sprechstunde'; case 'STANDBY': return 'Bereitschaft'; case 'BREAK_SUPERVISION': return 'Pausenaufsicht'; case 'EXAM': return 'Prüfung'; default: return type; } } static Widget? _textTile(IconData icon, String label, String? value) { final text = (value ?? '').trim(); if (text.isEmpty || text == '-') return null; return ListTile( leading: Icon(icon), title: Text(label), subtitle: Text(text), ); } static String _line(String name, {String? longname}) { final parts = [if (name.isNotEmpty) name else '?']; final ln = (longname ?? '').trim(); if (ln.isNotEmpty && ln != name) parts.add('($ln)'); return parts.join(' '); } static String? _subjectLongName(dynamic subjects, String shortName) { if (subjects == null) return null; final list = subjects.result as Iterable; for (final s in list) { if (s.shortName == shortName) return s.longName as String?; } return null; } static IconData _iconForStatus(String status) { switch (status) { case 'CANCELLED': return Icons.event_busy_outlined; case 'IRREGULAR': return Icons.swap_horiz; default: return Icons.school_outlined; } } static String _statusLabel(String status) { switch (status) { case 'CANCELLED': return 'Entfällt'; case 'IRREGULAR': return 'Geändert'; default: return 'Regulär'; } } static String _statusPrefix(String status) { switch (status) { case 'CANCELLED': return 'Entfällt: '; case 'IRREGULAR': return 'Änderung: '; default: return ''; } } } /// Aufbereitete Darstellung einer einzelnen Lehrkraft aus einem /// Webuntis-Element. Trennt die drei Fälle regulär / Vertretung / Entfall, /// damit die Ansicht statt eines nackten `?` einen sprechenden Namen zeigt. class _TeacherDisplay { /// Ersetzte bzw. entfallende Lehrkraft — steht vorn und wird durchgestrichen. /// Leer bei einer regulären Lehrkraft. final String before; /// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall. final String 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; String get dedupKey => '$before|$after'; factory _TeacherDisplay.from(McTimetableTeacher t) { final current = _formatName(t.shortName, t.displayName); final original = _formatName( t.originalShortName ?? '', t.originalDisplayName ?? '', ); final target = _target(t, currentKnown: current.isNotEmpty); // Kein (abweichendes) Original → reguläre Lehrkraft. if (original.isEmpty || original == current) { return _TeacherDisplay._( after: current.isEmpty ? '?' : current, target: target, ); } // Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle // Lehrkraft (falls vorhanden) als Ersatz dahinter. 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 /// Ersatz ein Pfeil auf die neue Person. Bricht bei Bedarf sauber um. Widget buildRow(BuildContext context) { final muted = Theme.of(context).colorScheme.onSurfaceVariant; final spans = []; if (before.isNotEmpty) { spans.add( TextSpan( text: before, style: TextStyle( decoration: TextDecoration.lineThrough, color: muted, ), ), ); } if (after.isNotEmpty) { if (before.isNotEmpty) { spans.add(TextSpan(text: ' → ', style: TextStyle(color: muted))); } spans.add(TextSpan(text: after)); } return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('• '), Expanded(child: Text.rich(TextSpan(children: spans))), ], ), ); } /// Kombiniert Kurz- und Langname zu „Langname (Kurzname)“ und lässt leere /// Teile weg. Liefert `''`, wenn beides leer ist. static String _formatName(String short, String long) { short = short.trim(); long = long.trim(); // Ein wörtliches „?“ zählt wie ein fehlender Wert. if (short == '?') short = ''; if (long == '?') long = ''; if (long.isNotEmpty && short.isNotEmpty && long != short) { return '$long ($short)'; } if (long.isNotEmpty) return long; 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), ], ), ), ); } }