From b8cac73e74b0ddf7c87c2d6c0a750884c333934d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 22:53:24 +0200 Subject: [PATCH] updated timetable UI with event status and enhanced appointment tile rendering --- .../pages/timetable/data/lesson_color.dart | 3 + .../pages/timetable/data/lesson_status.dart | 10 +- .../data/timetable_appointment_factory.dart | 19 ++- .../timetable/widgets/appointment_tile.dart | 139 ++++++++++++++++-- .../widgets/custom_workweek_calendar.dart | 15 +- 5 files changed, 163 insertions(+), 23 deletions(-) diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart index cb4ad80..03cda8f 100644 --- a/lib/view/pages/timetable/data/lesson_color.dart +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -8,12 +8,15 @@ class LessonColor { static const Color cancelled = Color(0xff000000); static const Color irregular = Color(0xff8F19B3); static const Color teacherChanged = Color(0xFF29639B); + static const Color event = Color(0xff2E7D32); static const Color parseFallback = Color(0xff404040); static Color forStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: return cancelled; + case LessonStatus.event: + return event; case LessonStatus.irregular: return irregular; case LessonStatus.teacherChanged: diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 39eeb37..90e24d0 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -2,6 +2,7 @@ import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.da enum LessonStatus { cancelled, + event, irregular, teacherChanged, past, @@ -10,8 +11,15 @@ enum LessonStatus { } class LessonStatusClassifier { - static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) { + static LessonStatus classify( + GetTimetableResponseObject lesson, + DateTime startTime, + DateTime endTime, + DateTime now, { + bool isEvent = false, + }) { if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (isEvent) return LessonStatus.event; if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular; if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged; if (endTime.isBefore(now)) return LessonStatus.past; diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 258c359..ba1d1b6 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -42,13 +42,20 @@ class TimetableAppointmentFactory { try { final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); - final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now); + final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + final status = LessonStatusClassifier.classify( + lesson, + startTime, + endTime, + now, + isEvent: subject == null, + ); return Appointment( id: WebuntisAppointment(lesson), startTime: startTime, endTime: endTime, - subject: _subjectName(lesson), + subject: _subjectName(lesson, subject), location: _locationLabel(lesson), notes: lesson.activityType, color: LessonColor.forStatus(status), @@ -77,7 +84,10 @@ class TimetableAppointmentFactory { ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) : event.endDate, isAllDay: allDay, - location: _collapseWhitespace(event.description), + // Preserve user-entered newlines in descriptions; the tile soft-wraps to + // fill the available height. For lessons we still collapse whitespace + // so room/teacher stay on one line each. + location: event.description.trim().isEmpty ? null : event.description.trim(), subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: event.rrule, color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), @@ -104,8 +114,7 @@ class TimetableAppointmentFactory { e.second == 0; } - String _subjectName(GetTimetableResponseObject lesson) { - final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) { if (subject == null) return 'Event'; final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 180a6ac..bb24afb 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../data/arbitrary_appointment.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { static const _radius = BorderRadius.all(Radius.circular(7)); + static const _titleFontSize = 15.0; + static const _titleMinFontSize = 11.0; + static const _bodyFontSize = 10.0; + static const _bodyLineHeight = 1.15; final Appointment appointment; final bool crossedOut; @@ -15,12 +20,8 @@ class AppointmentTile extends StatelessWidget { Widget build(BuildContext context) { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); - - final locationLines = (appointment.location ?? '') - .split('\n') - .where((p) => p.isNotEmpty) - .take(2) - .toList(growable: false); + final isCustom = appointment.id is CustomAppointment; + final description = appointment.location ?? ''; return Padding( padding: const EdgeInsets.all(1), @@ -37,15 +38,33 @@ class AppointmentTile extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ - _ScaledLine( + _AdaptiveTitle( text: appointment.subject, - fontSize: 15, + fontSize: _titleFontSize, + minFontSize: _titleMinFontSize, fontWeight: FontWeight.w500, ), - for (final line in locationLines) - _ScaledLine(text: line, fontSize: 10), + if (isCustom) ...[ + if (description.isNotEmpty) + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: _WrappingBody( + text: description, + fontSize: _bodyFontSize, + lineHeight: _bodyLineHeight, + ), + ), + ), + ] else ...[ + for (final line in description + .split('\n') + .where((p) => p.isNotEmpty) + .take(2)) + _ScaledLine(text: line, fontSize: _bodyFontSize), + ], ], ), ), @@ -69,18 +88,111 @@ class AppointmentTile extends StatelessWidget { } } +/// Renders the appointment title. Scales down to fit the available width via +/// [FittedBox], but never below [minFontSize] — when even the minimum size +/// overflows, the text is rendered at [minFontSize] with an ellipsis. +class _AdaptiveTitle extends StatelessWidget { + final String text; + final double fontSize; + final double minFontSize; + final FontWeight? fontWeight; + + const _AdaptiveTitle({ + required this.text, + required this.fontSize, + required this.minFontSize, + this.fontWeight, + }); + + @override + Widget build(BuildContext context) { + final baseStyle = TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.1, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + // Probe at the minimum size: if even that overflows, we have to ellipsize. + final probe = TextPainter( + text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)), + textDirection: TextDirection.ltr, + maxLines: 1, + textScaler: textScaler, + )..layout(); + if (probe.width > constraints.maxWidth) { + return Text( + text, + style: baseStyle.copyWith(fontSize: minFontSize), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ); + } + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: baseStyle, + maxLines: 1, + softWrap: false, + ), + ); + }, + ); + } +} + +/// Body text for custom events. Wraps to fill the available height and clips +/// trailing content with an ellipsis if there is more than fits. +class _WrappingBody extends StatelessWidget { + final String text; + final double fontSize; + final double lineHeight; + + const _WrappingBody({ + required this.text, + required this.fontSize, + required this.lineHeight, + }); + + @override + Widget build(BuildContext context) { + final style = TextStyle( + color: Colors.white, + fontSize: fontSize, + height: lineHeight, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + final lineBox = textScaler.scale(fontSize) * lineHeight; + final maxLines = (constraints.maxHeight / lineBox).floor().clamp(1, 99); + return Text( + text, + style: style, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + softWrap: true, + ); + }, + ); + } +} + /// One row of appointment text. The FittedBox scales **only this line** down /// when the text is wider than the tile, so a long teacher name does not /// shrink the room number above it. class _ScaledLine extends StatelessWidget { final String text; final double fontSize; - final FontWeight? fontWeight; const _ScaledLine({ required this.text, required this.fontSize, - this.fontWeight, }); @override @@ -92,7 +204,6 @@ class _ScaledLine extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: fontSize, - fontWeight: fontWeight, height: 1.1, ), maxLines: 1, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 53c70ef..9c776ef 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -399,8 +399,17 @@ class _OutsideChip extends StatelessWidget { ? null : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + // Past chips fade further, future/ongoing ones get a more saturated tint + // so the strip no longer reads as one uniform grey block. + final isPast = appointment.endTime.isBefore(DateTime.now()); + final backgroundAlpha = isPast ? 38 : 120; + final subjectColor = isPast + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface; + final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; + return Material( - color: appointment.color.withAlpha(60), + color: appointment.color.withAlpha(backgroundAlpha), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(7)), ), @@ -419,8 +428,8 @@ class _OutsideChip extends StatelessWidget { overflow: TextOverflow.ellipsis, softWrap: false, style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w500, + color: subjectColor, + fontWeight: subjectWeight, ), ), ),