updated timetable UI with event status and enhanced appointment tile rendering

This commit is contained in:
2026-05-06 22:53:24 +02:00
parent 95ef29fb09
commit b8cac73e74
5 changed files with 163 additions and 23 deletions
@@ -8,12 +8,15 @@ class LessonColor {
static const Color cancelled = Color(0xff000000); static const Color cancelled = Color(0xff000000);
static const Color irregular = Color(0xff8F19B3); static const Color irregular = Color(0xff8F19B3);
static const Color teacherChanged = Color(0xFF29639B); static const Color teacherChanged = Color(0xFF29639B);
static const Color event = Color(0xff2E7D32);
static const Color parseFallback = Color(0xff404040); static const Color parseFallback = Color(0xff404040);
static Color forStatus(LessonStatus status) { static Color forStatus(LessonStatus status) {
switch (status) { switch (status) {
case LessonStatus.cancelled: case LessonStatus.cancelled:
return cancelled; return cancelled;
case LessonStatus.event:
return event;
case LessonStatus.irregular: case LessonStatus.irregular:
return irregular; return irregular;
case LessonStatus.teacherChanged: case LessonStatus.teacherChanged:
@@ -2,6 +2,7 @@ import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.da
enum LessonStatus { enum LessonStatus {
cancelled, cancelled,
event,
irregular, irregular,
teacherChanged, teacherChanged,
past, past,
@@ -10,8 +11,15 @@ enum LessonStatus {
} }
class LessonStatusClassifier { 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 (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.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 (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged;
if (endTime.isBefore(now)) return LessonStatus.past; if (endTime.isBefore(now)) return LessonStatus.past;
@@ -42,13 +42,20 @@ class TimetableAppointmentFactory {
try { try {
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); 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( return Appointment(
id: WebuntisAppointment(lesson), id: WebuntisAppointment(lesson),
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
subject: _subjectName(lesson), subject: _subjectName(lesson, subject),
location: _locationLabel(lesson), location: _locationLabel(lesson),
notes: lesson.activityType, notes: lesson.activityType,
color: LessonColor.forStatus(status), color: LessonColor.forStatus(status),
@@ -77,7 +84,10 @@ class TimetableAppointmentFactory {
? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59)
: event.endDate, : event.endDate,
isAllDay: allDay, 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, subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule, recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
@@ -104,8 +114,7 @@ class TimetableAppointmentFactory {
e.second == 0; e.second == 0;
} }
String _subjectName(GetTimetableResponseObject lesson) { String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) {
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
if (subject == null) return 'Event'; if (subject == null) return 'Event';
final name = switch (settings.timetableNameMode) { final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name, TimetableNameMode.name => subject.name,
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/arbitrary_appointment.dart';
import 'cross_painter.dart'; import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget { class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7)); 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 Appointment appointment;
final bool crossedOut; final bool crossedOut;
@@ -15,12 +20,8 @@ class AppointmentTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isPast = appointment.endTime.isBefore(DateTime.now()); final isPast = appointment.endTime.isBefore(DateTime.now());
final color = appointment.color.withAlpha(isPast ? 160 : 255); final color = appointment.color.withAlpha(isPast ? 160 : 255);
final isCustom = appointment.id is CustomAppointment;
final locationLines = (appointment.location ?? '') final description = appointment.location ?? '';
.split('\n')
.where((p) => p.isNotEmpty)
.take(2)
.toList(growable: false);
return Padding( return Padding(
padding: const EdgeInsets.all(1), padding: const EdgeInsets.all(1),
@@ -37,15 +38,33 @@ class AppointmentTile extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.max,
children: [ children: [
_ScaledLine( _AdaptiveTitle(
text: appointment.subject, text: appointment.subject,
fontSize: 15, fontSize: _titleFontSize,
minFontSize: _titleMinFontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
for (final line in locationLines) if (isCustom) ...[
_ScaledLine(text: line, fontSize: 10), 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 /// 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 /// when the text is wider than the tile, so a long teacher name does not
/// shrink the room number above it. /// shrink the room number above it.
class _ScaledLine extends StatelessWidget { class _ScaledLine extends StatelessWidget {
final String text; final String text;
final double fontSize; final double fontSize;
final FontWeight? fontWeight;
const _ScaledLine({ const _ScaledLine({
required this.text, required this.text,
required this.fontSize, required this.fontSize,
this.fontWeight,
}); });
@override @override
@@ -92,7 +204,6 @@ class _ScaledLine extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: fontSize, fontSize: fontSize,
fontWeight: fontWeight,
height: 1.1, height: 1.1,
), ),
maxLines: 1, maxLines: 1,
@@ -399,8 +399,17 @@ class _OutsideChip extends StatelessWidget {
? null ? null
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; : '${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( return Material(
color: appointment.color.withAlpha(60), color: appointment.color.withAlpha(backgroundAlpha),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7)), borderRadius: BorderRadius.all(Radius.circular(7)),
), ),
@@ -419,8 +428,8 @@ class _OutsideChip extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: false, softWrap: false,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface, color: subjectColor,
fontWeight: FontWeight.w500, fontWeight: subjectWeight,
), ),
), ),
), ),