refactored timetable
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user