refactored timetable

This commit is contained in:
2026-05-05 13:49:45 +02:00
parent 551c1bf1fa
commit e8faa77e70
29 changed files with 1574 additions and 300 deletions
@@ -15,17 +15,28 @@ import 'custom_event_colors.dart';
class CustomEventEditDialog extends StatefulWidget {
final CustomTimetableEvent? existingEvent;
final DateTime? initialStart;
final DateTime? initialEnd;
const CustomEventEditDialog({this.existingEvent, super.key});
const CustomEventEditDialog({
this.existingEvent,
this.initialStart,
this.initialEnd,
super.key,
});
@override
State<CustomEventEditDialog> createState() => _CustomEventEditDialogState();
}
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0);
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ??
widget.initialStart?.toTimeOfDay() ??
const TimeOfDay(hour: 8, minute: 0);
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ??
widget.initialEnd?.toTimeOfDay() ??
const TimeOfDay(hour: 9, minute: 30);
late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title);
late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description);
late String _rrule = widget.existingEvent?.rrule ?? '';
@@ -167,13 +178,20 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
const Divider(),
RRuleGenerator(
config: RRuleGeneratorConfig(
headerEnabled: true,
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
weekdayColor: Colors.black,
selectDayStyle: RRuleSelectDayStyle(
dayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.secondary,
),
dayTextStyle: const TextStyle(color: Colors.black),
selectedDayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).primaryColor,
),
),
),
initialRRule: _rrule,
textDelegate: const GermanRRuleTextDelegate(),
locale: RRuleLocale.de_DE,
onChange: (newValue) {
log('Rule: $newValue');
setState(() => _rrule = newValue);
@@ -0,0 +1,8 @@
const double kCalendarStartHour = 7.5;
const double kCalendarEndHour = 17.25;
const Duration kCalendarTimeInterval = Duration(minutes: 30);
const double kCalendarViewHeaderHeight = 60;
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
/// than compressing further.
const double kCalendarMinPxPerHour = 56;
@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
import 'lesson_status.dart';
class LessonColor {
static const Color regular = Color.fromARGB(255, 153, 51, 51);
static const Color ongoing = Color.fromARGB(255, 200, 51, 51);
static const Color cancelled = Color(0xff000000);
static const Color irregular = Color(0xff8F19B3);
static const Color teacherChanged = Color(0xFF29639B);
static const Color parseFallback = Color(0xff404040);
static Color forStatus(LessonStatus status, ColorScheme scheme) {
static Color forStatus(LessonStatus status) {
switch (status) {
case LessonStatus.cancelled:
return cancelled;
@@ -18,14 +20,9 @@ class LessonColor {
return teacherChanged;
case LessonStatus.past:
case LessonStatus.regular:
return scheme.primary;
return regular;
case LessonStatus.ongoing:
return Color.from(
alpha: scheme.primary.a,
red: 200 / 255,
green: scheme.primary.g,
blue: scheme.primary.b,
);
return ongoing;
}
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
class LessonPeriod {
final String name;
final TimeOfDay start;
final TimeOfDay end;
final bool isBreak;
const LessonPeriod({
required this.name,
required this.start,
required this.end,
this.isBreak = false,
});
Duration get duration => Duration(
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
);
int get _startMinutes => start.hour * 60 + start.minute;
}
class LessonPeriodSchedule {
final List<LessonPeriod> periods;
const LessonPeriodSchedule(this.periods);
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
final canonical = response.result.firstWhere(
(d) => d.day == 1,
orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []),
);
if (canonical.timeUnits.isEmpty) return null;
final periods = canonical.timeUnits
.map((u) => LessonPeriod(
name: u.name,
start: _fromHHMM(u.startTime),
end: _fromHHMM(u.endTime),
))
.toList()
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
return LessonPeriodSchedule(periods);
}
static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([
LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)),
LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)),
LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)),
LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)),
LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)),
LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)),
LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)),
LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)),
LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)),
LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)),
LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)),
LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)),
]);
static LessonPeriodSchedule fromState(TimetableState state) {
final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null;
return (fromApi ?? fallback()).withSyntheticBreaks();
}
LessonPeriodSchedule withSyntheticBreaks() {
final result = <LessonPeriod>[];
for (var i = 0; i < periods.length; i++) {
final current = periods[i];
result.add(current);
if (i + 1 >= periods.length) continue;
final next = periods[i + 1];
final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute);
if (gapMinutes >= 10) {
result.add(LessonPeriod(
name: 'Pause',
start: current.end,
end: next.start,
isBreak: true,
));
}
}
return LessonPeriodSchedule(result);
}
static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay(
hour: hhmm ~/ 100,
minute: hhmm % 100,
);
}
@@ -1,5 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
@@ -20,7 +19,6 @@ class TimetableAppointmentFactory {
final GetRoomsResponse rooms;
final GetSubjectsResponse subjects;
final TimetableSettings settings;
final ColorScheme colorScheme;
final DateTime now;
TimetableAppointmentFactory({
@@ -29,7 +27,6 @@ class TimetableAppointmentFactory {
required this.rooms,
required this.subjects,
required this.settings,
required this.colorScheme,
required this.now,
});
@@ -54,7 +51,7 @@ class TimetableAppointmentFactory {
subject: _subjectName(lesson),
location: _locationLabel(lesson),
notes: lesson.activityType,
color: LessonColor.forStatus(status, colorScheme),
color: LessonColor.forStatus(status),
);
} catch (_) {
return Appointment(
+40 -76
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -12,12 +10,11 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'custom_events/custom_events_view.dart';
import 'data/arbitrary_appointment.dart';
import 'data/lesson_period_schedule.dart';
import 'data/timetable_appointment_factory.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/appointment_tile.dart';
import 'widgets/lesson_appointment_source.dart';
import 'widgets/custom_workweek_calendar.dart';
import 'widgets/special_regions_builder.dart';
import 'widgets/time_region_tile.dart';
enum _CalendarAction { addEvent, viewEvents }
@@ -29,32 +26,15 @@ class Timetable extends StatefulWidget {
}
class _TimetableState extends State<Timetable> {
final CalendarController _controller = CalendarController();
late Timer _highlightTicker;
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey = GlobalKey<CustomWorkWeekCalendarState>();
LessonAppointmentSource? _cachedSource;
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
@override
void initState() {
super.initState();
_controller.displayDate = _initialDisplayDate();
_highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() => _cachedSource = null);
});
}
@override
void dispose() {
_highlightTicker.cancel();
super.dispose();
}
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
void _jumpToToday() {
_controller.displayDate = _initialDisplayDate();
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
}
void _onAction(_CalendarAction action) {
@@ -70,24 +50,27 @@ class _TimetableState extends State<Timetable> {
}
}
LessonAppointmentSource _appointmentSource(TimetableState state) {
if (_cachedSource != null && _lastDataVersion == state.dataVersion) {
return _cachedSource!;
List<Appointment> _appointments(TimetableState state) {
if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) {
return _cachedAppointments!;
}
_lastDataVersion = state.dataVersion;
final settings = context.read<SettingsCubit>();
final appointments = TimetableAppointmentFactory(
return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!,
subjects: state.subjects!,
settings: settings.val().timetableSettings,
colorScheme: Theme.of(context).colorScheme,
now: DateTime.now(),
).build();
}
return _cachedSource = LessonAppointmentSource(appointments);
bool _isCrossedOut(Appointment appointment) {
final id = appointment.id;
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
return false;
}
@override
@@ -126,54 +109,35 @@ class _TimetableState extends State<Timetable> {
Widget _calendar(TimetableState state, TimetableBloc bloc) {
if (!state.hasReferenceData) return const SizedBox.shrink();
return SfCalendar(
timeZone: 'W. Europe Standard Time',
view: CalendarView.workWeek,
dataSource: _appointmentSource(state),
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
final schedule = LessonPeriodSchedule.fromState(state);
final appointments = _appointments(state);
final regions = SpecialRegionsBuilder(
holidays: state.schoolHolidays!,
schedule: schedule,
colorScheme: Theme.of(context).colorScheme,
disabledColor: Theme.of(context).disabledColor,
).build();
return CustomWorkWeekCalendar(
key: _calendarKey,
schedule: schedule,
appointments: appointments,
timeRegions: regions,
initialDate: _initialDisplayDate(),
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
controller: _controller,
onViewChanged: (details) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
bloc.changeWeek(details.visibleDates.first, details.visibleDates.last);
});
},
onTap: (tap) {
if (tap.appointments == null || tap.appointments!.isEmpty) return;
AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first);
},
firstDayOfWeek: DateTime.monday,
specialRegions: SpecialRegionsBuilder(
holidays: state.schoolHolidays!,
colorScheme: Theme.of(context).colorScheme,
disabledColor: Theme.of(context).disabledColor,
).build(),
timeSlotViewSettings: const TimeSlotViewSettings(
startHour: 7.5,
endHour: 16.5,
timeInterval: Duration(minutes: 30),
timeFormat: 'HH:mm',
dayFormat: 'EE',
timeIntervalHeight: 40,
),
timeRegionBuilder: (_, details) => TimeRegionTile(details: details),
appointmentBuilder: (_, details) => AppointmentTile(
details: details,
crossedOut: _isCrossedOut(details),
),
headerHeight: 0,
selectionDecoration: const BoxDecoration(),
allowAppointmentResize: false,
allowDragAndDrop: false,
allowViewNavigation: false,
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt),
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
isCrossedOut: _isCrossedOut,
onCreateEvent: _onCreateEventAt,
);
}
bool _isCrossedOut(CalendarAppointmentDetails details) {
final appointment = details.appointments.first;
final id = appointment.id;
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
return false;
void _onCreateEventAt(DateTime start, DateTime end) {
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end),
barrierDismissible: false,
);
}
}
@@ -4,65 +4,68 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget {
final CalendarAppointmentDetails details;
final Appointment appointment;
final bool crossedOut;
const AppointmentTile({super.key, required this.details, this.crossedOut = false});
const AppointmentTile({super.key, required this.appointment, this.crossedOut = false});
@override
Widget build(BuildContext context) {
final Appointment meeting = details.appointments.first;
final isPast = meeting.endTime.isBefore(DateTime.now());
final color = meeting.color.withAlpha(isPast ? 100 : 255);
final isPast = appointment.endTime.isBefore(DateTime.now());
final color = appointment.color.withAlpha(isPast ? 160 : 255);
return Stack(
children: [
Container(
padding: const EdgeInsets.all(3),
height: details.bounds.height,
alignment: Alignment.topLeft,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(5)),
color: color,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
meeting.subject,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
maxLines: 1,
softWrap: false,
),
),
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
meeting.location?.isNotEmpty == true ? meeting.location! : ' ',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
),
),
),
if (crossedOut)
return Padding(
padding: const EdgeInsets.all(1),
child: Stack(
children: [
Positioned.fill(
child: Container(
padding: const EdgeInsets.all(4),
alignment: Alignment.topLeft,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: const BorderRadius.all(Radius.circular(5)),
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(7)),
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: CustomPaint(painter: CrossPainter()),
),
),
],
if (crossedOut)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: const BorderRadius.all(Radius.circular(7)),
),
child: CustomPaint(painter: CrossPainter()),
),
),
],
),
);
}
}
@@ -0,0 +1,761 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/calendar_layout.dart';
import '../data/lesson_period_schedule.dart';
import 'appointment_tile.dart';
import 'time_region_tile.dart';
class CustomWorkWeekCalendar extends StatefulWidget {
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final DateTime initialDate;
final DateTime minDate;
final DateTime maxDate;
final void Function(Appointment appointment) onAppointmentTap;
final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged;
final bool Function(Appointment appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
const CustomWorkWeekCalendar({
super.key,
required this.schedule,
required this.appointments,
required this.timeRegions,
required this.initialDate,
required this.minDate,
required this.maxDate,
required this.onAppointmentTap,
required this.onWeekChanged,
required this.isCrossedOut,
this.onCreateEvent,
});
@override
State<CustomWorkWeekCalendar> createState() => CustomWorkWeekCalendarState();
}
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
static const double _rulerWidth = 50;
late PageController _pageController;
late int _currentWeekIndex;
late DateTime _firstMonday;
late int _totalWeeks;
late Timer _ticker;
late ValueNotifier<DateTime> _nowNotifier;
DateTime _today = _dateOnly(DateTime.now());
@override
void initState() {
super.initState();
_firstMonday = _mondayOf(widget.minDate);
final lastMonday = _mondayOf(widget.maxDate);
_totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1;
_currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
_pageController = PageController(initialPage: _currentWeekIndex);
_nowNotifier = ValueNotifier<DateTime>(DateTime.now());
_ticker = Timer.periodic(const Duration(seconds: 30), (_) {
if (!mounted) return;
final now = DateTime.now();
_nowNotifier.value = now;
final newToday = _dateOnly(now);
if (newToday != _today) setState(() => _today = newToday);
});
}
@override
void dispose() {
_pageController.dispose();
_ticker.cancel();
_nowNotifier.dispose();
super.dispose();
}
static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
void jumpToDate(DateTime date) {
final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7;
if (target < 0 || target >= _totalWeeks) return;
_pageController.animateToPage(
target,
duration: const Duration(milliseconds: 380),
curve: Curves.easeInOutCubic,
);
}
static DateTime _mondayOf(DateTime d) {
final monday = d.subtract(Duration(days: d.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7));
return Column(
children: [
SizedBox(
height: kCalendarViewHeaderHeight,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.08),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: _DayHeaderStrip(
key: ValueKey(visibleWeekStart),
weekStart: visibleWeekStart,
today: _today,
rulerWidth: _rulerWidth,
),
),
),
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
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;
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: gridHeight,
child: PageView.builder(
controller: _pageController,
itemCount: _totalWeeks,
onPageChanged: (index) {
setState(() => _currentWeekIndex = index);
final weekStart = _firstMonday.add(Duration(days: index * 7));
widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4)));
},
itemBuilder: (_, weekIndex) {
final weekStart = _firstMonday.add(Duration(days: weekIndex * 7));
return _WeekGrid(
weekStart: weekStart,
schedule: widget.schedule,
appointments: widget.appointments,
timeRegions: widget.timeRegions,
onAppointmentTap: widget.onAppointmentTap,
isCrossedOut: widget.isCrossedOut,
onCreateEvent: widget.onCreateEvent,
today: _today,
nowNotifier: _nowNotifier,
rulerWidth: _rulerWidth,
pxPerHour: pxPerHour,
);
},
),
),
);
},
),
),
],
);
}
}
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 = _isSameDay(date, 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,
),
),
),
],
),
);
}
}
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 double pxPerHour;
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.pxPerHour,
});
@override
Widget build(BuildContext context) {
final perDay = _expandAppointmentsForWeek(appointments, weekStart);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PeriodRuler(
schedule: schedule,
pxPerHour: pxPerHour,
width: rulerWidth,
),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayColumn(
date: weekStart.add(Duration(days: d)),
schedule: schedule,
appointments: perDay[d],
timeRegions: timeRegions,
pxPerHour: pxPerHour,
today: today,
nowNotifier: nowNotifier,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
onCreateEvent: onCreateEvent,
),
),
],
);
}
}
class _PeriodRuler extends StatelessWidget {
final LessonPeriodSchedule schedule;
final double pxPerHour;
final double width;
const _PeriodRuler({
required this.schedule,
required this.pxPerHour,
required this.width,
});
double _y(TimeOfDay t) =>
(t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour;
@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: _y(period.start),
height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity),
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,
height: 1.0,
fontSize: 10,
);
const tightTextHeight = TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
);
return LayoutBuilder(
builder: (context, constraints) {
final showTimes = constraints.maxHeight >= 38;
return Container(
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 double pxPerHour;
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.pxPerHour,
required this.today,
required this.nowNotifier,
required this.onAppointmentTap,
required this.isCrossedOut,
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;
}
return false;
}
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
if (onCreateEvent == null) return;
final period = _periodAt(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);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dayAppointments = appointments;
final dayRegions = _expandRegionsForDay(timeRegions, date);
final isToday = _isSameDay(date, today);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
child: Container(
decoration: BoxDecoration(
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),
),
),
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) =>
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme),
),
],
),
),
);
}
}
class _CurrentTimeMarker extends StatelessWidget {
final DateTime now;
final double pxPerHour;
final ThemeData theme;
const _CurrentTimeMarker({
required this.now,
required this.pxPerHour,
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();
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 _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 (_isSameDay(region.startTime, day)) {
result.add(_BoundRegion(
region: region,
start: region.startTime,
end: region.endTime,
));
}
}
return result;
}
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
/// Expands the given list of appointments across the visible 5-day work week,
/// resolving any RRULE-based recurrences into per-day synthetic instances.
/// Returns a list of length 5 (Monday..Friday); each entry holds the
/// appointments occurring on that day, with `startTime` and `endTime` shifted
/// to the actual occurrence date (preserving time-of-day and duration). The
/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps
/// still resolve to the correct underlying event.
List<List<Appointment>> _expandAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
final perDay = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final weekEnd = weekStart.add(const Duration(days: 5));
final weekStartUtc = weekStart.toUtc();
final weekEndUtc = weekEnd.toUtc();
for (final a in appointments) {
final rule = a.recurrenceRule;
if (rule == null || rule.isEmpty) {
final idx = a.startTime.difference(weekStart).inDays;
if (idx >= 0 && idx < 5) perDay[idx].add(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);
perDay[idx].add(Appointment(
id: a.id,
startTime: newStart,
endTime: newStart.add(duration),
subject: a.subject,
color: a.color,
location: a.location,
notes: a.notes,
));
}
} catch (_) {
// Malformed RRULE → behave as non-recurring (anchor day only).
final idx = a.startTime.difference(weekStart).inDays;
if (idx >= 0 && idx < 5) perDay[idx].add(a);
}
}
return perDay;
}
@@ -1,7 +0,0 @@
import 'package:syncfusion_flutter_calendar/calendar.dart';
class LessonAppointmentSource extends CalendarDataSource {
LessonAppointmentSource(List<Appointment> source) {
appointments = source;
}
}
@@ -3,32 +3,38 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../../extensions/dateTime.dart';
import '../data/calendar_layout.dart';
import '../data/lesson_period_schedule.dart';
import '../data/webuntis_time.dart';
import 'time_region_tile.dart';
class SpecialRegionsBuilder {
final GetHolidaysResponse holidays;
final LessonPeriodSchedule schedule;
final ColorScheme colorScheme;
final Color disabledColor;
SpecialRegionsBuilder({
required this.holidays,
required this.schedule,
required this.colorScheme,
required this.disabledColor,
});
List<TimeRegion> build() {
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
final firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
final secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
final holidayRegions = _buildHolidayRegions().toList();
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) {
final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute);
return _breakRegion(start, p.duration);
}).where((region) => !isInHoliday(region.startTime));
return [
...holidayRegions,
if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)),
if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)),
...breakRegions,
];
}
@@ -36,9 +42,13 @@ class SpecialRegionsBuilder {
final startDay = WebuntisTime.parse(holiday.startDate, 0);
final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays;
final days = List<DateTime>.generate(dayCount, (i) => startDay.add(Duration(days: i)));
final gridStartHour = kCalendarStartHour.floor();
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
final gridEndHour = kCalendarEndHour.floor();
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
return days.map((day) => TimeRegion(
startTime: day.copyWith(hour: 7, minute: 55),
endTime: day.copyWith(hour: 16, minute: 30),
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
text: '$kTimeRegionHolidayPrefix${holiday.name}',
color: disabledColor.withAlpha(50),
iconData: Icons.holiday_village_outlined,
@@ -5,20 +5,20 @@ const String kTimeRegionCenterIcon = 'centerIcon';
const String kTimeRegionHolidayPrefix = 'holiday:';
class TimeRegionTile extends StatelessWidget {
final TimeRegionDetails details;
final TimeRegion region;
const TimeRegionTile({super.key, required this.details});
const TimeRegionTile({super.key, required this.region});
@override
Widget build(BuildContext context) {
final text = details.region.text ?? '';
final color = details.region.color;
final text = region.text ?? '';
final color = region.color;
if (text == kTimeRegionCenterIcon) {
return Container(
color: color,
alignment: Alignment.center,
child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor),
child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary),
);
}
@@ -50,6 +50,6 @@ class TimeRegionTile extends StatelessWidget {
);
}
return const Placeholder();
return const SizedBox.shrink();
}
}