import 'dart:async'; import 'package:flutter/material.dart'; import '../../../extensions/dateTime.dart'; import 'package:provider/provider.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; import '../../../model/timetable/timetableProps.dart'; import '../../../storage/base/settingsProvider.dart'; import '../../../widget/loadingSpinner.dart'; import '../../../widget/placeholderView.dart'; import 'appointmenetComponent.dart'; import 'appointmentDetails.dart'; import 'arbitraryAppointment.dart'; import 'customTimetableColors.dart'; import 'customTimetableEventEditDialog.dart'; import 'timeRegionComponent.dart'; import 'timetableEvents.dart'; import 'viewCustomTimetableEvents.dart'; class Timetable extends StatefulWidget { const Timetable({super.key}); @override State createState() => _TimetableState(); } enum CalendarActions { addEvent, viewEvents } class _TimetableState extends State { CalendarController controller = CalendarController(); late Timer updateTimings; late final SettingsProvider settings; @override void initState() { settings = Provider.of(context, listen: false); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { Provider.of(context, listen: false).run(); }); controller.displayDate = DateTime.now().add(const Duration(days: 2)); updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ IconButton( icon: const Icon(Icons.home_outlined), onPressed: () { controller.displayDate = DateTime.now().add(const Duration(days: 2)); } ), PopupMenuButton( icon: const Icon(Icons.edit_calendar_outlined), itemBuilder: (context) { return CalendarActions.values.map( (e) { String title; Icon icon; switch(e) { case CalendarActions.addEvent: title = 'Kalendereintrag hinzufügen'; icon = const Icon(Icons.add); case CalendarActions.viewEvents: default: title = 'Kalendereinträge anzeigen'; icon = const Icon(Icons.perm_contact_calendar_outlined); } return PopupMenuItem( value: e, child: ListTile( title: Text(title), leading: icon, ) ); } ).toList(); }, onSelected: (value) { switch(value) { case CalendarActions.addEvent: showDialog( context: context, builder: (context) => const CustomTimetableEventEditDialog(), barrierDismissible: false, ); case CalendarActions.viewEvents: Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents())); } }, ) ], ), body: Consumer( builder: (context, value, child) { if(value.hasError) { return PlaceholderView( icon: Icons.calendar_month, text: 'Webuntis error: ${value.error.toString()}', button: TextButton( child: const Text('Neu laden'), onPressed: () { controller.displayDate = DateTime.now().add(const Duration(days: 2)); Provider.of(context, listen: false).resetWeek(); }, ), ); } if(value.primaryLoading()) return const LoadingSpinner(); GetHolidaysResponse holidays = value.getHolidaysResponse; return RefreshIndicator( child: SfCalendar( view: CalendarView.workWeek, dataSource: _buildTableEvents(value), maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday), controller: controller, onViewChanged: (ViewChangedDetails details) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last); }); }, onTap: (calendarTapDetails) { if(calendarTapDetails.appointments == null) return; Appointment tapped = calendarTapDetails.appointments!.first; AppointmentDetails.show(context, value, tapped); }, firstDayOfWeek: DateTime.monday, specialRegions: _buildSpecialTimeRegions(holidays), timeSlotViewSettings: const TimeSlotViewSettings( startHour: 07.5, endHour: 16.5, timeInterval: Duration(minutes: 30), timeFormat: 'HH:mm', dayFormat: 'EE', timeIntervalHeight: 40, ), timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails), appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent( details: details, crossedOut: _isCrossedOut(details) ), headerHeight: 0, selectionDecoration: const BoxDecoration(), allowAppointmentResize: false, allowDragAndDrop: false, allowViewNavigation: false, ), onRefresh: () async { Provider.of(context, listen: false).run(renew: true); return Future.delayed(const Duration(seconds: 3)); } ); }, ), ); } @override void dispose() { updateTimings.cancel(); super.dispose(); } List _buildSpecialTimeRegions(GetHolidaysResponse holidays) { DateTime lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); DateTime firstBreak = lastMonday.copyWith(hour: 10, minute: 15); DateTime secondBreak = lastMonday.copyWith(hour: 13, minute: 50); Iterable holidayList = holidays.result.map((holiday) { DateTime startDay = _parseWebuntisTimestamp(holiday.startDate, 0); int dayCount = _parseWebuntisTimestamp(holiday.endDate, 0) .difference(startDay) .inDays; List days = List.generate(dayCount, (index) => startDay.add(Duration(days: index))); return days.map((holidayDay) { return TimeRegion( startTime: holidayDay.copyWith(hour: 07, minute: 55), endTime: holidayDay.copyWith(hour: 16, minute: 30), text: 'holiday:${holiday.name}', color: Theme .of(context) .disabledColor .withAlpha(50), iconData: Icons.holiday_village_outlined ); }); }).expand((e) => e); bool isInHoliday(DateTime time) { return holidayList.any((element) => element.startTime.isSameDay(time)); } return [ ...holidayList, if(!isInHoliday(firstBreak)) TimeRegion( startTime: firstBreak, endTime: firstBreak.add(const Duration(minutes: 20)), recurrenceRule: 'FREQ=DAILY;INTERVAL=1', text: 'centerIcon', color: Theme.of(context).primaryColor.withAlpha(50), iconData: Icons.restaurant ), if(!isInHoliday(secondBreak)) TimeRegion( startTime: secondBreak, endTime: secondBreak.add(const Duration(minutes: 15)), recurrenceRule: 'FREQ=DAILY;INTERVAL=1', text: 'centerIcon', color: Theme.of(context).primaryColor.withAlpha(50), iconData: Icons.restaurant ), ]; } List _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) { List timetableList = data.getTimetableResponse.result.toList( ); if ( timetableList.isEmpty ) return timetableList; timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime))); GetTimetableResponseObject previousElement = timetableList.first; for (var i = 1; i < timetableList.length; i++) { GetTimetableResponseObject currentElement = timetableList.elementAt(i); bool isSameLesson() { int? currentSubjectId = currentElement.su.firstOrNull?.id; int? previousSubjectId = previousElement.su.firstOrNull?.id; if( currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId ) return false; int? currentRoomId = currentElement.ro.firstOrNull?.id; int? previousRoomId = previousElement.ro.firstOrNull?.id; if( currentRoomId != previousRoomId ) return false; int? currentTeacherId = currentElement.te.firstOrNull?.id; int? previousTeacherId = previousElement.te.firstOrNull?.id; if( currentTeacherId != previousTeacherId ) return false; String? currentStatusCode = currentElement.code; String? previousStatusCode = previousElement.code; if( currentStatusCode != previousStatusCode ) return false; return true; } bool isNotSeparated() => _parseWebuntisTimestamp( previousElement.date, previousElement.endTime ).add( maxTimeBetweenDouble ) .isSameOrAfter( _parseWebuntisTimestamp( currentElement.date, currentElement.startTime ) ); if ( isSameLesson() && isNotSeparated() ) { previousElement.endTime = currentElement.endTime; timetableList.remove( currentElement ); i--; } else { previousElement = currentElement; } } return timetableList; } TimetableEvents _buildTableEvents(TimetableProps data) { List timetableList = data.getTimetableResponse.result.toList( ); if( settings.val().timetableSettings.connectDoubleLessons ){ timetableList = _removeDuplicates( data, const Duration(minutes: 5) ); } List appointments = timetableList.map((element) { GetRoomsResponse rooms = data.getRoomsResponse; GetSubjectsResponse subjects = data.getSubjectsResponse; try { DateTime startTime = _parseWebuntisTimestamp(element.date, element.startTime); DateTime endTime = _parseWebuntisTimestamp(element.date, element.endTime); return Appointment( id: ArbitraryAppointment(webuntis: element), startTime: startTime, endTime: endTime, subject: subjects.result.firstWhere((subject) => subject.id == element.su[0].id).name, location: '' '${rooms.result.firstWhere((room) => room.id == element.ro[0].id).name}' '\n' '${element.te.first.longname}', notes: element.activityType, color: _getEventColor(element, startTime, endTime), ); } catch(e) { DateTime endTime = _parseWebuntisTimestamp(element.date, element.endTime); return Appointment( id: ArbitraryAppointment(webuntis: element), startTime: _parseWebuntisTimestamp(element.date, element.startTime), endTime: endTime, subject: 'Änderung', notes: element.info, location: 'Unbekannt', color: endTime.isBefore(DateTime.now()) ? Theme.of(context).primaryColor.withAlpha(100) : Theme.of(context).primaryColor, startTimeZone: '', endTimeZone: '', ); } }).toList(); appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) { return Appointment( id: ArbitraryAppointment(custom: customEvent), startTime: customEvent.startDate, endTime: customEvent.endDate, location: customEvent.description, subject: customEvent.title, recurrenceRule: customEvent.rrule, color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name), startTimeZone: '', endTimeZone: '', ); })); return TimetableEvents(appointments); } DateTime _parseWebuntisTimestamp(int date, int time) { String timeString = time.toString().padLeft(4, '0'); return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); } Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) { // Make element darker, when it already took place int alpha = endTime.isBefore(DateTime.now()) ? 100 : 255; // Cancelled if(webuntisElement.code == 'cancelled') return const Color(0xff000000).withAlpha(alpha); // Any changes or no teacher at this element if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3).withAlpha(alpha); // Event was in the past if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withAlpha(alpha); // Event takes currently place if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200); // Fallback return Theme.of(context).primaryColor.withAlpha(alpha); } bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) { ArbitraryAppointment appointment = calendarEntry.appointments.first.id as ArbitraryAppointment; if(appointment.hasWebuntis()) { return appointment.webuntis!.code == 'cancelled'; } return false; } }