From e8faa77e70d704b35ceb11fbac2938a5fd766fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 13:49:45 +0200 Subject: [PATCH] refactored timetable --- .../get/getCustomTimetableEventCache.dart | 12 +- lib/api/requestCache.dart | 47 +- .../queries/authenticate/authenticate.dart | 22 +- .../queries/getHolidays/getHolidaysCache.dart | 11 +- .../queries/getRooms/getRoomsCache.dart | 11 +- .../queries/getSubjects/getSubjectsCache.dart | 11 +- .../getTimegridUnits/getTimegridUnits.dart | 22 + .../getTimegridUnitsCache.dart | 20 + .../getTimegridUnitsResponse.dart | 38 + .../getTimegridUnitsResponse.g.dart | 64 ++ .../getTimetable/getTimetableCache.dart | 8 +- .../timetable/bloc/timetable_bloc.dart | 100 ++- .../timetable/bloc/timetable_state.dart | 2 + .../bloc/timetable_state.freezed.dart | 43 +- .../timetable/bloc/timetable_state.g.dart | 6 + .../dataProvider/timetable_data_provider.dart | 129 ++- .../custom_event_edit_dialog.dart | 36 +- .../pages/timetable/data/calendar_layout.dart | 8 + .../pages/timetable/data/lesson_color.dart | 13 +- .../data/lesson_period_schedule.dart | 94 +++ .../data/timetable_appointment_factory.dart | 5 +- lib/view/pages/timetable/timetable.dart | 116 +-- .../timetable/widgets/appointment_tile.dart | 97 +-- .../widgets/custom_workweek_calendar.dart | 761 ++++++++++++++++++ .../widgets/lesson_appointment_source.dart | 7 - .../widgets/special_regions_builder.dart | 22 +- .../timetable/widgets/time_region_tile.dart | 12 +- lib/widget/userAvatar.dart | 156 +++- pubspec.yaml | 1 + 29 files changed, 1574 insertions(+), 300 deletions(-) create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart create mode 100644 lib/view/pages/timetable/data/calendar_layout.dart create mode 100644 lib/view/pages/timetable/data/lesson_period_schedule.dart create mode 100644 lib/view/pages/timetable/widgets/custom_workweek_calendar.dart delete mode 100644 lib/view/pages/timetable/widgets/lesson_appointment_source.dart diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart index 1f67d5f..f0a0119 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart @@ -8,7 +8,17 @@ import 'getCustomTimetableEventResponse.dart'; class GetCustomTimetableEventCache extends RequestCache { GetCustomTimetableEventParams params; - GetCustomTimetableEventCache(this.params, {onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetCustomTimetableEventCache( + this.params, { + void Function(GetCustomTimetableEventResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('customTimetableEvents'); } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index cbbf15b..2588a22 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:localstore/localstore.dart'; @@ -17,29 +18,41 @@ abstract class RequestCache { void Function(Exception) onError; bool? renew; + final Completer _ready = Completer(); + + /// Resolves when [start] has finished, regardless of whether the network + /// call succeeded, failed, or was skipped due to a fresh cache. Callers + /// can await this to know when both the cache lookup and the network + /// attempt have settled. + Future get ready => _ready.future; + RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); static void ignore(Exception e) {} Future start(String document) async { - final tableData = await Localstore.instance.collection(collection).doc(document).get(); - if (tableData != null) { - onUpdate?.call(onLocalData(tableData['json'])); - } - - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { - if (renew == null || !renew!) return; - } - try { - final newValue = await onLoad(); - onUpdate?.call(newValue); - Localstore.instance.collection(collection).doc(document).set({ - 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch, - }); - } on Exception catch (e) { - onError(e); + final tableData = await Localstore.instance.collection(collection).doc(document).get(); + if (tableData != null) { + onUpdate?.call(onLocalData(tableData['json'])); + } + + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + if (renew == null || !renew!) return; + } + + try { + final newValue = await onLoad(); + onUpdate?.call(newValue); + Localstore.instance.collection(collection).doc(document).set({ + 'json': jsonEncode(newValue), + 'lastupdate': DateTime.now().millisecondsSinceEpoch, + }); + } on Exception catch (e) { + onError(e); + } + } finally { + if (!_ready.isCompleted) _ready.complete(); } } diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index c42f62c..483278e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -14,11 +14,23 @@ class Authenticate extends WebuntisApi { @override Future run() async { awaitingResponse = true; - var rawAnswer = await query(this); - AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); - _lastResponse = response; - if(!awaitedResponse.isCompleted) awaitedResponse.complete(); - return response; + try { + var rawAnswer = await query(this); + AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); + _lastResponse = response; + if(!awaitedResponse.isCompleted) awaitedResponse.complete(); + return response; + } catch (e) { + // Surface the error to anyone waiting on the current completer, then + // install a fresh one so a future attempt can succeed. Without this, + // any later call to getSession() would hang forever on a completer + // that is already settled with no listeners (or never settles at all). + if(!awaitedResponse.isCompleted) awaitedResponse.completeError(e); + awaitedResponse = Completer(); + rethrow; + } finally { + awaitingResponse = false; + } } static bool awaitingResponse = false; diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart index c4e4627..e986965 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart @@ -5,7 +5,16 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate}) : super(RequestCache.cacheDay, onUpdate) { + GetHolidaysCache({ + void Function(GetHolidaysResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheDay, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-holidays'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart index 33d00ee..00c155f 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart @@ -5,7 +5,16 @@ import 'getRooms.dart'; import 'getRoomsResponse.dart'; class GetRoomsCache extends RequestCache { - GetRoomsCache({void Function(GetRoomsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetRoomsCache({ + void Function(GetRoomsResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheHour, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-rooms'); } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart index bec137b..6e834a4 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart @@ -5,7 +5,16 @@ import 'getSubjects.dart'; import 'getSubjectsResponse.dart'; class GetSubjectsCache extends RequestCache { - GetSubjectsCache({void Function(GetSubjectsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetSubjectsCache({ + void Function(GetSubjectsResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheHour, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-subjects'); } diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart new file mode 100644 index 0000000..0e9c38f --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:developer'; + +import '../../webuntisApi.dart'; +import 'getTimegridUnitsResponse.dart'; + +class GetTimegridUnits extends WebuntisApi { + GetTimegridUnits() : super('getTimegridUnits', null); + + @override + Future run() async { + var rawAnswer = await query(this); + try { + return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer))); + } catch (e, trace) { + log(trace.toString()); + log('Failed to parse getTimegridUnits data with server response: $rawAnswer'); + } + + throw Exception('Failed to parse getTimegridUnits server response: $rawAnswer'); + } +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart new file mode 100644 index 0000000..6de483d --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import '../../../requestCache.dart'; +import 'getTimegridUnits.dart'; +import 'getTimegridUnitsResponse.dart'; + +class GetTimegridUnitsCache extends RequestCache { + GetTimegridUnitsCache({ + void Function(GetTimegridUnitsResponse)? onUpdate, + bool? renew, + }) : super(RequestCache.cacheDay, onUpdate, renew: renew) { + start('wu-timegrid'); + } + + @override + Future onLoad() => GetTimegridUnits().run(); + + @override + GetTimegridUnitsResponse onLocalData(String json) => GetTimegridUnitsResponse.fromJson(jsonDecode(json)); +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart new file mode 100644 index 0000000..a730567 --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart @@ -0,0 +1,38 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../apiResponse.dart'; + +part 'getTimegridUnitsResponse.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponse extends ApiResponse { + List result; + + GetTimegridUnitsResponse(this.result); + + factory GetTimegridUnitsResponse.fromJson(Map json) => _$GetTimegridUnitsResponseFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseDay { + int day; + List timeUnits; + + GetTimegridUnitsResponseDay(this.day, this.timeUnits); + + factory GetTimegridUnitsResponseDay.fromJson(Map json) => _$GetTimegridUnitsResponseDayFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseDayToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseUnit { + String name; + int startTime; + int endTime; + + GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime); + + factory GetTimegridUnitsResponseUnit.fromJson(Map json) => _$GetTimegridUnitsResponseUnitFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseUnitToJson(this); +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart new file mode 100644 index 0000000..b6fc909 --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'getTimegridUnitsResponse.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson( + Map json, +) => + GetTimegridUnitsResponse( + (json['result'] as List) + .map( + (e) => GetTimegridUnitsResponseDay.fromJson( + e as Map, + ), + ) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$GetTimegridUnitsResponseToJson( + GetTimegridUnitsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson( + Map json, +) => GetTimegridUnitsResponseDay( + (json['day'] as num).toInt(), + (json['timeUnits'] as List) + .map( + (e) => GetTimegridUnitsResponseUnit.fromJson(e as Map), + ) + .toList(), +); + +Map _$GetTimegridUnitsResponseDayToJson( + GetTimegridUnitsResponseDay instance, +) => { + 'day': instance.day, + 'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson( + Map json, +) => GetTimegridUnitsResponseUnit( + json['name'] as String, + (json['startTime'] as num).toInt(), + (json['endTime'] as num).toInt(), +); + +Map _$GetTimegridUnitsResponseUnitToJson( + GetTimegridUnitsResponseUnit instance, +) => { + 'name': instance.name, + 'startTime': instance.startTime, + 'endTime': instance.endTime, +}; diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart index 0872b70..c834030 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart @@ -15,7 +15,13 @@ class GetTimetableCache extends RequestCache { void Function(Exception)? onError, required this.startdate, required this.enddate, - }) : super(RequestCache.cacheMinute, onUpdate, onError: onError ?? RequestCache.ignore) { + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-timetable-$startdate-$enddate'); } diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 5055e08..1b142e9 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -14,6 +14,16 @@ class TimetableBloc extends LoadableHydratedBloc TimetableRepository(); @@ -35,11 +45,23 @@ class TimetableBloc extends LoadableHydratedBloc gatherData() async { final initial = innerState ?? fromNothing(); + final renew = _forceRenew; + _forceRenew = false; + + Object? firstError; + void recordError(Object e) { + firstError ??= e; + } + await Future.wait([ - _loadCurrentWeek(initial.startDate, initial.endDate), - _loadStaticReferenceData(), - _loadCustomEvents(), + _loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError, renew: renew), + _loadStaticReferenceData(onError: recordError, renew: renew), + _loadCustomEvents(onError: recordError, renew: renew), ]); + + if (firstError != null) throw firstError!; + + add(DataGathered((s) => s)); _prefetchAdjacentWeeks(initial.startDate, initial.endDate); } @@ -73,41 +95,69 @@ class TimetableBloc extends LoadableHydratedBloc _loadCurrentWeek(DateTime startDate, DateTime endDate) async { + Future _loadCurrentWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + bool renew = false, + }) async { final requestStart = DateTime.now(); _lastWeekRequestStart = requestStart; try { - final week = await repo.data.getWeek(startDate, endDate); + final week = await repo.data.getWeek(startDate, endDate, onError: onError, renew: renew); if (_lastWeekRequestStart.isAfter(requestStart)) return; _writeWeekToCache(startDate, week); - } catch (_) { - // Errors are surfaced via LoadableHydratedBloc.fetch's catchError. - rethrow; + } catch (e) { + onError?.call(e); } } - Future _loadStaticReferenceData() async { - final (rooms, subjects, schoolHolidays) = await ( - repo.data.getRooms(), - repo.data.getSubjects(), - repo.data.getSchoolHolidays(), - ).wait; + Future _loadStaticReferenceData({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final (rooms, subjects, schoolHolidays) = await ( + repo.data.getRooms(onError: onError, renew: renew), + repo.data.getSubjects(onError: onError, renew: renew), + repo.data.getSchoolHolidays(onError: onError, renew: renew), + ).wait; - add(DataGathered((s) => s.copyWith( - rooms: rooms, - subjects: subjects, - schoolHolidays: schoolHolidays, - dataVersion: s.dataVersion + 1, - ))); + add(Emit((s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + dataVersion: s.dataVersion + 1, + ))); + } catch (e) { + onError?.call(e); + } + + try { + final timegrid = await repo.data.getTimegrid(renew: renew); + add(Emit((s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1))); + } catch (_) { + // Timegrid load failure falls back to a hardcoded schedule in the UI layer. + } } - Future _loadCustomEvents({bool renew = false}) async { - final events = await repo.data.getCustomEvents(renew: renew); + Future _loadCustomEvents({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final events = await repo.data.getCustomEvents(renew: renew, onError: onError); + add(Emit((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + } catch (e) { + onError?.call(e); + } + } + + Future _refreshCustomEvents() async { + final events = await repo.data.getCustomEvents(renew: true); add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); } - Future _refreshCustomEvents() => _loadCustomEvents(renew: true); - void _prefetchAdjacentWeeks(DateTime start, DateTime end) { _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); @@ -119,7 +169,7 @@ class TimetableBloc extends LoadableHydratedBloc.of(s.weekCache); updated[key] = week; return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index cc88b26..1d1b2ea 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -4,6 +4,7 @@ import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEvent 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/getTimegridUnits/getTimegridUnitsResponse.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; part 'timetable_state.freezed.dart'; @@ -18,6 +19,7 @@ abstract class TimetableState with _$TimetableState { GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, + GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, required DateTime startDate, required DateTime endDate, diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart index 1ad6cd1..4af71be 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$TimetableState { - Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $TimetableStateCopyWith get copyWith => _$TimetableStateCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; } @@ -48,7 +48,7 @@ abstract mixin class $TimetableStateCopyWith<$Res> { factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion }); @@ -65,13 +65,14 @@ class _$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { return _then(_self.copyWith( weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable @@ -160,10 +161,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: return orElse(); } @@ -181,10 +182,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; switch (_that) { case _TimetableState(): -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: throw StateError('Unexpected subclass'); } @@ -201,10 +202,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: return null; } @@ -216,7 +217,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, @JsonSerializable() class _TimetableState extends TimetableState { - const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); final Map _weekCache; @@ -229,6 +230,7 @@ class _TimetableState extends TimetableState { @override final GetRoomsResponse? rooms; @override final GetSubjectsResponse? subjects; @override final GetHolidaysResponse? schoolHolidays; +@override final GetTimegridUnitsResponse? timegrid; @override final GetCustomTimetableEventResponse? customEvents; @override final DateTime startDate; @override final DateTime endDate; @@ -247,16 +249,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; } @@ -267,7 +269,7 @@ abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCo factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; @override @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion }); @@ -284,13 +286,14 @@ class __$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { return _then(_TimetableState( weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart index 07960f5..367b428 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -29,6 +29,11 @@ _TimetableState _$TimetableStateFromJson(Map json) => : GetHolidaysResponse.fromJson( json['schoolHolidays'] as Map, ), + timegrid: json['timegrid'] == null + ? null + : GetTimegridUnitsResponse.fromJson( + json['timegrid'] as Map, + ), customEvents: json['customEvents'] == null ? null : GetCustomTimetableEventResponse.fromJson( @@ -45,6 +50,7 @@ Map _$TimetableStateToJson(_TimetableState instance) => 'rooms': instance.rooms, 'subjects': instance.subjects, 'schoolHolidays': instance.schoolHolidays, + 'timegrid': instance.timegrid, 'customEvents': instance.customEvents, 'startDate': instance.startDate.toIso8601String(), 'endDate': instance.endDate.toIso8601String(), diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart index 261bb0d..5d394c0 100644 --- a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:intl/intl.dart'; import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; @@ -18,6 +16,8 @@ import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart'; +import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; import '../../../../../model/accountData.dart'; @@ -25,55 +25,116 @@ import '../../../../../model/accountData.dart'; class TimetableDataProvider { static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); - Future getWeek(DateTime startDate, DateTime endDate) { - final completer = Completer(); - GetTimetableCache( + Future getWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + bool renew = false, + }) async { + GetTimetableResponse? latest; + Object? capturedError; + final cache = GetTimetableCache( startdate: int.parse(_dateFormat.format(startDate)), enddate: int.parse(_dateFormat.format(endDate)), - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }, + renew: renew, + onUpdate: (data) => latest = data, onError: (e) { - if (!completer.isCompleted) completer.completeError(e); + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getWeek'); } - Future getRooms() { - final completer = Completer(); - GetRoomsCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getRooms({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetRoomsResponse? latest; + Object? capturedError; + final cache = GetRoomsCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getRooms'); } - Future getSubjects() { - final completer = Completer(); - GetSubjectsCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getSubjects({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetSubjectsResponse? latest; + Object? capturedError; + final cache = GetSubjectsCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getSubjects'); } - Future getSchoolHolidays() { - final completer = Completer(); - GetHolidaysCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getSchoolHolidays({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetHolidaysResponse? latest; + Object? capturedError; + final cache = GetHolidaysCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getSchoolHolidays'); } - Future getCustomEvents({bool renew = false}) { - final completer = Completer(); - GetCustomTimetableEventCache( + Future getTimegrid({bool renew = false}) async { + GetTimegridUnitsResponse? latest; + Object? capturedError; + final cache = GetTimegridUnitsCache( + renew: renew, + onUpdate: (data) => latest = data, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getTimegrid'); + } + + Future getCustomEvents({ + bool renew = false, + void Function(Object)? onError, + }) async { + GetCustomTimetableEventResponse? latest; + Object? capturedError; + final cache = GetCustomTimetableEventCache( GetCustomTimetableEventParams(AccountData().getUserSecret()), renew: renew, - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getCustomEvents'); } Future addCustomEvent(CustomTimetableEvent event) => diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index b383134..58d6f9e 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -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 createState() => _CustomEventEditDialogState(); } class _CustomEventEditDialogState extends State { - 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 { 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); diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart new file mode 100644 index 0000000..d24729b --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -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; diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart index 18477a4..cb4ad80 100644 --- a/lib/view/pages/timetable/data/lesson_color.dart +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -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; } } } diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart new file mode 100644 index 0000000..b163226 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -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 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 = []; + 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, + ); +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 8360c8c..868bc60 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -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( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index cfcac4a..5099577 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -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 { - final CalendarController _controller = CalendarController(); - late Timer _highlightTicker; + final GlobalKey _calendarKey = GlobalKey(); - LessonAppointmentSource? _cachedSource; + List? _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 { } } - LessonAppointmentSource _appointmentSource(TimetableState state) { - if (_cachedSource != null && _lastDataVersion == state.dataVersion) { - return _cachedSource!; + List _appointments(TimetableState state) { + if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) { + return _cachedAppointments!; } _lastDataVersion = state.dataVersion; final settings = context.read(); - 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 { 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, + ); } } diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 15fcea1..e34a5d5 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -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()), + ), + ), + ], + ), ); } } diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart new file mode 100644 index 0000000..18ed146 --- /dev/null +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -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 appointments; + final List 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 createState() => CustomWorkWeekCalendarState(); +} + +class CustomWorkWeekCalendarState extends State { + static const double _rulerWidth = 50; + + late PageController _pageController; + late int _currentWeekIndex; + late DateTime _firstMonday; + late int _totalWeeks; + late Timer _ticker; + late ValueNotifier _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.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( + 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 appointments; + final List timeRegions; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final DateTime today; + final ValueListenable 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 appointments; + final List timeRegions; + final double pxPerHour; + final DateTime today; + final ValueListenable 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 dayAppts) { + for (final a in dayAppts) { + if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; + } + return false; + } + + void _handleLongPress(LongPressStartDetails details, List 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( + 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 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> _expandAppointmentsForWeek( + List appointments, DateTime weekStart) { + final perDay = List>.generate(5, (_) => []); + 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; +} diff --git a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart deleted file mode 100644 index 9269184..0000000 --- a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -class LessonAppointmentSource extends CalendarDataSource { - LessonAppointmentSource(List source) { - appointments = source; - } -} diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 0ab0115..6587b93 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -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 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.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, diff --git a/lib/view/pages/timetable/widgets/time_region_tile.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart index 10c074b..9fa946c 100644 --- a/lib/view/pages/timetable/widgets/time_region_tile.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -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(); } } diff --git a/lib/widget/userAvatar.dart b/lib/widget/userAvatar.dart index a9a5d92..4a9cb8b 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/userAvatar.dart @@ -1,45 +1,139 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:http/http.dart' as http; import '../model/accountData.dart'; import '../model/endpointData.dart'; -class UserAvatar extends StatelessWidget { +class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key}); @override - Widget build(BuildContext context) { - if(isGroup) { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar', - errorListener: (p0) {} - ) - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.group, size: size.toDouble()), - ); - } else { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${EndpointData().nextcloud().full()}/avatar/$id/$size', - errorListener: (p0) {} - ), - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.person, size: size.toDouble()), - ); + State createState() => _UserAvatarState(); +} + +class _AvatarPayload { + final Uint8List bytes; + final bool isSvg; + _AvatarPayload(this.bytes, this.isSvg); +} + +final Map> _avatarCache = {}; + +class _UserAvatarState extends State { + late Future<_AvatarPayload?> _payload; + + @override + void initState() { + super.initState(); + _payload = _load(); + } + + @override + void didUpdateWidget(UserAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.id != widget.id || + oldWidget.isGroup != widget.isGroup || + oldWidget.size != widget.size) { + _payload = _load(); } } + + String _url() { + final host = EndpointData().nextcloud().full(); + if (widget.isGroup) { + return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar'; + } + return 'https://$host/avatar/${widget.id}/${widget.size}'; + } + + Future<_AvatarPayload?> _load() { + final url = _url(); + return _avatarCache.putIfAbsent(url, () => _fetch(url)); + } + + Future<_AvatarPayload?> _fetch(String url) async { + try { + final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString())); + final response = await http.get( + Uri.parse(url), + headers: { + 'Authorization': 'Basic $auth', + 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', + }, + ); + if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null; + + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + final bytes = response.bodyBytes; + final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); + return _AvatarPayload(bytes, isSvg); + } catch (_) { + return null; + } + } + + static bool _looksLikeSvg(Uint8List bytes) { + final head = utf8.decode( + bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), + allowMalformed: true, + ).trimLeft(); + return head.startsWith('( + future: _payload, + builder: (context, snapshot) { + final payload = snapshot.data; + + Widget content; + if (payload == null) { + content = Icon( + widget.isGroup ? Icons.group : Icons.person, + size: radius, + color: Colors.white, + ); + } else if (payload.isSvg) { + content = SvgPicture.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + ); + } else { + content = Image.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), + ); + }, + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index aa90db4..a8be8b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: flutter_login: ^6.0.0 flutter_native_splash: ^2.4.4 flutter_split_view: ^0.1.2 + flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 http: ^1.3.0 hydrated_bloc: ^11.0.0