custom login implementation, period-based timetable layout with overlap handling, enhanced error dialogs, and unified bottom sheets

This commit is contained in:
2026-05-06 20:42:09 +02:00
parent 50d2941e52
commit 86d12884fc
32 changed files with 1038 additions and 377 deletions
@@ -115,7 +115,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
Navigator.of(context).pop();
}).catchError((Object error) {
if (!mounted) return;
InfoDialog.show(context, error.toString());
InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
});
}
@@ -6,3 +6,11 @@ const double kCalendarViewHeaderHeight = 60;
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
/// than compressing further.
const double kCalendarMinPxPerHour = 56;
/// Minimum height of a lesson block in the period-based layout. The grid
/// scrolls vertically once lessons would otherwise be smaller than this.
const double kLessonBlockMinHeight = 50;
/// Fixed height of a break block in the period-based layout. Independent of
/// the actual break duration; breaks are rendered as a compact indicator.
const double kBreakBlockHeight = 28;
@@ -72,8 +72,8 @@ class TimetableAppointmentFactory {
id: CustomAppointment(event),
startTime: event.startDate,
endTime: event.endDate,
location: event.description,
subject: event.title,
location: _collapseWhitespace(event.description),
subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
@@ -83,19 +83,38 @@ class TimetableAppointmentFactory {
String _subjectName(GetTimetableResponseObject lesson) {
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
if (subject == null) return 'Unbekannt';
return switch (settings.timetableNameMode) {
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
};
return _collapseWhitespace(name) ?? 'Unbekannt';
}
String _locationLabel(GetTimetableResponseObject lesson) {
final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt';
final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt';
final roomName = _collapseWhitespace(
rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ??
'Unbekannt';
final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
return '$roomName\n$teacherName';
}
/// Collapses any line-break or whitespace run to a single space and trims.
/// Returns null when input is null or fully whitespace. Webuntis sometimes
/// returns multi-line room names like "A30\n4" — this normalizes those so
/// the tile renders the room on a single line.
static String? _collapseWhitespace(String? s) {
if (s == null) return null;
final cleaned = s
.replaceAll('\r\n', ' ')
.replaceAll('\n', ' ')
.replaceAll('\r', ' ')
.replaceAll('\t', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned.isEmpty ? null : cleaned;
}
// Pure: returns a new list, does not mutate input.
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
@@ -1,51 +1,32 @@
import 'package:flutter/material.dart';
/// Shows a modal bottom sheet for an appointment, matching the design of the
/// other sheets in the app (file details, file actions, overflow lessons):
/// drag handle on top, default theme background, ListTile-style header
/// followed by a divider, scrollable body below.
void showAppointmentBottomSheet(
BuildContext context, {
required Widget Function(BuildContext context) header,
required SliverChildListDelegate Function(BuildContext context) body,
required Widget header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.4,
minChildSize: 0.2,
maxChildSize: 0.7,
snap: true,
snapSizes: const [0.4],
builder: (_, scrollController) => CustomScrollView(
controller: scrollController,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeader(child: header(sheetContext)),
),
SliverList(delegate: body(sheetContext)),
],
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
const Divider(height: 1),
...children(sheetContext),
],
),
),
),
);
}
class _StickyHeader extends SliverPersistentHeaderDelegate {
_StickyHeader({required this.child});
final Widget child;
@override
double get minExtent => 100;
@override
double get maxExtent => 100;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material(
color: Theme.of(context).colorScheme.surface,
child: SizedBox.expand(child: child),
);
@override
bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child;
}
@@ -11,51 +11,49 @@ import 'delete_custom_event.dart';
class CustomEventSheet {
static void show(BuildContext context, CustomTimetableEvent event) {
final timeRange =
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}';
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
Text(
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
),
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(timeRange),
),
body: (sheetCtx) => SliverChildListDelegate([
const Divider(),
Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(sheetCtx).pop();
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
if (!sheetCtx.mounted) return;
children: (sheetCtx) => [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
if (!sheetCtx.mounted) return;
Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
),
),
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
@@ -82,7 +80,7 @@ class CustomEventSheet {
),
),
DebugTile(sheetCtx).jsonData(event.toJson()),
]),
],
);
}
}
@@ -23,29 +23,24 @@ class WebuntisLessonSheet {
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
final timeRange =
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_codePrefix(lesson.code)}$headerTitle',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 25),
overflow: TextOverflow.ellipsis,
),
if (headerLongName.isNotEmpty) Text(headerLongName),
Text(
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
? '$timeRange\n$headerLongName'
: timeRange),
isThreeLine: headerLongName.isNotEmpty,
),
body: (_) => SliverChildListDelegate(<Widget>[
const Divider(),
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'),
@@ -82,10 +77,21 @@ class WebuntisLessonSheet {
),
..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()),
]),
],
);
}
static IconData _iconForCode(String? code) {
switch (code) {
case 'cancelled':
return Icons.event_busy_outlined;
case 'irregular':
return Icons.swap_horiz;
default:
return Icons.school_outlined;
}
}
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
@@ -193,7 +199,7 @@ class WebuntisLessonSheet {
static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim();
if (text.isEmpty) return null;
if (text.isEmpty || text == '-') return null;
return ListTile(
leading: Icon(icon),
title: Text(label),
@@ -4,6 +4,8 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7));
final Appointment appointment;
final bool crossedOut;
@@ -14,54 +16,51 @@ class AppointmentTile extends StatelessWidget {
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);
return Padding(
padding: const EdgeInsets.all(1),
child: Stack(
children: [
Positioned.fill(
child: Container(
padding: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
alignment: Alignment.topLeft,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(7)),
borderRadius: _radius,
color: color,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
appointment.subject,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
maxLines: 1,
softWrap: false,
),
),
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
appointment.location?.isNotEmpty == true ? appointment.location! : ' ',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_ScaledLine(
text: appointment.subject,
fontSize: 15,
fontWeight: FontWeight.w500,
),
for (final line in locationLines)
_ScaledLine(text: line, fontSize: 10),
],
),
),
),
if (crossedOut)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: ClipRRect(
borderRadius: _radius,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: _radius,
),
child: CustomPaint(painter: CrossPainter()),
),
child: CustomPaint(painter: CrossPainter()),
),
),
],
@@ -69,3 +68,35 @@ class AppointmentTile extends StatelessWidget {
);
}
}
/// 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
Widget build(BuildContext context) => FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: fontWeight,
height: 1.1,
),
maxLines: 1,
softWrap: false,
),
);
}
@@ -132,11 +132,23 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final hours = kCalendarEndHour - kCalendarStartHour;
final fitPxPerHour = constraints.maxHeight / hours;
final pxPerHour =
fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour;
final gridHeight = pxPerHour * hours;
final periods = widget.schedule.periods;
final lessonCount =
periods.where((p) => !p.isBreak).length;
final breakCount = periods.length - lessonCount;
final available =
constraints.maxHeight - breakCount * kBreakBlockHeight;
final fitLessonH =
lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight;
final lessonH = fitLessonH < kLessonBlockMinHeight
? kLessonBlockMinHeight
: fitLessonH;
final layout = _PeriodLayout(
periods: periods,
lessonHeight: lessonH,
breakHeight: kBreakBlockHeight,
);
final gridHeight = layout.totalHeight;
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@@ -163,7 +175,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
today: _today,
nowNotifier: _nowNotifier,
rulerWidth: _rulerWidth,
pxPerHour: pxPerHour,
layout: layout,
);
},
),
@@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget {
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final double rulerWidth;
final double pxPerHour;
final _PeriodLayout layout;
const _WeekGrid({
required this.weekStart,
@@ -284,7 +296,7 @@ class _WeekGrid extends StatelessWidget {
required this.today,
required this.nowNotifier,
required this.rulerWidth,
required this.pxPerHour,
required this.layout,
});
@override
@@ -296,7 +308,7 @@ class _WeekGrid extends StatelessWidget {
children: [
_PeriodRuler(
schedule: schedule,
pxPerHour: pxPerHour,
layout: layout,
width: rulerWidth,
),
for (var d = 0; d < 5; d++)
@@ -306,7 +318,7 @@ class _WeekGrid extends StatelessWidget {
schedule: schedule,
appointments: perDay[d],
timeRegions: timeRegions,
pxPerHour: pxPerHour,
layout: layout,
today: today,
nowNotifier: nowNotifier,
onAppointmentTap: onAppointmentTap,
@@ -321,18 +333,15 @@ class _WeekGrid extends StatelessWidget {
class _PeriodRuler extends StatelessWidget {
final LessonPeriodSchedule schedule;
final double pxPerHour;
final _PeriodLayout layout;
final double width;
const _PeriodRuler({
required this.schedule,
required this.pxPerHour,
required this.layout,
required this.width,
});
double _y(TimeOfDay t) =>
(t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -343,8 +352,8 @@ class _PeriodRuler extends StatelessWidget {
children: [
for (final period in schedule.periods)
Positioned(
top: _y(period.start),
height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity),
top: layout.topOf(period),
height: layout.heightOf(period),
left: 0,
right: 0,
child: _PeriodLabel(period: period, theme: theme),
@@ -450,7 +459,7 @@ class _DayColumn extends StatelessWidget {
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final double pxPerHour;
final _PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final void Function(Appointment) onAppointmentTap;
@@ -462,7 +471,7 @@ class _DayColumn extends StatelessWidget {
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.pxPerHour,
required this.layout,
required this.today,
required this.nowNotifier,
required this.onAppointmentTap,
@@ -470,66 +479,6 @@ class _DayColumn extends StatelessWidget {
required this.onCreateEvent,
});
double _y(int hour, int minute) =>
(hour + minute / 60 - kCalendarStartHour) * pxPerHour;
double _yFromDate(DateTime t) => _y(t.hour, t.minute);
/// Snaps an appointment edge to the nearest period boundary if the gap is small,
/// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually.
double _yForAppointmentEdge(DateTime t, {required bool isStart}) {
final tMin = t.hour * 60 + t.minute;
for (final period in schedule.periods) {
if (period.isBreak) continue;
final pStart = period.start.hour * 60 + period.start.minute;
final pEnd = period.end.hour * 60 + period.end.minute;
if (isStart) {
final delta = tMin - pStart;
if (delta >= 0 && delta < 5) {
return _y(period.start.hour, period.start.minute);
}
} else {
final delta = pEnd - tMin;
if (delta >= 0 && delta < 5) {
// Snap to the next non-break period's start when the gap is short
// (Wechselzeit). Skips into a break never extends the lesson.
final idx = schedule.periods.indexOf(period);
if (idx + 1 < schedule.periods.length) {
final next = schedule.periods[idx + 1];
if (!next.isBreak) {
final nextStart = next.start.hour * 60 + next.start.minute;
if (nextStart - pEnd < 10) {
return _y(next.start.hour, next.start.minute);
}
}
}
}
}
}
return _yFromDate(t);
}
/// Returns the lesson period (non-break) that the given y-offset falls into,
/// or the next upcoming non-break period if y falls inside a break or before
/// the first period. Returns null if y is past the last period of the day.
LessonPeriod? _periodAt(double y) {
final hoursDecimal = y / pxPerHour + kCalendarStartHour;
final tappedMinutes = (hoursDecimal * 60).round();
LessonPeriod? upcoming;
for (final p in schedule.periods) {
if (p.isBreak) continue;
final pStart = p.start.hour * 60 + p.start.minute;
final pEnd = p.end.hour * 60 + p.end.minute;
if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p;
if (tappedMinutes < pStart) {
upcoming = p;
break;
}
}
return upcoming;
}
bool _overlapsExistingAppointment(DateTime start, DateTime end, List<Appointment> dayAppts) {
for (final a in dayAppts) {
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
@@ -539,7 +488,7 @@ class _DayColumn extends StatelessWidget {
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
if (onCreateEvent == null) return;
final period = _periodAt(details.localPosition.dy);
final period = layout.periodAtY(details.localPosition.dy);
if (period == null) return;
final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute);
@@ -550,6 +499,56 @@ class _DayColumn extends StatelessWidget {
onCreateEvent!(start, end);
}
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: sorted.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
final apt = sorted[i];
return ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
),
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_overflowSubtitle(apt)),
onTap: () {
Navigator.of(sheetContext).pop();
onAppointmentTap(apt);
},
);
},
),
),
);
}
static String _overflowSubtitle(Appointment apt) {
final time = '${_formatHm(apt.startTime)}${_formatHm(apt.endTime)}';
final loc = apt.location?.replaceAll('\n', ' · ');
return loc != null && loc.isNotEmpty ? '$time · $loc' : time;
}
static String _formatHm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -558,6 +557,8 @@ class _DayColumn extends StatelessWidget {
final dayRegions = _expandRegionsForDay(timeRegions, date);
final isToday = _isSameDay(date, today);
final laidOut = _assignLanes(dayAppointments);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
@@ -566,52 +567,66 @@ class _DayColumn extends StatelessWidget {
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
),
child: Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: _y(period.start.hour, period.start.minute),
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
),
),
for (final region in dayRegions)
Positioned(
top: _yFromDate(region.start),
height:
(_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final apt in dayAppointments)
Positioned(
top: _yForAppointmentEdge(apt.startTime, isStart: true),
height: (_yForAppointmentEdge(apt.endTime, isStart: false) -
_yForAppointmentEdge(apt.startTime, isStart: true))
.clamp(0, double.infinity),
left: 1,
right: 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(apt),
child: AppointmentTile(
appointment: apt,
crossedOut: isCrossedOut(apt),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
),
),
),
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) =>
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme),
),
],
for (final region in dayRegions)
Positioned(
top: layout.yOfDateTime(region.start),
height: (layout.yOfDateTime(region.end) -
layout.yOfDateTime(region.start))
.clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final cell in laidOut)
Positioned(
top: layout.yOfDateTime(cell.startTime),
height: (layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
_LaidOutAppointment(:final appointment) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(appointment),
child: AppointmentTile(
appointment: appointment,
crossedOut: isCrossedOut(appointment),
),
),
_LaidOutOverflow(:final appointments) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
_showOverflowSheet(context, appointments),
child: _OverflowTile(count: appointments.length),
),
},
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) =>
_CurrentTimeMarker(now: now, layout: layout, theme: theme),
),
],
);
},
),
),
);
@@ -620,20 +635,27 @@ class _DayColumn extends StatelessWidget {
class _CurrentTimeMarker extends StatelessWidget {
final DateTime now;
final double pxPerHour;
final _PeriodLayout layout;
final ThemeData theme;
const _CurrentTimeMarker({
required this.now,
required this.pxPerHour,
required this.layout,
required this.theme,
});
@override
Widget build(BuildContext context) {
final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour;
final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour;
if (y < 0 || y > maxY) return const SizedBox.shrink();
final periods = layout.periods;
if (periods.isEmpty) return const SizedBox.shrink();
final tMin = now.hour * 60 + now.minute;
final firstStart =
periods.first.start.hour * 60 + periods.first.start.minute;
final lastEnd =
periods.last.end.hour * 60 + periods.last.end.minute;
if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink();
final y = layout.yOfDateTime(now);
return AnimatedPositioned(
duration: const Duration(milliseconds: 400),
@@ -759,3 +781,278 @@ List<List<Appointment>> _expandAppointmentsForWeek(
}
return perDay;
}
/// Maps lesson periods to vertical screen positions. Every non-break period
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
/// Short transition gaps (Wechselzeiten) between periods are not represented
/// at all — periods are rendered back-to-back, so a 5-minute gap simply
/// disappears visually.
class _PeriodLayout {
final List<LessonPeriod> periods;
final double lessonHeight;
final double breakHeight;
const _PeriodLayout({
required this.periods,
required this.lessonHeight,
required this.breakHeight,
});
double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;
double get totalHeight =>
periods.fold<double>(0, (sum, p) => sum + _h(p));
double topOf(LessonPeriod period) {
var y = 0.0;
for (final p in periods) {
if (identical(p, period)) return y;
y += _h(p);
}
return y;
}
double heightOf(LessonPeriod period) => _h(period);
/// Vertical offset for a given time of day. Times inside a period are mapped
/// proportionally; times that fall into a transition gap are clipped to the
/// end of the preceding period. Times before the first / after the last
/// period clip to 0 / [totalHeight].
double yOf(TimeOfDay t) {
final tMin = t.hour * 60 + t.minute;
var y = 0.0;
for (final p in periods) {
final pStart = p.start.hour * 60 + p.start.minute;
final pEnd = p.end.hour * 60 + p.end.minute;
final h = _h(p);
if (tMin < pStart) return y;
if (tMin <= pEnd) {
final span = pEnd - pStart;
final ratio = span > 0 ? (tMin - pStart) / span : 0.0;
return y + ratio * h;
}
y += h;
}
return y;
}
double yOfDateTime(DateTime t) =>
yOf(TimeOfDay(hour: t.hour, minute: t.minute));
/// Period at a given y-offset. If y falls into a break, returns the next
/// non-break period. Returns null when y is past the last period.
LessonPeriod? periodAtY(double y) {
var cursor = 0.0;
for (var i = 0; i < periods.length; i++) {
final p = periods[i];
final h = _h(p);
if (y >= cursor && y < cursor + h) {
if (p.isBreak) {
for (var j = i + 1; j < periods.length; j++) {
if (!periods[j].isBreak) return periods[j];
}
return null;
}
return p;
}
cursor += h;
}
return null;
}
}
/// Maximum number of cells shown side by side in a single time slot. When a
/// cluster needs more lanes than this, the first appointment (by start time)
/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell
/// in lane 1.
const int _kMaxVisibleCells = 2;
class _OverflowTile extends StatelessWidget {
final int count;
const _OverflowTile({required this.count});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
const radius = BorderRadius.all(Radius.circular(7));
return Padding(
padding: const EdgeInsets.all(1),
child: Stack(
children: [
// Card peeking out at the bottom — visual hint that more cards lie
// underneath the visible one.
Positioned(
top: 4,
left: 2,
right: 2,
bottom: 0,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
color: scheme.secondaryContainer.withAlpha(120),
),
),
),
// Front card with the "+N" indicator.
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 4,
child: Container(
decoration: BoxDecoration(
borderRadius: radius,
color: scheme.secondaryContainer,
),
alignment: Alignment.center,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.unfold_more_rounded,
size: 18,
color: scheme.onSecondaryContainer,
),
Text(
'+$count',
style: theme.textTheme.titleSmall?.copyWith(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
],
),
),
),
),
),
],
),
);
}
}
/// One cell rendered in the day column — either a regular appointment or an
/// overflow placeholder representing several hidden appointments.
sealed class _LaidOutCell {
int get lane;
int get laneCount;
DateTime get startTime;
DateTime get endTime;
}
class _LaidOutAppointment extends _LaidOutCell {
final Appointment appointment;
@override
final int lane;
@override
final int laneCount;
_LaidOutAppointment(this.appointment, this.lane, this.laneCount);
@override
DateTime get startTime => appointment.startTime;
@override
DateTime get endTime => appointment.endTime;
}
class _LaidOutOverflow extends _LaidOutCell {
final List<Appointment> appointments;
@override
final int lane;
@override
final int laneCount;
@override
final DateTime startTime;
@override
final DateTime endTime;
_LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime);
}
/// Assigns each appointment a lane index using a greedy sweep, then collapses
/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1
/// overflow cell side by side.
///
/// Greedy sweep:
/// 1. Sort by `startTime` ascending, `endTime` descending on ties.
/// 2. Walk the list, placing each appointment in the lowest-index lane that
/// is free at its `startTime`. When no lane is free, open a new one.
/// 3. A cluster ends as soon as every active lane's end is at or before the
/// next appointment's start.
List<_LaidOutCell> _assignLanes(List<Appointment> appts) {
if (appts.isEmpty) return const <_LaidOutCell>[];
final sorted = [...appts]..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
return b.endTime.compareTo(a.endTime);
});
// Phase 1: greedy lane assignment, grouped by cluster.
final clusters = <List<({Appointment apt, int lane})>>[];
var current = <({Appointment apt, int lane})>[];
var laneEnds = <DateTime>[];
for (final apt in sorted) {
final allFree =
laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime));
if (allFree) {
clusters.add(current);
current = <({Appointment apt, int lane})>[];
laneEnds = <DateTime>[];
}
var laneIdx = -1;
for (var i = 0; i < laneEnds.length; i++) {
if (!laneEnds[i].isAfter(apt.startTime)) {
laneIdx = i;
break;
}
}
if (laneIdx == -1) {
laneIdx = laneEnds.length;
laneEnds.add(apt.endTime);
} else {
laneEnds[laneIdx] = apt.endTime;
}
current.add((apt: apt, lane: laneIdx));
}
if (current.isNotEmpty) clusters.add(current);
// Phase 2: emit cells per cluster, collapsing if too wide.
final result = <_LaidOutCell>[];
for (final cluster in clusters) {
final laneCount =
cluster.fold<int>(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m);
if (laneCount <= _kMaxVisibleCells) {
for (final entry in cluster) {
result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount));
}
} else {
// 3+ parallel appointments: keep the earliest, collapse the rest.
final byStart = [...cluster.map((e) => e.apt)]
..sort((a, b) => a.startTime.compareTo(b.startTime));
result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells));
final overflow = byStart.sublist(1);
var earliest = overflow.first.startTime;
var latest = overflow.first.endTime;
for (final a in overflow.skip(1)) {
if (a.startTime.isBefore(earliest)) earliest = a.startTime;
if (a.endTime.isAfter(latest)) latest = a.endTime;
}
result.add(_LaidOutOverflow(
overflow, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest));
}
}
return result;
}