403 lines
13 KiB
Dart
403 lines
13 KiB
Dart
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_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_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,
|
|
}) {
|
|
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<TimetableBloc>();
|
|
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: (_) => <Widget>[
|
|
ListTile(
|
|
leading: const Icon(Icons.notifications_active),
|
|
title: Text('Status: ${_statusLabel(lesson.status)}'),
|
|
),
|
|
if (lesson.subjects.length > 1)
|
|
_listTile(
|
|
icon: Icons.book_outlined,
|
|
label: 'Fächer',
|
|
entries: lesson.subjects
|
|
.map(
|
|
(s) => _line(s, longname: _subjectLongName(state.subjects, s)),
|
|
)
|
|
.toList(),
|
|
),
|
|
_roomTile(context, lesson),
|
|
_teacherTile(context, lesson),
|
|
if (lesson.classNames.isNotEmpty)
|
|
_listTile(
|
|
icon: Icons.people,
|
|
label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen',
|
|
entries: lesson.classNames.map(_line).toList(),
|
|
),
|
|
..._optionalTextTiles(lesson),
|
|
DebugTile(context).jsonData(lesson.toJson()),
|
|
],
|
|
);
|
|
}
|
|
|
|
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
|
|
final trailing = IconButton(
|
|
icon: const Icon(Icons.house_outlined),
|
|
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,
|
|
);
|
|
}
|
|
|
|
static Widget _teacherTile(BuildContext context, McTimetableEntry lesson) {
|
|
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 = <String>{};
|
|
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';
|
|
|
|
// 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}'),
|
|
);
|
|
}
|
|
|
|
return ListTile(
|
|
leading: const Icon(Icons.person),
|
|
title: Text(label),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [for (final t in teachers) t.buildRow(context)],
|
|
),
|
|
);
|
|
}
|
|
|
|
static Widget _listTileWithSubs({
|
|
required IconData icon,
|
|
required String label,
|
|
required List<({String main, String? sub})> entries,
|
|
Widget? trailing,
|
|
}) {
|
|
if (entries.length == 1) {
|
|
final e = entries.first;
|
|
return ListTile(
|
|
leading: Icon(icon),
|
|
title: Text('$label: ${e.main}'),
|
|
subtitle: e.sub != null ? Text(e.sub!) : null,
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
return ListTile(
|
|
leading: Icon(icon),
|
|
title: Text(label),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: entries
|
|
.expand<Widget>(
|
|
(e) => [
|
|
Text(e.main),
|
|
if (e.sub != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 12),
|
|
child: Text(e.sub!),
|
|
),
|
|
],
|
|
)
|
|
.toList(),
|
|
),
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
|
|
static Widget _listTile({
|
|
required IconData icon,
|
|
required String label,
|
|
required List<String> entries,
|
|
Widget? trailing,
|
|
}) {
|
|
if (entries.length == 1) {
|
|
return ListTile(
|
|
leading: Icon(icon),
|
|
title: Text('$label: ${entries.first}'),
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
return ListTile(
|
|
leading: Icon(icon),
|
|
title: Text(label),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: entries.map<Widget>(Text.new).toList(),
|
|
),
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
|
|
static List<Widget> _optionalTextTiles(McTimetableEntry lesson) {
|
|
return <Widget?>[
|
|
_textTile(Icons.info_outline, 'Info', lesson.infoText),
|
|
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText),
|
|
_textTile(Icons.subject, 'Stundentext', lesson.lessonText),
|
|
_textTile(
|
|
Icons.category_outlined,
|
|
'Stundentyp',
|
|
_lessonTypeLabel(lesson.lessonType),
|
|
),
|
|
].whereType<Widget>().toList();
|
|
}
|
|
|
|
/// Marianum-Connect liefert den Stundentyp immer (Default `LESSON`). Den
|
|
/// Standard blenden wir aus — sonst stünde unter jeder regulären Stunde
|
|
/// derselbe Eintrag. Sonderfälle bekommen einen deutschen Klartext.
|
|
static String? _lessonTypeLabel(String type) {
|
|
switch (type) {
|
|
case 'LESSON':
|
|
return null;
|
|
case 'OFFICE_HOUR':
|
|
return 'Sprechstunde';
|
|
case 'STANDBY':
|
|
return 'Bereitschaft';
|
|
case 'BREAK_SUPERVISION':
|
|
return 'Pausenaufsicht';
|
|
case 'EXAM':
|
|
return 'Prüfung';
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
|
|
static Widget? _textTile(IconData icon, String label, String? value) {
|
|
final text = (value ?? '').trim();
|
|
if (text.isEmpty || text == '-') return null;
|
|
return ListTile(
|
|
leading: Icon(icon),
|
|
title: Text(label),
|
|
subtitle: Text(text),
|
|
);
|
|
}
|
|
|
|
static String _line(String name, {String? longname}) {
|
|
final parts = <String>[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<dynamic>;
|
|
for (final s in list) {
|
|
if (s.shortName == shortName) return s.longName as String?;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static IconData _iconForStatus(String status) {
|
|
switch (status) {
|
|
case 'CANCELLED':
|
|
return Icons.event_busy_outlined;
|
|
case 'IRREGULAR':
|
|
return Icons.swap_horiz;
|
|
default:
|
|
return Icons.school_outlined;
|
|
}
|
|
}
|
|
|
|
static String _statusLabel(String status) {
|
|
switch (status) {
|
|
case 'CANCELLED':
|
|
return 'Entfällt';
|
|
case 'IRREGULAR':
|
|
return 'Geändert';
|
|
default:
|
|
return 'Regulär';
|
|
}
|
|
}
|
|
|
|
static String _statusPrefix(String status) {
|
|
switch (status) {
|
|
case 'CANCELLED':
|
|
return 'Entfällt: ';
|
|
case 'IRREGULAR':
|
|
return 'Änderung: ';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
const _TeacherDisplay._({this.before = '', this.after = ''});
|
|
|
|
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 ?? '',
|
|
);
|
|
|
|
// Kein (abweichendes) Original → reguläre Lehrkraft.
|
|
if (original.isEmpty || original == current) {
|
|
return _TeacherDisplay._(after: current.isEmpty ? '?' : current);
|
|
}
|
|
// Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle
|
|
// Lehrkraft (falls vorhanden) als Ersatz dahinter.
|
|
return _TeacherDisplay._(before: original, after: current);
|
|
}
|
|
|
|
/// 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 = <InlineSpan>[];
|
|
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;
|
|
}
|
|
}
|