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:
@@ -4,7 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import 'custom_event_sheet.dart';
|
||||
import 'webuntis_lesson_sheet.dart';
|
||||
import 'lesson_sheet.dart';
|
||||
|
||||
class AppointmentDetailsDispatcher {
|
||||
static void show(
|
||||
@@ -16,8 +16,8 @@ class AppointmentDetailsDispatcher {
|
||||
if (id is! ArbitraryAppointment) return;
|
||||
|
||||
id.when(
|
||||
webuntis: (lesson) =>
|
||||
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
||||
lesson: (entry) =>
|
||||
LessonSheet.show(context, bloc, appointment, entry),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
||||
import '../../../../api/webuntis/services/lesson_resolver.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';
|
||||
|
||||
class WebuntisLessonSheet {
|
||||
static void show(
|
||||
BuildContext context,
|
||||
TimetableBloc bloc,
|
||||
Appointment appointment,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final state = bloc.state.data;
|
||||
if (state == null) return;
|
||||
|
||||
final headerSubject = LessonResolver.resolveSubject(
|
||||
state,
|
||||
lesson.su.firstOrNull?.id,
|
||||
);
|
||||
final headerTitle = firstNonEmpty([
|
||||
headerSubject.alternateName,
|
||||
headerSubject.name,
|
||||
headerSubject.longName,
|
||||
'?',
|
||||
]);
|
||||
final headerLongName =
|
||||
headerSubject.longName.isNotEmpty &&
|
||||
headerSubject.longName != headerTitle
|
||||
? headerSubject.longName
|
||||
: '';
|
||||
|
||||
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
|
||||
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: ListTile(
|
||||
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
|
||||
title: Text(
|
||||
'${LessonFormatter.codePrefix(lesson.code)}$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: ${LessonFormatter.statusLabel(lesson.code)}'),
|
||||
),
|
||||
if (lesson.su.length > 1)
|
||||
_listTile(
|
||||
icon: Icons.book_outlined,
|
||||
label: 'Fächer',
|
||||
entries: lesson.su.map((s) {
|
||||
final resolved = LessonResolver.resolveSubject(state, s.id);
|
||||
return LessonFormatter.formatLine(
|
||||
firstNonEmpty([resolved.name, s.name, '?']),
|
||||
longname: firstNonEmpty([resolved.longName, s.longname, '']),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
_roomTile(context, state, lesson),
|
||||
_teacherTile(lesson),
|
||||
if ((lesson.activityType ?? '').trim().isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.abc),
|
||||
title: Text('Typ: ${lesson.activityType}'),
|
||||
),
|
||||
if (lesson.kl.isNotEmpty)
|
||||
_listTile(
|
||||
icon: Icons.people,
|
||||
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
|
||||
entries: lesson.kl
|
||||
.map(
|
||||
(k) => LessonFormatter.formatLine(
|
||||
k.name.isNotEmpty ? k.name : '?',
|
||||
longname: k.longname,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
..._optionalTextTiles(lesson),
|
||||
DebugTile(context).jsonData(lesson.toJson()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _roomTile(
|
||||
BuildContext context,
|
||||
TimetableState state,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final trailing = IconButton(
|
||||
icon: const Icon(Icons.house_outlined),
|
||||
onPressed: () => AppRoutes.openRoomplan(context),
|
||||
);
|
||||
|
||||
if (lesson.ro.isEmpty) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.room),
|
||||
title: const Text('Raum: ?'),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
final entries = lesson.ro.map((r) {
|
||||
final resolved = LessonResolver.resolveRoom(state, r.id);
|
||||
final name = firstNonEmpty([resolved.name, r.name, '?']);
|
||||
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
|
||||
final building = resolved.building.trim();
|
||||
final main = LessonFormatter.formatLine(
|
||||
name,
|
||||
extra: (building.isNotEmpty && building != '?') ? building : null,
|
||||
);
|
||||
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
|
||||
return (main: main, sub: sub);
|
||||
}).toList();
|
||||
|
||||
return _listTileWithSubs(
|
||||
icon: Icons.room,
|
||||
label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
|
||||
entries: entries,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _teacherTile(GetTimetableResponseObject lesson) {
|
||||
if (lesson.te.isEmpty) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.person),
|
||||
title: Text('Lehrkraft: ?'),
|
||||
);
|
||||
}
|
||||
|
||||
final entries = lesson.te.map((t) {
|
||||
final main = LessonFormatter.formatLine(
|
||||
t.name.isNotEmpty ? t.name : '?',
|
||||
longname: t.longname,
|
||||
);
|
||||
final orgname = (t.orgname ?? '').trim();
|
||||
return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
|
||||
}).toList();
|
||||
|
||||
return _listTileWithSubs(
|
||||
icon: Icons.person,
|
||||
label: lesson.te.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(GetTimetableResponseObject lesson) {
|
||||
return <Widget?>[
|
||||
_textTile(Icons.info_outline, 'Info', lesson.info),
|
||||
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText),
|
||||
_textTile(Icons.subject, 'Stundentext', lesson.lstext),
|
||||
_textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype),
|
||||
_textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags),
|
||||
_textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg),
|
||||
_textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark),
|
||||
_textTile(Icons.notes, 'Buchungstext', lesson.bkText),
|
||||
].whereType<Widget>().toList();
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user