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

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/lesson_type_label.dart';
class LessonSheet {
static void show(
BuildContext context,
TimetableBloc bloc,
Appointment appointment,
McTimetableEntry lesson,
) {
final state = bloc.state.data;
if (state == null) return;
final subjectShort = lesson.subjects.firstOrNull;
final headerLong = subjectShort == null
? null
: state.subjects?.result
.where((s) => s.shortName == subjectShort)
.firstOrNull
?.longName;
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
// einsetzen — sonst stünde im Header nur ein generisches "?".
final headerTitle = subjectShort != null
? firstNonEmpty([subjectShort, headerLong, '?'])
: LessonTypeLabel.forEntry(lesson);
final headerLongName =
(headerLong != null && headerLong.isNotEmpty && headerLong != headerTitle)
? headerLong
: '';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(_iconForStatus(lesson.status), size: 32),
title: Text(
'${_statusPrefix(lesson.status)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
),
isThreeLine: headerLongName.isNotEmpty,
),
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.status)}'),
),
if (lesson.subjects.length > 1)
_listTile(
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.subjects
.map(
(s) => _line(s, longname: _subjectLongName(state.subjects, s)),
)
.toList(),
),
_roomTile(context, lesson),
_teacherTile(lesson),
if (lesson.classNames.isNotEmpty)
_listTile(
icon: Icons.people,
label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.classNames.map(_line).toList(),
),
..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()),
],
);
}
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () => AppRoutes.openRoomplan(context),
);
if (lesson.rooms.isEmpty) {
return ListTile(
leading: const Icon(Icons.room),
title: const Text('Raum: ?'),
trailing: trailing,
);
}
final entries = lesson.rooms
.map((name) => (main: _line(name), sub: null as String?))
.toList();
return _listTileWithSubs(
icon: Icons.room,
label: lesson.rooms.length == 1 ? 'Raum' : 'Räume',
entries: entries,
trailing: trailing,
);
}
static Widget _teacherTile(McTimetableEntry lesson) {
if (lesson.teachers.isEmpty) {
return const ListTile(
leading: Icon(Icons.person),
title: Text('Lehrkraft: ?'),
);
}
final entries = lesson.teachers.map((t) {
final shortName = t.shortName.isEmpty ? '?' : t.shortName;
final longName = t.displayName.trim();
final orgShort = (t.originalShortName ?? '').trim();
final orgLong = (t.originalDisplayName ?? '').trim();
final subLines = <String>[];
if (longName.isNotEmpty && longName != shortName) {
subLines.add(longName);
}
if (orgShort.isNotEmpty) {
final label = orgLong.isEmpty || orgLong == orgShort
? orgShort
: '$orgShort · $orgLong';
subLines.add('ehemals $label');
}
return (
main: shortName,
sub: subLines.isEmpty ? null : subLines.join('\n'),
);
}).toList();
return _listTileWithSubs(
icon: Icons.person,
label: lesson.teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing,
);
}
static Widget _listTile({
required IconData icon,
required String label,
required List<String> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
return ListTile(
leading: Icon(icon),
title: Text('$label: ${entries.first}'),
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries.map<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(McTimetableEntry lesson) {
return <Widget?>[
_textTile(Icons.info_outline, 'Info', lesson.infoText),
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText),
_textTile(Icons.subject, 'Stundentext', lesson.lessonText),
_textTile(
Icons.category_outlined,
'Stundentyp',
_lessonTypeLabel(lesson.lessonType),
),
].whereType<Widget>().toList();
}
/// Marianum-Connect liefert den Stundentyp immer (Default `LESSON`). Den
/// Standard blenden wir aus — sonst stünde unter jeder regulären Stunde
/// derselbe Eintrag. Sonderfälle bekommen einen deutschen Klartext.
static String? _lessonTypeLabel(String type) {
switch (type) {
case 'LESSON':
return null;
case 'OFFICE_HOUR':
return 'Sprechstunde';
case 'STANDBY':
return 'Bereitschaft';
case 'BREAK_SUPERVISION':
return 'Pausenaufsicht';
case 'EXAM':
return 'Prüfung';
default:
return type;
}
}
static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim();
if (text.isEmpty || text == '-') return null;
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Text(text),
);
}
static String _line(String name, {String? longname, String? extra}) {
final parts = <String>[if (name.isNotEmpty) name else '?'];
final ln = (longname ?? '').trim();
if (ln.isNotEmpty && ln != name) parts.add('($ln)');
final ex = (extra ?? '').trim();
if (ex.isNotEmpty) parts.add('· $ex');
return parts.join(' ');
}
static String? _subjectLongName(dynamic subjects, String shortName) {
if (subjects == null) return null;
final list = subjects.result as Iterable<dynamic>;
for (final s in list) {
if (s.shortName == shortName) return s.longName as String?;
}
return null;
}
static IconData _iconForStatus(String status) {
switch (status) {
case 'CANCELLED':
return Icons.event_busy_outlined;
case 'IRREGULAR':
return Icons.swap_horiz;
default:
return Icons.school_outlined;
}
}
static String _statusLabel(String status) {
switch (status) {
case 'CANCELLED':
return 'Entfällt';
case 'IRREGULAR':
return 'Geändert';
default:
return 'Regulär';
}
}
static String _statusPrefix(String status) {
switch (status) {
case 'CANCELLED':
return 'Entfällt: ';
case 'IRREGULAR':
return 'Änderung: ';
default:
return '';
}
}
}