refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
@@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget {
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}',
'beginnend ${e.startDate.formatRelative()}',
),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
@@ -0,0 +1,356 @@
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../extensions/date_time.dart';
import 'arbitrary_appointment.dart';
import 'calendar_layout.dart';
import 'lesson_period_schedule.dart';
/// Either explicitly marked as all-day, or so long it's effectively a full
/// day from the user's perspective. We compare in minutes (not hours) because
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
bool isAllDayLike(Appointment a) =>
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
/// True when the appointment doesn't fit into the school-hours grid:
/// all-day, fully before the grid start, fully after the grid end, engulfing
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
/// event the source system happens to represent with explicit times).
bool isOutsideSchoolHours(Appointment a) {
if (isAllDayLike(a)) return true;
final schoolStart = (kCalendarStartHour * 60).round();
final schoolEnd = (kCalendarEndHour * 60).round();
final startMin = a.startTime.hour * 60 + a.startTime.minute;
final endMin = a.endTime.hour * 60 + a.endTime.minute;
if (endMin <= schoolStart) return true;
if (startMin >= schoolEnd) return true;
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
return false;
}
int dayIndex(DateTime t, DateTime weekStart) =>
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
class BoundRegion {
final TimeRegion region;
final DateTime start;
final DateTime end;
BoundRegion({required this.region, required this.start, required this.end});
}
List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
final result = <BoundRegion>[];
final dayStart = DateTime(day.year, day.month, day.day);
for (final region in regions) {
final isRecurringDaily = region.recurrenceRule != null &&
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
if (isRecurringDaily) {
final start = dayStart.add(Duration(
hours: region.startTime.hour,
minutes: region.startTime.minute,
));
final end = dayStart.add(Duration(
hours: region.endTime.hour,
minutes: region.endTime.minute,
));
result.add(BoundRegion(region: region, start: start, end: end));
} else if (region.startTime.isSameDay(day)) {
result.add(BoundRegion(
region: region,
start: region.startTime,
end: region.endTime,
));
}
}
return result;
}
/// Expands the given list of appointments across the visible 5-day work week
/// (resolving RRULE recurrences) and splits each day's events into two
/// buckets: those that fit within the school-hours grid (`inside`) and those
/// that don't (`outside` — all-day events and events that start before
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
/// is rendered as chips above the grid.
({List<List<Appointment>> inside, List<List<Appointment>> outside})
partitionAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final weekEnd = weekStart.add(const Duration(days: 5));
final weekStartUtc = weekStart.toUtc();
final weekEndUtc = weekEnd.toUtc();
void place(int idx, Appointment a) {
if (isOutsideSchoolHours(a)) {
outside[idx].add(a);
} else {
inside[idx].add(a);
}
}
for (final a in appointments) {
final rule = a.recurrenceRule;
if (rule == null || rule.isEmpty) {
final idx = dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
continue;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = a.startTime.toUtc();
final duration = a.endTime.difference(a.startTime);
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(weekEndUtc)) break;
if (occUtc.isBefore(weekStartUtc)) continue;
final occLocal = occUtc.toLocal();
final idx = DateTime(occLocal.year, occLocal.month, occLocal.day)
.difference(weekStart)
.inDays;
if (idx < 0 || idx >= 5) continue;
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
a.startTime.hour, a.startTime.minute);
place(
idx,
Appointment(
id: a.id,
startTime: newStart,
endTime: newStart.add(duration),
subject: a.subject,
color: a.color,
location: a.location,
notes: a.notes,
isAllDay: a.isAllDay,
),
);
}
} catch (_) {
final idx = dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
}
}
return (inside: inside, outside: outside);
}
/// 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 yOfDateTime(DateTime t) {
final tMin = t.hour * 60 + t.minute + t.second / 60.0;
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;
}
/// 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;
}
}
/// 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);
}
/// Horizontal ordering rank for parallel appointments. Lower = further left.
/// User-owned custom events sit on the leftmost lane, cancelled lessons after
/// them, every other lesson last. Only used as a tiebreaker — the greedy lane
/// assignment still has to honor actual time-overlap constraints, so events
/// that start later can't jump left of events that started earlier and are
/// still occupying that lane.
int _appointmentPriority(Appointment a) {
final id = a.id;
if (id is CustomAppointment) return 0;
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
return 2;
}
/// Assigns each appointment a lane index using a greedy sweep, then collapses
/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments
/// + one trailing overflow cell.
///
/// Greedy sweep:
/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom →
/// cancelled → other) so parallel events land in the requested left-to-
/// right order, then `endTime` descending as a final tiebreaker.
/// 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, {required int maxLanes}) {
assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
if (appts.isEmpty) return const <LaidOutCell>[];
final sorted = [...appts]..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
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 <= maxLanes) {
for (final entry in cluster) {
result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount));
}
} else {
// Too many parallel appointments: keep the highest-priority
// (maxLanes - 1) and collapse the rest into a single overflow cell in
// the trailing lane. Sorting by priority first means custom and
// cancelled lessons stay visible when the cluster has to be trimmed,
// matching the requested left-to-right order in the visible lanes.
final visibleCount = maxLanes - 1;
final byPriority = [...cluster.map((e) => e.apt)]
..sort((a, b) {
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return a.startTime.compareTo(b.startTime);
});
for (var i = 0; i < visibleCount; i++) {
result.add(LaidOutAppointment(byPriority[i], i, maxLanes));
}
final overflow = byPriority.sublist(visibleCount);
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, maxLanes - 1, maxLanes, earliest, latest));
}
}
return result;
}
@@ -1,32 +0,0 @@
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 header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
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),
],
),
),
),
);
}
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rrule/rrule.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../custom_events/custom_event_edit_dialog.dart';
import 'bottom_sheet.dart';
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')}';
final timeRange = event.startDate.timeRangeTo(event.endDate);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
@@ -1,38 +1,35 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.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';
import '../../../../widget/unimplemented_dialog.dart';
import '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 = _resolveSubject(state, lesson.su.firstOrNull?.id);
final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
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 =
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showAppointmentBottomSheet(
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(_iconForCode(lesson.code), size: 32),
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
title: Text(
'${_codePrefix(lesson.code)}$headerTitle',
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
@@ -43,17 +40,17 @@ class WebuntisLessonSheet {
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.code)}'),
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 = _resolveSubject(state, s.id);
return _formatLine(
_firstNonEmpty([resolved.name, s.name, '?']),
longname: _firstNonEmpty([resolved.longName, s.longname, '']),
final resolved = LessonResolver.resolveSubject(state, s.id);
return LessonFormatter.formatLine(
firstNonEmpty([resolved.name, s.name, '?']),
longname: firstNonEmpty([resolved.longName, s.longname, '']),
);
}).toList(),
),
@@ -69,7 +66,7 @@ class WebuntisLessonSheet {
icon: Icons.people,
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.kl
.map((k) => _formatLine(
.map((k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
))
@@ -81,17 +78,6 @@ class WebuntisLessonSheet {
);
}
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),
@@ -107,11 +93,11 @@ class WebuntisLessonSheet {
}
final entries = lesson.ro.map((r) {
final resolved = _resolveRoom(state, r.id);
final name = _firstNonEmpty([resolved.name, r.name, '?']);
final longname = _firstNonEmpty([resolved.longName, r.longname, '']);
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();
return _formatLine(
return LessonFormatter.formatLine(
name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null,
@@ -144,7 +130,7 @@ class WebuntisLessonSheet {
}
final entries = lesson.te.map((t) {
final base = _formatLine(
final base = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
@@ -206,54 +192,4 @@ class WebuntisLessonSheet {
subtitle: Text(text),
);
}
static String _formatLine(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 _firstNonEmpty(List<String> values) {
for (final v in values) {
if (v.trim().isNotEmpty) return v;
}
return '';
}
static String _statusLabel(String? code) {
switch (code) {
case null:
case '':
return 'Regulär';
case 'cancelled':
return 'Entfällt';
case 'irregular':
return 'Geändert';
default:
return code;
}
}
static String _codePrefix(String? code) {
if (code == 'cancelled') return 'Entfällt: ';
if (code == 'irregular') return 'Änderung: ';
return code ?? '';
}
static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) {
final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
if (id == null) return fallback;
return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback;
}
static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) {
final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '');
if (id == null) return fallback;
return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback;
}
}
@@ -0,0 +1,84 @@
part of '../custom_workweek_calendar.dart';
class _DayHeaderStrip extends StatelessWidget {
final DateTime weekStart;
final DateTime today;
final double rulerWidth;
const _DayHeaderStrip({
super.key,
required this.weekStart,
required this.today,
required this.rulerWidth,
});
@override
Widget build(BuildContext context) => Row(
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayHeaderCell(
date: weekStart.add(Duration(days: d)),
today: today,
),
),
],
);
}
class _DayHeaderCell extends StatelessWidget {
final DateTime date;
final DateTime today;
const _DayHeaderCell({required this.date, required this.today});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isToday = date.isSameDay(today);
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
final accent = theme.colorScheme.primary;
final onAccent = theme.colorScheme.onPrimary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
dayName,
style: theme.textTheme.labelSmall?.copyWith(
color: isToday ? accent : theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
height: 1.1,
),
),
const SizedBox(height: 2),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isToday ? accent : Colors.transparent,
),
alignment: Alignment.center,
child: Text(
'${date.day}',
style: theme.textTheme.titleSmall?.copyWith(
color: isToday ? onAccent : theme.colorScheme.onSurface,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
height: 1.0,
),
),
),
],
),
);
}
}
@@ -0,0 +1,271 @@
part of '../custom_workweek_calendar.dart';
class _OutsideHoursStrip extends StatelessWidget {
static const int _maxVisibleChips = 2;
static const double _chipHeight = 22;
static const double _chipSpacing = 3;
static const double _verticalPadding = 3;
final DateTime weekStart;
final List<Appointment> appointments;
final double rulerWidth;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideHoursStrip({
super.key,
required this.weekStart,
required this.appointments,
required this.rulerWidth,
required this.onAppointmentTap,
required this.isCrossedOut,
});
@override
Widget build(BuildContext context) {
final outside = partitionAppointmentsForWeek(appointments, weekStart).outside;
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
final theme = Theme.of(context);
final maxChipsPerDay = outside
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
.fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = _verticalPadding * 2 +
maxChipsPerDay * _chipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
child: SizedBox(
height: stripHeight - _verticalPadding * 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _OutsideDayColumn(
appointments: outside[d],
maxVisible: _maxVisibleChips,
chipHeight: _chipHeight,
chipSpacing: _chipSpacing,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
),
),
],
),
),
);
}
}
class _OutsideDayColumn extends StatelessWidget {
final List<Appointment> appointments;
final int maxVisible;
final double chipHeight;
final double chipSpacing;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideDayColumn({
required this.appointments,
required this.maxVisible,
required this.chipHeight,
required this.chipSpacing,
required this.onAppointmentTap,
required this.isCrossedOut,
});
void _showOverflow(BuildContext context, List<Appointment> hidden) {
showDetailsBottomSheet(
context,
children: (sheetCtx) {
final tiles = <Widget>[];
for (var i = 0; i < hidden.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = hidden[i];
tiles.add(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(_subtitleFor(apt)),
onTap: () {
Navigator.of(sheetCtx).pop();
onAppointmentTap(apt);
},
));
}
return tiles;
},
);
}
static String _subtitleFor(Appointment a) {
if (isAllDayLike(a)) return 'Ganztägig';
return '${_hm(a.startTime)}${_hm(a.endTime)}';
}
static String _hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
if (appointments.isEmpty) return const SizedBox.shrink();
final sorted = [...appointments]
..sort((a, b) {
final aLike = isAllDayLike(a);
final bLike = isAllDayLike(b);
if (aLike && !bLike) return -1;
if (!aLike && bLike) return 1;
return a.startTime.compareTo(b.startTime);
});
final visible = sorted.length <= maxVisible
? sorted
: sorted.take(maxVisible - 1).toList();
final overflow =
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < visible.length; i++) ...[
if (i > 0) SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideChip(
appointment: visible[i],
onTap: () => onAppointmentTap(visible[i]),
),
),
],
if (overflow.isNotEmpty) ...[
SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideOverflowChip(
count: overflow.length,
onTap: () => _showOverflow(context, overflow),
),
),
],
],
),
);
}
}
class _OutsideChip extends StatelessWidget {
final Appointment appointment;
final VoidCallback onTap;
const _OutsideChip({required this.appointment, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final allDay = isAllDayLike(appointment);
final timeLabel = allDay
? 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(backgroundAlpha),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
appointment.subject,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: subjectColor,
fontWeight: subjectWeight,
),
),
),
if (timeLabel != null) ...[
const SizedBox(width: 4),
Flexible(
child: Text(
timeLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
),
],
],
),
),
),
);
}
}
class _OutsideOverflowChip extends StatelessWidget {
final int count;
final VoidCallback onTap;
const _OutsideOverflowChip({required this.count, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Center(
child: Text(
'+$count weitere',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
@@ -0,0 +1,489 @@
part of '../custom_workweek_calendar.dart';
class _WeekGrid extends StatelessWidget {
final DateTime weekStart;
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final double rulerWidth;
final PeriodLayout layout;
const _WeekGrid({
required this.weekStart,
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.onAppointmentTap,
required this.isCrossedOut,
required this.onCreateEvent,
required this.today,
required this.nowNotifier,
required this.rulerWidth,
required this.layout,
});
@override
Widget build(BuildContext context) {
final partitioned = partitionAppointmentsForWeek(appointments, weekStart);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PeriodRuler(
schedule: schedule,
layout: layout,
width: rulerWidth,
),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayColumn(
date: weekStart.add(Duration(days: d)),
schedule: schedule,
appointments: partitioned.inside[d],
timeRegions: timeRegions,
layout: layout,
today: today,
nowNotifier: nowNotifier,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
onCreateEvent: onCreateEvent,
),
),
],
);
}
}
class _PeriodRuler extends StatelessWidget {
final LessonPeriodSchedule schedule;
final PeriodLayout layout;
final double width;
const _PeriodRuler({
required this.schedule,
required this.layout,
required this.width,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
height: layout.heightOf(period),
left: 0,
right: 0,
child: _PeriodLabel(period: period, theme: theme),
),
],
),
);
}
}
class _PeriodLabel extends StatelessWidget {
final LessonPeriod period;
final ThemeData theme;
const _PeriodLabel({required this.period, required this.theme});
@override
Widget build(BuildContext context) {
final dividerColor = theme.dividerColor.withAlpha(110);
final secondaryTextColor = theme.colorScheme.onSurfaceVariant;
if (period.isBreak) {
return Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: dividerColor, width: 0.5),
bottom: BorderSide(color: dividerColor, width: 0.5),
),
),
alignment: Alignment.center,
child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)),
);
}
final timeStyle = theme.textTheme.labelSmall?.copyWith(
color: secondaryTextColor.withAlpha(140),
height: 1.0,
fontSize: 9,
);
const tightTextHeight = TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
);
return LayoutBuilder(
builder: (context, constraints) {
final showTimes = constraints.maxHeight >= 38;
return DecoratedBox(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: dividerColor, width: 0.5)),
),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
if (showTimes)
Positioned(
top: 3,
left: 0,
right: 0,
child: Text(
_format(period.start),
style: timeStyle,
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
),
Text(
period.name,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.0,
),
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
if (showTimes)
Positioned(
bottom: 3,
left: 0,
right: 0,
child: Text(
_format(period.end),
style: timeStyle,
textAlign: TextAlign.center,
textHeightBehavior: tightTextHeight,
),
),
],
),
);
},
);
}
static String _format(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
class _DayColumn extends StatelessWidget {
final DateTime date;
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> nowNotifier;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
const _DayColumn({
required this.date,
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.layout,
required this.today,
required this.nowNotifier,
required this.onAppointmentTap,
required this.isCrossedOut,
required this.onCreateEvent,
});
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;
}
return false;
}
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
if (onCreateEvent == null) return;
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);
final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute);
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
HapticFeedback.mediumImpact();
onCreateEvent!(start, end);
}
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showDetailsBottomSheet(
context,
children: (sheetContext) {
final tiles = <Widget>[];
for (var i = 0; i < sorted.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = sorted[i];
tiles.add(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);
},
));
}
return tiles;
},
);
}
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);
final dayAppointments = appointments;
final dayRegions = expandRegionsForDay(timeRegions, date);
final isToday = date.isSameDay(today);
final isTablet = MediaQuery.of(context).size.shortestSide >= 600;
final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
),
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),
),
),
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),
),
],
);
},
),
),
);
}
}
class _CurrentTimeMarker extends StatelessWidget {
final DateTime now;
final PeriodLayout layout;
final ThemeData theme;
const _CurrentTimeMarker({
required this.now,
required this.layout,
required this.theme,
});
@override
Widget build(BuildContext context) {
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),
curve: Curves.easeInOut,
top: y - 1,
left: 0,
right: 0,
child: IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 2,
color: theme.colorScheme.primary,
),
Positioned(
top: -3,
left: -4,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
),
],
),
),
);
}
}
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,
),
),
],
),
),
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff