From ca16d6efd2ac12e3dc02886c8e2d590afabb1a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20M=C3=BChlberger?= Date: Fri, 17 Apr 2026 10:51:54 +0200 Subject: [PATCH 1/4] Add recurring calendar series support --- assets/translations/de.json | 13 + assets/translations/en.json | 13 + .../model/calendar_editing.dart | 4 + .../model/calendar_preferences.dart | 8 +- .../model/calendar_preferences.g.dart | 5 + .../services/calendar_preference_service.dart | 83 ++- .../services/calendar_service.dart | 2 +- .../services/calendar_view_service.dart | 54 +- .../calendar_addition_viewmodel.dart | 596 +++++++++++++++--- .../viewModels/calendar_viewmodel.dart | 207 +++++- .../views/custom_event_view.dart | 206 ++++-- 11 files changed, 960 insertions(+), 231 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index 6b8c9d67..461f09b2 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -174,8 +174,21 @@ "annotation": "Bemerkung", "from": "Von", "to": "Bis", + "recurrence": "Wiederholen", + "recurrenceDaily": "Tag", + "recurrenceWeekly": "Woche", + "recurrenceBiweekly": "2 Wochen", + "recurrenceMonthly": "Monat", + "repeatEvery": "Wiederholen alle", + "for": "für", + "until": "bis", + "times": "Mal", "timeFrame": "Zeitrahmen", "delete": "Löschen", + "series": "Terminserie", + "editingSeriesBanner": "Alle Wiederholungen dieser Terminserie werden aktualisiert", + "deleteThisEvent": "Dieses Ereignis löschen", + "deleteThisAndFollowing": "Dieses und folgende löschen", "deviceSettings": "Geräte-Einstellungen", "edit": "Editieren", "digitalStudentCard": "StudentCard", diff --git a/assets/translations/en.json b/assets/translations/en.json index c1c49833..be68ab2e 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -174,8 +174,21 @@ "annotation": "Annotation", "from": "From", "to": "To", + "recurrence": "Repeat", + "recurrenceDaily": "day", + "recurrenceWeekly": "week", + "recurrenceBiweekly": "2 weeks", + "recurrenceMonthly": "month", + "repeatEvery": "Repeat every", + "for": "for", + "until": "until", + "times": "times", "timeFrame": "Time Frame", "delete": "Delete", + "series": "Series", + "editingSeriesBanner": "All occurrences in this series will be updated", + "deleteThisEvent": "Delete this event", + "deleteThisAndFollowing": "Delete this and following", "deviceSettings": "Device Settings", "edit": "Edit", "digitalStudentCard": "StudentCard", diff --git a/lib/calendarComponent/model/calendar_editing.dart b/lib/calendarComponent/model/calendar_editing.dart index 9f60788c..c2908f02 100644 --- a/lib/calendarComponent/model/calendar_editing.dart +++ b/lib/calendarComponent/model/calendar_editing.dart @@ -2,6 +2,10 @@ import 'package:json_annotation/json_annotation.dart'; part 'calendar_editing.g.dart'; +enum RecurrenceType { none, daily, weekly, biweekly, monthly } + +enum RecurrenceEndType { count, untilDate } + class AddedCalendarEvent { final String title; final String? annotation; diff --git a/lib/calendarComponent/model/calendar_preferences.dart b/lib/calendarComponent/model/calendar_preferences.dart index 964598df..ad34f4b8 100644 --- a/lib/calendarComponent/model/calendar_preferences.dart +++ b/lib/calendarComponent/model/calendar_preferences.dart @@ -6,8 +6,14 @@ part 'calendar_preferences.g.dart'; class CalendarPreferences { final Map colorPreferences; final Map visibilityPreferences; + @JsonKey(defaultValue: {}) + final Map seriesPreferences; - CalendarPreferences(this.colorPreferences, this.visibilityPreferences); + CalendarPreferences( + this.colorPreferences, + this.visibilityPreferences, [ + this.seriesPreferences = const {}, + ]); factory CalendarPreferences.fromJson(Map json) => _$CalendarPreferencesFromJson(json); diff --git a/lib/calendarComponent/model/calendar_preferences.g.dart b/lib/calendarComponent/model/calendar_preferences.g.dart index 4415e1d7..a3aa67c7 100644 --- a/lib/calendarComponent/model/calendar_preferences.g.dart +++ b/lib/calendarComponent/model/calendar_preferences.g.dart @@ -10,6 +10,10 @@ CalendarPreferences _$CalendarPreferencesFromJson(Map json) => CalendarPreferences( Map.from(json['colorPreferences'] as Map), Map.from(json['visibilityPreferences'] as Map), + (json['seriesPreferences'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ) ?? + {}, ); Map _$CalendarPreferencesToJson( @@ -17,4 +21,5 @@ Map _$CalendarPreferencesToJson( ) => { 'colorPreferences': instance.colorPreferences, 'visibilityPreferences': instance.visibilityPreferences, + 'seriesPreferences': instance.seriesPreferences, }; diff --git a/lib/calendarComponent/services/calendar_preference_service.dart b/lib/calendarComponent/services/calendar_preference_service.dart index 3dd9a7bc..194f674e 100644 --- a/lib/calendarComponent/services/calendar_preference_service.dart +++ b/lib/calendarComponent/services/calendar_preference_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:campus_flutter/base/extensions/color.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_preferences.dart'; @@ -13,23 +14,18 @@ class CalendarPreferenceService { Map colorPreferences = {}; Map visibilityPreferences = {}; + Map seriesPreferences = {}; + bool _loaded = false; CalendarPreferenceService(this.sharedPreferences); void saveColorPreference(String id, Color color) { colorPreferences[id] = color.intValue; - try { - sharedPreferences.setString( - key, - jsonEncode( - CalendarPreferences(colorPreferences, visibilityPreferences).toJson(), - ), - ); - } catch (_) {} + _persist(); } Color? getColorPreference(String key) { - if (colorPreferences.isEmpty) { + if (!_loaded) { loadPreferences(); } @@ -39,24 +35,65 @@ class CalendarPreferenceService { void saveVisibilityPreference(String id, bool isVisible) { visibilityPreferences[id] = isVisible; - try { - sharedPreferences.setString( - key, - jsonEncode( - CalendarPreferences(colorPreferences, visibilityPreferences).toJson(), - ), - ); - } catch (_) {} + _persist(); } bool? getVisibilityPreference(String key) { - if (visibilityPreferences.isEmpty) { + if (!_loaded) { loadPreferences(); } return visibilityPreferences[key]; } + void saveSeriesId(String eventId, String seriesId) { + seriesPreferences[eventId] = seriesId; + _persist(); + } + + String? getSeriesId(String eventId) { + if (!_loaded) { + loadPreferences(); + } + return seriesPreferences[eventId]; + } + + List getSeriesEventIds(String seriesId) { + if (!_loaded) { + loadPreferences(); + } + return seriesPreferences.entries + .where((e) => e.value == seriesId) + .map((e) => e.key) + .toList(); + } + + void removeEventPreferences(Iterable eventIds) { + for (final eventId in eventIds) { + colorPreferences.remove(eventId); + visibilityPreferences.remove(eventId); + seriesPreferences.remove(eventId); + } + _persist(); + } + + void _persist() { + try { + sharedPreferences.setString( + key, + jsonEncode( + CalendarPreferences( + colorPreferences, + visibilityPreferences, + seriesPreferences, + ).toJson(), + ), + ); + } catch (e) { + log('Failed to persist calendar preferences: $e'); + } + } + void loadPreferences() { try { final data = sharedPreferences.getString(key); @@ -67,11 +104,19 @@ class CalendarPreferenceService { ); colorPreferences = calendarPreferences.colorPreferences; visibilityPreferences = calendarPreferences.visibilityPreferences; + seriesPreferences = Map.from(calendarPreferences.seriesPreferences); } - } catch (_) {} + _loaded = true; + } catch (e) { + log('Failed to load calendar preferences: $e'); + } } void resetPreferences() { sharedPreferences.remove(key); + colorPreferences = {}; + visibilityPreferences = {}; + seriesPreferences = {}; + _loaded = true; } } diff --git a/lib/calendarComponent/services/calendar_service.dart b/lib/calendarComponent/services/calendar_service.dart index 0b8a5c69..fb5e17bb 100644 --- a/lib/calendarComponent/services/calendar_service.dart +++ b/lib/calendarComponent/services/calendar_service.dart @@ -48,7 +48,7 @@ class CalendarService { static Future deleteCalendarEvent(String id) async { RestClient restClient = getIt(); - restClient.getWithException( + await restClient.getWithException( TumOnlineApi(TumOnlineEndpointEventDelete(eventId: id)), CalendarDeletionConfirmationData.fromJson, TumOnlineApiException.fromJson, diff --git a/lib/calendarComponent/services/calendar_view_service.dart b/lib/calendarComponent/services/calendar_view_service.dart index d89e2dfb..6e460d87 100644 --- a/lib/calendarComponent/services/calendar_view_service.dart +++ b/lib/calendarComponent/services/calendar_view_service.dart @@ -1,8 +1,9 @@ import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; +import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; import 'package:campus_flutter/calendarComponent/views/custom_event_view.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:campus_flutter/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -61,42 +62,23 @@ class CalendarViewService { if (calendarEvent != null && calendarEvent.url != null) { context.push(calendarDetails, extra: calendarEvent); } else if (calendarEvent != null) { - showDialog( + final canDeleteSeries = ref + .read(calendarViewModel) + .canDeleteRecurringSeriesFrom(calendarEvent); + final isSeries = + getIt().getSeriesId(calendarEvent.id) != + null; + + showModalBottomSheet( context: context, - builder: (context) => AlertDialog( - title: Text( - calendarEvent!.title ?? "-", - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - content: CustomEventView(calendarEvent: calendarEvent), - actionsAlignment: MainAxisAlignment.center, - actions: [ - ElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(Colors.redAccent), - ), - onPressed: () { - ref - .read(calendarViewModel) - .deleteCalendarElement(calendarEvent!.id) - .then((value) { - if (context.mounted) { - context.pop(); - } - }); - }, - child: Text(context.tr("delete")), - ), - ElevatedButton( - onPressed: () { - context.pop(); - context.push(eventCreation, extra: calendarEvent); - }, - child: Text(context.tr("edit")), - ), - ], + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => CustomEventView( + calendarEvent: calendarEvent!, + canDeleteSeries: canDeleteSeries, + isSeries: isSeries, ), ); } diff --git a/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart b/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart index 11922b97..5ba2af4e 100644 --- a/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart +++ b/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart @@ -1,12 +1,18 @@ +import 'dart:developer'; + import 'package:campus_flutter/calendarComponent/model/calendar_editing.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; +import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/services/calendar_service.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; import 'package:campus_flutter/calendarComponent/views/calendars_view.dart'; +import 'package:campus_flutter/base/theme/constants.dart'; +import 'package:campus_flutter/main.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; final calendarAdditionViewModel = Provider.autoDispose .family((ref, calendarEvent) { @@ -23,10 +29,30 @@ class CalendarAdditionViewModel { final BehaviorSubject isValid = BehaviorSubject.seeded(false); late final BehaviorSubject from; late final BehaviorSubject to; + final BehaviorSubject recurrenceType = BehaviorSubject.seeded( + RecurrenceType.none, + ); + final BehaviorSubject occurrenceCount = BehaviorSubject.seeded(2); + final BehaviorSubject recurrenceEndType = + BehaviorSubject.seeded(RecurrenceEndType.untilDate); + late final BehaviorSubject untilDate; + final BehaviorSubject selectedColor = BehaviorSubject.seeded( + primaryLightColor, + ); + final BehaviorSubject isSaving = BehaviorSubject.seeded(false); + + static const int maxOccurrences = 20; + static const _uuid = Uuid(); String? id; + String? seriesId; final Ref ref; bool _isSaving = false; + bool get isEditing => id != null; + bool get isEditingSeries => isEditing && seriesId != null; + bool get hasRecurrenceEnabled => recurrenceType.value != RecurrenceType.none; + bool get shouldShowSeriesBanner => isEditing && hasRecurrenceEnabled; + bool get hasValidTimeFrame => to.value.isAfter(from.value); CalendarAdditionViewModel(this.ref) { final date = ref.read(selectedDate); @@ -34,135 +60,539 @@ class CalendarAdditionViewModel { to = BehaviorSubject.seeded( (date.$1 ?? DateTime.now()).add(const Duration(hours: 1)), ); + untilDate = BehaviorSubject.seeded( + (date.$1 ?? DateTime.now()).add(const Duration(days: 30)), + ); + checkValidity(); } CalendarAdditionViewModel.edit(this.ref, CalendarEvent calendarEvent) { - titleController.text = calendarEvent.title ?? ""; - annotationController.text = calendarEvent.description ?? ""; - from = BehaviorSubject.seeded(calendarEvent.startDate); - to = BehaviorSubject.seeded(calendarEvent.endDate); id = calendarEvent.id; - isValid.value = true; + seriesId = getIt().getSeriesId(calendarEvent.id); + + final initialEvent = _resolveInitialEditingEvent(calendarEvent); + titleController.text = initialEvent.title ?? ""; + annotationController.text = initialEvent.description ?? ""; + from = BehaviorSubject.seeded(initialEvent.startDate); + to = BehaviorSubject.seeded(initialEvent.endDate); + untilDate = BehaviorSubject.seeded( + initialEvent.startDate.add(const Duration(days: 30)), + ); + final colorPreference = getIt() + .getColorPreference(calendarEvent.lvNr ?? calendarEvent.id); + selectedColor.add(colorPreference ?? initialEvent.getColor()); + + if (isEditingSeries) { + _seedSeriesRecurrence(); + } + + checkValidity(); + } + + CalendarEvent _resolveInitialEditingEvent(CalendarEvent fallbackEvent) { + if (seriesId == null) return fallbackEvent; + + return ref + .read(calendarViewModel) + .getSeriesSiblings(seriesId!) + .firstOrNull ?? + fallbackEvent; + } + + void _seedSeriesRecurrence() { + final siblings = ref.read(calendarViewModel).getSeriesSiblings(seriesId!); + if (siblings.length < 2) { + recurrenceType.add(RecurrenceType.none); + occurrenceCount.add(2); + recurrenceEndType.add(RecurrenceEndType.count); + untilDate.add(from.value); + return; + } + + recurrenceType.add(_inferRecurrenceType(siblings)); + occurrenceCount.add(siblings.length.clamp(2, maxOccurrences)); + recurrenceEndType.add(RecurrenceEndType.count); + untilDate.add(siblings.last.startDate); + } + + RecurrenceType _inferRecurrenceType(List siblings) { + if (siblings.length < 2) return RecurrenceType.none; + + final first = siblings[0].startDate; + final second = siblings[1].startDate; + final dayDelta = DateUtils.dateOnly( + second, + ).difference(DateUtils.dateOnly(first)).inDays; + + if (dayDelta == 1) return RecurrenceType.daily; + if (dayDelta == 7) return RecurrenceType.weekly; + if (dayDelta == 14) return RecurrenceType.biweekly; + + final nextMonth = _addMonths(first, 1); + if (nextMonth == second) return RecurrenceType.monthly; + + return RecurrenceType.weekly; + } + + void setSelectedColor(Color color) { + selectedColor.add(color); + } + + void setRecurrenceType(RecurrenceType type) { + recurrenceType.add(type); + } + + void setOccurrenceCount(int count) { + occurrenceCount.add(count.clamp(2, maxOccurrences)); + } + + void setRecurrenceEndType(RecurrenceEndType type) { + recurrenceEndType.add(type); + } + + void setUntilDate(DateTime? date) { + if (date != null) { + untilDate.add(_normalizeUntilDate(date)); + checkValidity(); + } } void setFromDate(DateTime? dateTime) { if (dateTime != null) { - from.add( - DateTime( - dateTime.year, - dateTime.month, - dateTime.day, - from.value.hour, - from.value.minute, - ), - ); - to.add( - DateTime( - dateTime.year, - dateTime.month, - dateTime.day, - from.value.hour, - from.value.minute, - 0, - ), + final duration = to.value.difference(from.value); + final newFrom = DateTime( + dateTime.year, + dateTime.month, + dateTime.day, + from.value.hour, + from.value.minute, ); + from.add(newFrom); + to.add(newFrom.add(duration)); + _clampUntilDateToFrom(); + checkValidity(); } } void setFromTimeOfDay(TimeOfDay? timeOfDay) { if (timeOfDay != null) { - from.add( - DateTime( - from.value.year, - from.value.month, - from.value.day, - timeOfDay.hour, - timeOfDay.minute, - 0, - ), + final duration = to.value.difference(from.value); + final newFrom = DateTime( + from.value.year, + from.value.month, + from.value.day, + timeOfDay.hour, + timeOfDay.minute, + 0, ); + from.add(newFrom); + to.add(newFrom.add(duration)); + _clampUntilDateToFrom(); + checkValidity(); } } void setToDate(DateTime? dateTime) { if (dateTime != null) { + final candidate = DateTime( + dateTime.year, + dateTime.month, + dateTime.day, + to.value.hour, + to.value.minute, + 0, + ); to.add( - DateTime( - dateTime.year, - dateTime.month, - dateTime.day, - to.value.hour, - to.value.minute, - 0, - ), + candidate.isAfter(from.value) + ? candidate + : from.value.add(const Duration(hours: 1)), ); + checkValidity(); } } void setToTimeOfDay(TimeOfDay? timeOfDay) { if (timeOfDay != null) { + final candidate = DateTime( + to.value.year, + to.value.month, + to.value.day, + timeOfDay.hour, + timeOfDay.minute, + 0, + ); to.add( - DateTime( - to.value.year, - to.value.month, - to.value.day, - timeOfDay.hour, - timeOfDay.minute, - 0, - ), + candidate.isAfter(from.value) + ? candidate + : from.value.add(const Duration(hours: 1)), ); + checkValidity(); + } + } + + List<(DateTime, DateTime)> _generateOccurrences() { + final type = recurrenceType.value; + if (type == RecurrenceType.none) { + return [(from.value, to.value)]; + } + + final endType = recurrenceEndType.value; + final limit = endType == RecurrenceEndType.untilDate + ? maxOccurrences + : occurrenceCount.value; + final deadline = endType == RecurrenceEndType.untilDate + ? DateUtils.dateOnly(untilDate.value) + : null; + final List<(DateTime, DateTime)> dates = []; + + for (int i = 0; i < limit; i++) { + final DateTime startOffset; + final DateTime endOffset; + + switch (type) { + case RecurrenceType.daily: + startOffset = from.value.add(Duration(days: i)); + endOffset = to.value.add(Duration(days: i)); + case RecurrenceType.weekly: + startOffset = from.value.add(Duration(days: 7 * i)); + endOffset = to.value.add(Duration(days: 7 * i)); + case RecurrenceType.biweekly: + startOffset = from.value.add(Duration(days: 14 * i)); + endOffset = to.value.add(Duration(days: 14 * i)); + case RecurrenceType.monthly: + startOffset = _addMonths(from.value, i); + endOffset = startOffset.add(to.value.difference(from.value)); + case RecurrenceType.none: + startOffset = from.value; + endOffset = to.value; + } + + if (deadline != null && + DateUtils.dateOnly(startOffset).isAfter(deadline)) { + break; + } + + dates.add((startOffset, endOffset)); + } + + return dates; + } + + static DateTime _addMonths(DateTime date, int months) { + final targetMonth = date.month + months; + final year = date.year + (targetMonth - 1) ~/ 12; + final month = (targetMonth - 1) % 12 + 1; + final lastDay = DateUtils.getDaysInMonth(year, month); + return DateTime( + year, + month, + date.day.clamp(1, lastDay), + date.hour, + date.minute, + ); + } + + DateTime _normalizeUntilDate(DateTime date) { + final normalizedDate = DateUtils.dateOnly(date); + final minimumDate = DateUtils.dateOnly(from.value); + final clampedDate = normalizedDate.isBefore(minimumDate) + ? minimumDate + : normalizedDate; + return clampedDate.add( + const Duration(hours: 23, minutes: 59, seconds: 59, milliseconds: 999), + ); + } + + void _clampUntilDateToFrom() { + final normalized = _normalizeUntilDate(untilDate.value); + if (untilDate.value != normalized) { + untilDate.add(normalized); + } + } + + _EventPreferenceSnapshot _capturePreferenceSnapshot( + CalendarPreferenceService preferenceService, + String eventId, + ) { + return _EventPreferenceSnapshot( + color: preferenceService.getColorPreference(eventId), + isVisible: preferenceService.getVisibilityPreference(eventId), + seriesId: preferenceService.getSeriesId(eventId), + ); + } + + void _applyPreferenceSnapshot( + CalendarPreferenceService preferenceService, + String eventId, + _EventPreferenceSnapshot snapshot, + ) { + if (snapshot.color != null) { + preferenceService.saveColorPreference(eventId, snapshot.color!); + } + if (snapshot.isVisible != null) { + preferenceService.saveVisibilityPreference(eventId, snapshot.isVisible!); + } + if (snapshot.seriesId != null) { + preferenceService.saveSeriesId(eventId, snapshot.seriesId!); + } + } + + Future _deleteCreatedEvents( + CalendarPreferenceService preferenceService, + Iterable eventIds, + ) async { + final deletedIds = []; + for (final eventId in eventIds) { + try { + await CalendarService.deleteCalendarEvent(eventId); + deletedIds.add(eventId); + } catch (e) { + log('Failed to clean up created event $eventId: $e'); + } + } + if (deletedIds.isNotEmpty) { + preferenceService.removeEventPreferences(deletedIds); + } + } + + Future _restoreOriginalEvents( + CalendarPreferenceService preferenceService, + Iterable eventsToRestore, + Map snapshots, + ) async { + final restoredOriginalIds = []; + + for (final event in eventsToRestore) { + try { + final response = await CalendarService.createCalendarEvent( + AddedCalendarEvent( + title: event.title ?? '-', + annotation: event.description, + from: event.startDate, + to: event.endDate, + ), + ); + final snapshot = snapshots[event.id]; + if (snapshot != null) { + _applyPreferenceSnapshot( + preferenceService, + response.eventId, + snapshot, + ); + } + restoredOriginalIds.add(event.id); + } catch (e) { + log('Failed to restore original event ${event.id}: $e'); + } + } + + if (restoredOriginalIds.isNotEmpty) { + preferenceService.removeEventPreferences(restoredOriginalIds); + } + + if (restoredOriginalIds.length != eventsToRestore.length) { + throw Exception('Failed to restore the original calendar events.'); } } Future saveEvent() async { if (_isSaving) return; _isSaving = true; + isSaving.add(true); try { - if (id != null) { - await CalendarService.deleteCalendarEvent(id!); - } - final response = await CalendarService.createCalendarEvent( - AddedCalendarEvent( - title: titleController.text, - annotation: annotationController.text.isEmpty - ? null - : annotationController.text, - from: from.value, - to: to.value, - ), - ); - await ref.read(calendarViewModel).fetch(true); - if (ref - .read(calendarViewModel) - .events - .value - ?.firstWhereOrNull((e) => e.id == response.eventId) == - null) { - ref + final preferenceService = getIt(); + final createdEventIds = []; + + if (isEditingSeries) { + // ── Series edit: update all siblings ────────────────────────────── + final siblings = ref .read(calendarViewModel) - .events - .value - ?.add( - CalendarEvent( - id: response.eventId, - status: "FT", + .getSeriesSiblings(seriesId!); + final occurrences = _generateOccurrences(); + final snapshots = { + for (final sibling in siblings) + sibling.id: _capturePreferenceSnapshot( + preferenceService, + sibling.id, + ), + }; + final deletedIds = []; + + for (final sibling in siblings) { + try { + await CalendarService.deleteCalendarEvent(sibling.id); + deletedIds.add(sibling.id); + } catch (_) { + await _restoreOriginalEvents( + preferenceService, + siblings.where((sibling) => deletedIds.contains(sibling.id)), + snapshots, + ); + throw Exception( + 'Failed to delete ${siblings.length - deletedIds.length} of ' + '${siblings.length} series events. Original events were restored.', + ); + } + } + + try { + for (final (start, end) in occurrences) { + final response = await CalendarService.createCalendarEvent( + AddedCalendarEvent( + title: titleController.text, + annotation: annotationController.text.isEmpty + ? null + : annotationController.text, + from: start, + to: end, + ), + ); + createdEventIds.add(response.eventId); + if (occurrences.length > 1) { + preferenceService.saveSeriesId(response.eventId, seriesId!); + } + } + } catch (_) { + await _deleteCreatedEvents(preferenceService, createdEventIds); + await _restoreOriginalEvents(preferenceService, siblings, snapshots); + throw Exception( + 'Failed to update the series. Original events were restored.', + ); + } + + for ( + int index = 0; + index < createdEventIds.length && index < siblings.length; + index++ + ) { + final snapshot = snapshots[siblings[index].id]; + if (snapshot?.isVisible != null) { + preferenceService.saveVisibilityPreference( + createdEventIds[index], + snapshot!.isVisible!, + ); + } + } + + preferenceService.removeEventPreferences( + siblings.map((sibling) => sibling.id), + ); + } else { + // ── Single / new event ──────────────────────────────────────────── + final occurrences = _generateOccurrences(); + final newSeriesId = occurrences.length > 1 ? _uuid.v4() : null; + final originalSnapshot = id != null + ? _capturePreferenceSnapshot(preferenceService, id!) + : null; + + try { + for (final (start, end) in occurrences) { + final response = await CalendarService.createCalendarEvent( + AddedCalendarEvent( title: titleController.text, - startDate: from.value, - endDate: to.value, - locations: [], + annotation: annotationController.text.isEmpty + ? null + : annotationController.text, + from: start, + to: end, ), ); + createdEventIds.add(response.eventId); + if (newSeriesId != null) { + preferenceService.saveSeriesId(response.eventId, newSeriesId); + } + } + } catch (_) { + await _deleteCreatedEvents(preferenceService, createdEventIds); + throw Exception( + 'Failed to create the event. No changes were applied.', + ); + } + + if (id != null) { + try { + await CalendarService.deleteCalendarEvent(id!); + } catch (_) { + await _deleteCreatedEvents(preferenceService, createdEventIds); + throw Exception( + 'Failed to replace the event. The original event was kept.', + ); + } + + preferenceService.removeEventPreferences([id!]); + if (originalSnapshot?.isVisible != null) { + for (final eventId in createdEventIds) { + preferenceService.saveVisibilityPreference( + eventId, + originalSnapshot!.isVisible!, + ); + } + } + } + } + + final calendarVm = ref.read(calendarViewModel); + for (final eventId in createdEventIds) { + calendarVm.setEventColor( + eventId, + selectedColor.value, + notifyListeners: false, + ); + } + if (createdEventIds.isNotEmpty) { + calendarVm.notifyEventChanges(); + } + + await calendarVm.fetch(true); + + if (createdEventIds.isNotEmpty && + calendarVm.events.value?.firstWhereOrNull( + (e) => e.id == createdEventIds.last, + ) == + null) { + // Add the first occurrence as a placeholder if not yet fetched + calendarVm.events.value?.add( + CalendarEvent( + id: createdEventIds.last, + status: "FT", + title: titleController.text, + startDate: from.value, + endDate: to.value, + locations: [], + color: selectedColor.value.toARGB32(), + ), + ); } } finally { _isSaving = false; + isSaving.add(false); } } void checkValidity() { - if (titleController.text.isNotEmpty) { - isValid.add(true); - } else { - isValid.add(false); - } + final hasValidUntilDate = + recurrenceType.value != RecurrenceType.none && + recurrenceEndType.value == RecurrenceEndType.untilDate + ? !DateUtils.dateOnly( + untilDate.value, + ).isBefore(DateUtils.dateOnly(from.value)) + : true; + isValid.add( + titleController.text.trim().isNotEmpty && + hasValidTimeFrame && + hasValidUntilDate, + ); } } + +class _EventPreferenceSnapshot { + final Color? color; + final bool? isVisible; + final String? seriesId; + + const _EventPreferenceSnapshot({ + required this.color, + required this.isVisible, + required this.seriesId, + }); +} diff --git a/lib/calendarComponent/viewModels/calendar_viewmodel.dart b/lib/calendarComponent/viewModels/calendar_viewmodel.dart index 44aae947..8a82ae4d 100644 --- a/lib/calendarComponent/viewModels/calendar_viewmodel.dart +++ b/lib/calendarComponent/viewModels/calendar_viewmodel.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:campus_flutter/base/enums/user_preference.dart'; +import 'package:campus_flutter/calendarComponent/model/calendar_editing.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/services/calendar_service.dart'; @@ -23,36 +25,34 @@ class CalendarViewModel { final BehaviorSubject<(List, List)?> widgetEvents = BehaviorSubject.seeded(null); - Future fetch(bool forcedRefresh) async { + Future fetch(bool forcedRefresh) async { isLoading.add(true); - CalendarService.fetchCalendar(forcedRefresh).then( - (response) { - lastFetched.add(response.$1); - response.$2.removeWhere((element) => element.isCanceled); - getIt().loadPreferences(); - for (var element in response.$2) { - final eventColor = getIt() - .getColorPreference(element.lvNr ?? element.id); - if (eventColor != null) { - element.setColor(eventColor); - } + try { + final response = await CalendarService.fetchCalendar(forcedRefresh); + lastFetched.add(response.$1); + response.$2.removeWhere((element) => element.isCanceled); + getIt().loadPreferences(); + for (var element in response.$2) { + final eventColor = getIt() + .getColorPreference(element.lvNr ?? element.id); + if (eventColor != null) { + element.setColor(eventColor); + } - final eventVisibility = getIt() - .getVisibilityPreference(element.lvNr ?? element.id); - if (eventVisibility != null) { - element.isVisible = eventVisibility; - } + final eventVisibility = getIt() + .getVisibilityPreference(element.lvNr ?? element.id); + if (eventVisibility != null) { + element.isVisible = eventVisibility; } - events.add(response.$2); - isLoading.add(false); - updateHomeWidget(response.$2); - _syncToDeviceCalendar(response.$2); - }, - onError: (error) { - isLoading.add(false); - events.addError(error); - }, - ); + } + events.add(response.$2); + updateHomeWidget(response.$2); + _syncToDeviceCalendar(response.$2); + } catch (error) { + events.addError(error); + } finally { + isLoading.add(false); + } } Future updateHomeWidget(List calendarEvents) async { @@ -119,11 +119,137 @@ class CalendarViewModel { return (leftColumn, rightColumn); } - Future deleteCalendarElement(String id) async { - await CalendarService.deleteCalendarEvent(id).then((value) => fetch(true)); + List getSeriesSiblings(String seriesId) { + final siblingIds = getIt() + .getSeriesEventIds(seriesId) + .toSet(); + return (events.value ?? []).where((e) => siblingIds.contains(e.id)).toList() + ..sort((a, b) => a.startDate.compareTo(b.startDate)); + } + + int getSeriesEventCount(CalendarEvent event) { + final seriesId = getIt().getSeriesId(event.id); + if (seriesId == null) return 1; + + return getIt() + .getSeriesEventIds(seriesId) + .length; + } + + Future deleteCustomCalendarEvent(CalendarEvent event) async { + await CalendarService.deleteCalendarEvent(event.id); + getIt().removeEventPreferences([event.id]); + await fetch(true); + } + + Future deleteRecurringSeries(CalendarEvent event) async { + final preferenceService = getIt(); + final seriesId = preferenceService.getSeriesId(event.id); + if (seriesId == null) { + await deleteCustomCalendarEvent(event); + return; + } + + final eventIdsToDelete = preferenceService.getSeriesEventIds(seriesId); + final originalEvents = (events.value ?? []) + .where((calendarEvent) => eventIdsToDelete.contains(calendarEvent.id)) + .toList(); + final snapshots = { + for (final calendarEvent in originalEvents) + calendarEvent.id: _capturePreferenceSnapshot( + preferenceService, + calendarEvent.id, + ), + }; + + if (eventIdsToDelete.isEmpty) return; + + final deletedIds = []; + try { + for (final eventId in eventIdsToDelete) { + await CalendarService.deleteCalendarEvent(eventId); + deletedIds.add(eventId); + } + } catch (_) { + await _restoreDeletedSeriesEvents( + preferenceService, + originalEvents.where( + (calendarEvent) => deletedIds.contains(calendarEvent.id), + ), + snapshots, + ); + await fetch(true); + rethrow; + } + + preferenceService.removeEventPreferences(deletedIds); + await fetch(true); + } + + _EventPreferenceSnapshot _capturePreferenceSnapshot( + CalendarPreferenceService preferenceService, + String eventId, + ) { + return _EventPreferenceSnapshot( + color: preferenceService.getColorPreference(eventId), + isVisible: preferenceService.getVisibilityPreference(eventId), + seriesId: preferenceService.getSeriesId(eventId), + ); + } + + Future _restoreDeletedSeriesEvents( + CalendarPreferenceService preferenceService, + Iterable eventsToRestore, + Map snapshots, + ) async { + final restoredOriginalIds = []; + + for (final calendarEvent in eventsToRestore) { + try { + final response = await CalendarService.createCalendarEvent( + AddedCalendarEvent( + title: calendarEvent.title ?? '-', + annotation: calendarEvent.description, + from: calendarEvent.startDate, + to: calendarEvent.endDate, + ), + ); + + final snapshot = snapshots[calendarEvent.id]; + if (snapshot != null) { + if (snapshot.color != null) { + preferenceService.saveColorPreference( + response.eventId, + snapshot.color!, + ); + } + if (snapshot.isVisible != null) { + preferenceService.saveVisibilityPreference( + response.eventId, + snapshot.isVisible!, + ); + } + if (snapshot.seriesId != null) { + preferenceService.saveSeriesId( + response.eventId, + snapshot.seriesId!, + ); + } + } + restoredOriginalIds.add(calendarEvent.id); + } catch (error) { + log( + 'Failed to restore deleted series event ${calendarEvent.id}: $error', + ); + } + } + + if (restoredOriginalIds.isNotEmpty) { + preferenceService.removeEventPreferences(restoredOriginalIds); + } } - void setEventColor(String key, Color color) { + void setEventColor(String key, Color color, {bool notifyListeners = true}) { getIt().saveColorPreference(key, color); final elements = events.value; elements?.forEach((element) { @@ -131,8 +257,15 @@ class CalendarViewModel { element.setColor(color); } }); + if (notifyListeners) { + notifyEventChanges(); + } + } + + void notifyEventChanges() { + final elements = events.value; events.add(elements); - updateHomeWidget(events.value ?? []); + updateHomeWidget(elements ?? []); } void toggleEventVisibility(String key) { @@ -225,3 +358,15 @@ class CalendarViewModel { await syncService.removeSyncedCalendar(); } } + +class _EventPreferenceSnapshot { + final Color? color; + final bool? isVisible; + final String? seriesId; + + const _EventPreferenceSnapshot({ + required this.color, + required this.isVisible, + required this.seriesId, + }); +} diff --git a/lib/calendarComponent/views/custom_event_view.dart b/lib/calendarComponent/views/custom_event_view.dart index 5cbfce81..64452389 100644 --- a/lib/calendarComponent/views/custom_event_view.dart +++ b/lib/calendarComponent/views/custom_event_view.dart @@ -1,4 +1,5 @@ import 'package:campus_flutter/base/extensions/context.dart'; +import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/base/util/color_picker_view.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; @@ -6,79 +7,164 @@ import 'package:campus_flutter/calendarComponent/views/visibility_button_view.da import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; class CustomEventView extends ConsumerWidget { - const CustomEventView({super.key, required this.calendarEvent}); + const CustomEventView({ + super.key, + required this.calendarEvent, + required this.canDeleteSeries, + required this.isSeries, + }); final CalendarEvent calendarEvent; + final bool canDeleteSeries; + final bool isSeries; @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _infoEntry( - context.tr("timeFrame"), - Text(calendarEvent.timePeriodText(context)), - context, - ), - if (calendarEvent.description != null) - _infoEntry( - context.tr("annotation"), - Text( - calendarEvent.description!, - maxLines: 10, - overflow: TextOverflow.ellipsis, + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), ), - context, ), - Row( - children: [ - Expanded( - child: _infoEntry( - context.tr("color"), - ColorPickerView( - color: calendarEvent.getColor(), - onColorChanged: (color) { - ref - .read(calendarViewModel) - .setEventColor( - calendarEvent.lvNr ?? calendarEvent.id, - color, - ); - }, - ), - context, - ), + const SizedBox(height: 12), + Padding( + padding: EdgeInsets.symmetric(horizontal: context.padding), + child: Text( + calendarEvent.title ?? "-", + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, ), - Expanded( - child: _infoEntry( - context.tr("visibility"), - VisibilityButtonView( - id: calendarEvent.lvNr ?? calendarEvent.id, - isVisible: calendarEvent.isVisible, - ), - context, - ), + ), + if (isSeries) ...[ + const SizedBox(height: 6), + Chip( + avatar: const Icon(Icons.repeat_rounded, size: 16), + label: Text(context.tr("series")), + visualDensity: VisualDensity.compact, ), ], - ), - ], - ); - } - - Widget _infoEntry(String title, Widget child, BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: context.padding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), - Padding( - padding: EdgeInsets.symmetric(vertical: context.halfPadding), - child: child, + const SizedBox(height: 8), + const Divider(), + ListTile( + leading: const Icon(Icons.access_time_rounded), + title: Text(calendarEvent.timePeriodText(context)), ), + if (calendarEvent.description != null) + ListTile( + leading: const Icon(Icons.notes_rounded), + title: Text( + calendarEvent.description!, + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.palette_rounded), + title: Text(context.tr("color")), + trailing: ColorPickerView( + color: calendarEvent.getColor(), + onColorChanged: (color) { + ref.read(calendarViewModel).setEventColor( + calendarEvent.lvNr ?? calendarEvent.id, + color, + ); + }, + ), + ), + ListTile( + leading: const Icon(Icons.visibility_rounded), + title: Text(context.tr("visibility")), + trailing: VisibilityButtonView( + id: calendarEvent.lvNr ?? calendarEvent.id, + isVisible: calendarEvent.isVisible, + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.edit_rounded), + title: Text(context.tr("edit")), + onTap: () { + context.pop(); + context.push(eventCreation, extra: calendarEvent); + }, + ), + const Divider(), + ListTile( + leading: Icon( + Icons.delete_outline_rounded, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + canDeleteSeries + ? context.tr("deleteThisEvent") + : context.tr("delete"), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () { + ref + .read(calendarViewModel) + .deleteCustomCalendarEvent(calendarEvent) + .then((_) { + if (context.mounted) context.pop(); + }) + .catchError((error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: + Theme.of(context).colorScheme.error, + ), + ); + } + }); + }, + ), + if (canDeleteSeries) + ListTile( + leading: Icon( + Icons.delete_sweep_rounded, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + context.tr("deleteThisAndFollowing"), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () { + ref + .read(calendarViewModel) + .deleteRecurringSeriesFrom(calendarEvent) + .then((_) { + if (context.mounted) context.pop(); + }) + .catchError((error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: + Theme.of(context).colorScheme.error, + ), + ); + } + }); + }, + ), + const SizedBox(height: 8), ], ), ); From 24a4e3fa0991c184a8917a7868c71ff75b50ef44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20M=C3=BChlberger?= Date: Fri, 17 Apr 2026 10:52:28 +0200 Subject: [PATCH 2/4] Revamp calendar event editor UI --- assets/translations/de.json | 17 + assets/translations/en.json | 17 + lib/base/util/color_picker_view.dart | 11 + .../views/calendar_event_view.dart | 60 +- .../event_creation_date_time_picker.dart | 149 ++-- .../views/event_creation_form_field.dart | 44 +- .../views/event_creation_view.dart | 658 ++++++++++++++++-- .../views/visibility_button_view.dart | 9 +- 8 files changed, 787 insertions(+), 178 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index 461f09b2..378bb0d5 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -170,10 +170,15 @@ "licenses": "Lizenzen", "privacyPolicy": "Datenschutzrichtlinie", "createCalendarEvent": "Kalenderereignis erstellen", + "createCalendarSeries": "Kalenderserie erstellen", + "editCalendarEvent": "Kalenderereignis bearbeiten", + "editCalendarSeries": "Kalenderserie bearbeiten", "title": "Titel", "annotation": "Bemerkung", "from": "Von", "to": "Bis", + "start": "Beginn", + "end": "Ende", "recurrence": "Wiederholen", "recurrenceDaily": "Tag", "recurrenceWeekly": "Woche", @@ -182,12 +187,24 @@ "repeatEvery": "Wiederholen alle", "for": "für", "until": "bis", + "endsOn": "Ende am", "times": "Mal", "timeFrame": "Zeitrahmen", "delete": "Löschen", "series": "Terminserie", "editingSeriesBanner": "Alle Wiederholungen dieser Terminserie werden aktualisiert", + "creatingSeriesBanner": "Dieser Termin wird in eine Terminserie umgewandelt", + "editEvent": "Termin bearbeiten", + "editSeries": "Terminserie bearbeiten", + "confirmDeleteEventTitle": "Termin löschen?", + "confirmDeleteEventMessage": "Dieser Termin wird dauerhaft gelöscht.", + "confirmDeleteSeriesTitle": "Terminserie löschen?", + "confirmDeleteSeriesMessage": "Dadurch werden dauerhaft alle {} Termine dieser Serie gelöscht.", + "customEvent": "Eigener Termin", + "details": "Details", + "actions": "Aktionen", "deleteThisEvent": "Dieses Ereignis löschen", + "deleteSeries": "Terminserie löschen", "deleteThisAndFollowing": "Dieses und folgende löschen", "deviceSettings": "Geräte-Einstellungen", "edit": "Editieren", diff --git a/assets/translations/en.json b/assets/translations/en.json index be68ab2e..25a8fea6 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -170,10 +170,15 @@ "licenses": "Licenses", "privacyPolicy": "Privacy Policy", "createCalendarEvent": "Create Calendar Event", + "createCalendarSeries": "Create Calendar Series", + "editCalendarEvent": "Edit Calendar Event", + "editCalendarSeries": "Edit Calendar Series", "title": "Title", "annotation": "Annotation", "from": "From", "to": "To", + "start": "Start", + "end": "End", "recurrence": "Repeat", "recurrenceDaily": "day", "recurrenceWeekly": "week", @@ -182,12 +187,24 @@ "repeatEvery": "Repeat every", "for": "for", "until": "until", + "endsOn": "ends on", "times": "times", "timeFrame": "Time Frame", "delete": "Delete", "series": "Series", "editingSeriesBanner": "All occurrences in this series will be updated", + "creatingSeriesBanner": "This event will be converted into a series", + "editEvent": "Edit Event", + "editSeries": "Edit Series", + "confirmDeleteEventTitle": "Delete event?", + "confirmDeleteEventMessage": "This event will be permanently deleted.", + "confirmDeleteSeriesTitle": "Delete series?", + "confirmDeleteSeriesMessage": "This will permanently delete all {} events in this series.", + "customEvent": "Custom Event", + "details": "Details", + "actions": "Actions", "deleteThisEvent": "Delete this event", + "deleteSeries": "Delete series", "deleteThisAndFollowing": "Delete this and following", "deviceSettings": "Device Settings", "edit": "Edit", diff --git a/lib/base/util/color_picker_view.dart b/lib/base/util/color_picker_view.dart index 8207f519..29105de6 100644 --- a/lib/base/util/color_picker_view.dart +++ b/lib/base/util/color_picker_view.dart @@ -25,6 +25,17 @@ class _ColorPickerViewState extends State { super.didChangeDependencies(); } + @override + void didUpdateWidget(covariant ColorPickerView oldWidget) { + super.didUpdateWidget(oldWidget); + + final nextColor = widget.color ?? context.primaryColor; + final previousColor = oldWidget.color ?? context.primaryColor; + if (nextColor != previousColor) { + selectedColor = nextColor; + } + } + @override Widget build(BuildContext context) { return InkWell( diff --git a/lib/calendarComponent/views/calendar_event_view.dart b/lib/calendarComponent/views/calendar_event_view.dart index e5c39cf9..13e5b18a 100644 --- a/lib/calendarComponent/views/calendar_event_view.dart +++ b/lib/calendarComponent/views/calendar_event_view.dart @@ -24,31 +24,35 @@ class CalendarEventView extends StatelessWidget { } Widget _hiddenCalendarEvent(BuildContext context) { - return SizedBox( - height: bounds.height, - width: bounds.width, - child: Stack( - children: [ - DiagonalStripePatternView( - stripeColor: calendarEvent.getColor(), - bgColor: calendarEvent.getColor().withValues( - alpha: Theme.of(context).brightness == Brightness.light - ? 0.625 - : 0.5, + return ClipRect( + child: SizedBox( + height: bounds.height, + width: bounds.width, + child: Stack( + children: [ + DiagonalStripePatternView( + stripeColor: calendarEvent.getColor(), + bgColor: calendarEvent.getColor().withValues( + alpha: Theme.of(context).brightness == Brightness.light + ? 0.625 + : 0.5, + ), ), - ), - _content(context), - ], + _content(context), + ], + ), ), ); } Widget _visibleCalendarEvent(BuildContext context) { - return Container( - height: bounds.height, - width: bounds.width, - decoration: BoxDecoration(color: calendarEvent.getColor()), - child: _content(context), + return ClipRect( + child: Container( + height: bounds.height, + width: bounds.width, + decoration: BoxDecoration(color: calendarEvent.getColor()), + child: _content(context), + ), ); } @@ -80,10 +84,13 @@ class CalendarEventView extends StatelessWidget { } Widget _text(TextStyle? style, double padding, BuildContext context) { + final lineLimit = _calculateLineLimit(style, padding); + return Text( calendarEvent.subject, style: style, - maxLines: _calculateLineLimit(style, padding, context), + maxLines: lineLimit == null || lineLimit < 1 ? 1 : lineLimit, + overflow: TextOverflow.ellipsis, ); } @@ -91,11 +98,7 @@ class CalendarEventView extends StatelessWidget { return Text(calendarEvent.timePeriod, style: style, maxLines: 1); } - int? _calculateLineLimit( - TextStyle? style, - double padding, - BuildContext context, - ) { + int? _calculateLineLimit(TextStyle? style, double padding) { var absoluteHeight = bounds.height - padding * 2; if (style == null) { @@ -112,6 +115,11 @@ class CalendarEventView extends StatelessWidget { absoluteHeight = absoluteHeight - lineHeight; } - return (absoluteHeight / lineHeight).floor(); + final lineLimit = (absoluteHeight / lineHeight).floor(); + if (lineLimit < 1) { + return 1; + } + + return lineLimit; } } diff --git a/lib/calendarComponent/views/event_creation_date_time_picker.dart b/lib/calendarComponent/views/event_creation_date_time_picker.dart index c220eade..a5a39e68 100644 --- a/lib/calendarComponent/views/event_creation_date_time_picker.dart +++ b/lib/calendarComponent/views/event_creation_date_time_picker.dart @@ -1,97 +1,120 @@ import 'package:campus_flutter/base/extensions/context.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -class EventCreationDateTimePicker extends StatelessWidget { - const EventCreationDateTimePicker({ +class EventCreationDateTimeRow extends StatelessWidget { + const EventCreationDateTimeRow({ super.key, required this.title, required this.currentDate, required this.onDateSet, required this.onTimeOfDaySet, + required this.showTime, + this.titleWidth = 44, }); final String title; final Stream currentDate; final Function(DateTime?) onDateSet; final Function(TimeOfDay?) onTimeOfDaySet; + final bool showTime; + final double titleWidth; + + bool _isToday(DateTime date) { + final now = DateTime.now(); + return date.year == now.year && + date.month == now.month && + date.day == now.day; + } + + bool _isTomorrow(DateTime date) { + final tomorrow = DateTime.now().add(const Duration(days: 1)); + return date.year == tomorrow.year && + date.month == tomorrow.month && + date.day == tomorrow.day; + } + + String _dateLabel(DateTime date, BuildContext context) { + if (_isToday(date)) return context.tr("today"); + if (_isTomorrow(date)) return context.tr("tomorrow"); + return DateFormat.MMMd(context.locale.languageCode).format(date); + } @override Widget build(BuildContext context) { return StreamBuilder( stream: currentDate, - builder: (context, snapshot) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(left: context.padding), - child: Text(title, style: Theme.of(context).textTheme.titleMedium), - ), - Padding( - padding: EdgeInsets.only(bottom: context.padding), - child: Card( - child: ListTile( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _pickerItem( - () async => onDateSet( - await showDatePicker( - context: context, - firstDate: DateTime.now(), - lastDate: (snapshot.data ?? DateTime.now()).add( - const Duration(days: 365), - ), - ), - ), - DateFormat.yMd( - "de", - ).format(snapshot.data ?? DateTime.now()), - context, - ), - _pickerItem( - () async => onTimeOfDaySet( - await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime( - snapshot.data ?? DateTime.now(), - ), - ), - ), - DateFormat.Hm( - "de", - ).format(snapshot.data ?? DateTime.now()), - context, - ), - ], + builder: (context, snapshot) { + final date = snapshot.data ?? DateTime.now(); + + return Row( + children: [ + SizedBox( + width: titleWidth, + child: Text(title, style: Theme.of(context).textTheme.bodyLarge), + ), + Expanded( + child: _chip( + label: _dateLabel(date, context), + onTap: () async => onDateSet( + await showDatePicker( + context: context, + initialDate: date, + firstDate: date.isBefore(DateTime.now()) + ? date + : DateTime.now(), + lastDate: date.add(const Duration(days: 365)), + ), ), + context: context, ), ), - ), - ], - ), + if (showTime) ...[ + const SizedBox(width: 8), + SizedBox( + width: 84, + child: _chip( + label: DateFormat.Hm( + context.locale.languageCode, + ).format(date), + onTap: () async => onTimeOfDaySet( + await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(date), + ), + ), + context: context, + ), + ), + ], + ], + ); + }, ); } - Widget _pickerItem( - Function() onTap, - String currentData, - BuildContext context, - ) { + Widget _chip({ + required String label, + required VoidCallback onTap, + required BuildContext context, + }) { return InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(8), child: Container( - width: 100, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: context.primaryColor, - borderRadius: BorderRadius.circular(5), + color: context.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), ), child: Center( - child: Padding( - padding: EdgeInsets.all(context.halfPadding), - child: Text( - currentData, - style: const TextStyle(color: Colors.white), + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.primaryColor, + fontWeight: FontWeight.w500, ), ), ), diff --git a/lib/calendarComponent/views/event_creation_form_field.dart b/lib/calendarComponent/views/event_creation_form_field.dart index 4d7189c1..d39b02db 100644 --- a/lib/calendarComponent/views/event_creation_form_field.dart +++ b/lib/calendarComponent/views/event_creation_form_field.dart @@ -22,36 +22,22 @@ class EventCreationFormField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(left: context.padding), - child: Text(title, style: Theme.of(context).textTheme.titleMedium), + return Padding( + padding: EdgeInsets.symmetric(horizontal: context.padding), + child: TextFormField( + controller: controller, + maxLength: maxLength, + minLines: 1, + maxLines: maxLines, + decoration: InputDecoration( + labelText: title, + border: InputBorder.none, + counterText: "", ), - Padding( - padding: EdgeInsets.only(bottom: context.padding), - child: Card( - child: Padding( - padding: EdgeInsets.symmetric( - vertical: context.padding, - horizontal: context.padding, - ), - child: TextFormField( - controller: controller, - maxLength: maxLength, - minLines: 1, - maxLines: 15, - onChanged: (value) => ref - .read(calendarAdditionViewModel(calendarEvent)) - .checkValidity(), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ), - ), - ), - ), - ], + onChanged: (value) => + ref.read(calendarAdditionViewModel(calendarEvent)).checkValidity(), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), ); } } diff --git a/lib/calendarComponent/views/event_creation_view.dart b/lib/calendarComponent/views/event_creation_view.dart index 36c95a6a..66a014c7 100644 --- a/lib/calendarComponent/views/event_creation_view.dart +++ b/lib/calendarComponent/views/event_creation_view.dart @@ -1,9 +1,14 @@ +import 'package:campus_flutter/base/extensions/context.dart'; import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/base/util/custom_back_button.dart'; +import 'package:campus_flutter/base/util/color_picker_view.dart'; +import 'package:campus_flutter/base/util/icon_text.dart'; +import 'package:campus_flutter/calendarComponent/model/calendar_editing.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_addition_viewmodel.dart'; import 'package:campus_flutter/calendarComponent/views/event_creation_date_time_picker.dart'; import 'package:campus_flutter/calendarComponent/views/event_creation_form_field.dart'; +import 'package:campus_flutter/homeComponent/view/widget/widget_frame_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,6 +21,8 @@ class EventCreationScaffold extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.watch(calendarAdditionViewModel(calendarEvent)); + return Scaffold( appBar: AppBar( leading: CustomBackButton( @@ -24,9 +31,45 @@ class EventCreationScaffold extends ConsumerWidget { context.pop(); }, ), - title: Text(context.tr("createCalendarEvent")), + title: StreamBuilder( + stream: vm.recurrenceType, + builder: (context, snapshot) { + final hasRecurrence = + (snapshot.data ?? RecurrenceType.none) != RecurrenceType.none; + final title = vm.isEditing + ? context.tr( + hasRecurrence ? "editCalendarSeries" : "editCalendarEvent", + ) + : context.tr( + hasRecurrence + ? "createCalendarSeries" + : "createCalendarEvent", + ); + + return Text(title); + }, + ), + actions: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: context.padding), + child: StreamBuilder( + stream: vm.selectedColor, + builder: (context, snapshot) { + return ColorPickerView( + color: snapshot.data ?? context.primaryColor, + onColorChanged: vm.setSelectedColor, + ); + }, + ), + ), + ], + ), + body: Column( + children: [ + Expanded(child: EventCreationView(calendarEvent: calendarEvent)), + _SubmitBar(calendarEvent: calendarEvent), + ], ), - body: EventCreationView(calendarEvent: calendarEvent), ); } } @@ -38,78 +81,575 @@ class EventCreationView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(calendarAdditionViewModel(calendarEvent)); + return SafeArea( child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: context.halfPadding), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - EventCreationFormField( - title: context.tr("title"), - controller: ref - .read(calendarAdditionViewModel(calendarEvent)) - .titleController, - maxLength: 255, - maxLines: 2, - calendarEvent: calendarEvent, - ), - EventCreationFormField( - title: context.tr("annotation"), - controller: ref - .read(calendarAdditionViewModel(calendarEvent)) - .annotationController, - maxLength: 4000, - maxLines: 200, - calendarEvent: calendarEvent, - ), - EventCreationDateTimePicker( - title: context.tr("from"), - currentDate: ref - .watch(calendarAdditionViewModel(calendarEvent)) - .from, - onDateSet: ref - .read(calendarAdditionViewModel(calendarEvent)) - .setFromDate, - onTimeOfDaySet: ref - .read(calendarAdditionViewModel(calendarEvent)) - .setFromTimeOfDay, + // Title + annotation in one card + Card( + child: Column( + children: [ + EventCreationFormField( + title: context.tr("title"), + controller: vm.titleController, + maxLength: 255, + maxLines: 1, + calendarEvent: calendarEvent, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + EventCreationFormField( + title: context.tr("annotation"), + controller: vm.annotationController, + maxLength: 4000, + maxLines: 3, + calendarEvent: calendarEvent, + ), + ], + ), ), - EventCreationDateTimePicker( - title: context.tr("to"), - currentDate: ref - .watch(calendarAdditionViewModel(calendarEvent)) - .to, - onDateSet: ref - .read(calendarAdditionViewModel(calendarEvent)) - .setToDate, - onTimeOfDaySet: ref - .read(calendarAdditionViewModel(calendarEvent)) - .setToTimeOfDay, - ), - _submitButton(ref), + // Date & time card + _DateTimeCard(calendarEvent: calendarEvent), + // Recurrence as collapsible section + _RecurrenceSection(calendarEvent: calendarEvent), ], ), ), ); } +} + +class _SubmitBar extends ConsumerWidget { + const _SubmitBar({required this.calendarEvent}); + + final CalendarEvent? calendarEvent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.watch(calendarAdditionViewModel(calendarEvent)); - Widget _submitButton(WidgetRef ref) { return StreamBuilder( - stream: ref.watch(calendarAdditionViewModel(calendarEvent)).isValid, + stream: vm.isSaving, + builder: (context, savingSnapshot) { + final saving = savingSnapshot.data ?? false; + + return StreamBuilder( + stream: vm.isValid, + builder: (context, snapshot) { + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (saving) + const LinearProgressIndicator() + else + const Divider(height: 1), + Padding( + padding: EdgeInsets.fromLTRB( + context.padding, + context.halfPadding, + context.padding, + context.padding, + ), + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + onPressed: (!saving && (snapshot.data ?? false)) + ? () => ref + .read(calendarAdditionViewModel(calendarEvent)) + .saveEvent() + .then((_) { + if (context.mounted) { + ref.invalidate(calendarAdditionViewModel); + context.canPop() + ? context.pop(true) + : context.go(calendar); + } + }) + .catchError((error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: Theme.of( + context, + ).colorScheme.error, + ), + ); + } + }) + : null, + icon: const Icon(Icons.check_rounded), + label: Text(context.tr("submit")), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} + +class _DateTimeCard extends ConsumerWidget { + const _DateTimeCard({required this.calendarEvent}); + + final CalendarEvent? calendarEvent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.watch(calendarAdditionViewModel(calendarEvent)); + + return StreamBuilder( + stream: vm.recurrenceType, + builder: (context, snapshot) { + final isRecurring = + (snapshot.data ?? RecurrenceType.none) != RecurrenceType.none; + final firstLabel = context.tr(isRecurring ? "start" : "from"); + final secondLabel = context.tr(isRecurring ? "end" : "to"); + final labelWidth = isRecurring ? 56.0 : 44.0; + + return WidgetFrameView( + titleWidget: SymbolText.icon( + iconData: Icons.access_time_rounded, + label: context.tr("timeFrame"), + style: Theme.of(context).textTheme.titleMedium, + ), + child: Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: context.padding, + vertical: context.padding, + ), + child: Column( + children: [ + EventCreationDateTimeRow( + title: firstLabel, + titleWidth: labelWidth, + currentDate: vm.from, + onDateSet: vm.setFromDate, + onTimeOfDaySet: vm.setFromTimeOfDay, + showTime: true, + ), + SizedBox(height: context.halfPadding * 2), + EventCreationDateTimeRow( + title: secondLabel, + titleWidth: labelWidth, + currentDate: vm.to, + onDateSet: vm.setToDate, + onTimeOfDaySet: vm.setToTimeOfDay, + showTime: true, + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _RecurrenceSection extends ConsumerWidget { + const _RecurrenceSection({required this.calendarEvent}); + + final CalendarEvent? calendarEvent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.watch(calendarAdditionViewModel(calendarEvent)); + + return StreamBuilder( + stream: vm.recurrenceType, builder: (context, snapshot) { - return ElevatedButton( - onPressed: (snapshot.data ?? false) - ? () => ref - .read(calendarAdditionViewModel(calendarEvent)) - .saveEvent() - .then((value) { - if (context.mounted) { - ref.invalidate(calendarAdditionViewModel); - context.canPop() ? context.pop() : context.go(calendar); - } - }) - : null, - child: Text(context.tr("submit")), + final type = snapshot.data ?? RecurrenceType.none; + final isActive = type != RecurrenceType.none; + + return WidgetFrameView( + titleWidget: SymbolText.icon( + iconData: Icons.repeat_rounded, + label: context.tr("recurrence"), + style: Theme.of(context).textTheme.titleMedium, + ), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + SwitchListTile( + title: Text(context.tr("recurrence")), + value: isActive, + onChanged: (on) { + vm.setRecurrenceType( + on ? RecurrenceType.weekly : RecurrenceType.none, + ); + }, + ), + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: EdgeInsets.fromLTRB( + context.padding, + 0, + context.padding, + context.padding, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1), + SizedBox(height: context.padding), + _RecurrenceSentence(calendarEvent: calendarEvent), + ], + ), + ), + crossFadeState: isActive + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], + ), + ), ); }, ); } } + +class _RecurrenceSentence extends ConsumerWidget { + const _RecurrenceSentence({required this.calendarEvent}); + + final CalendarEvent? calendarEvent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.watch(calendarAdditionViewModel(calendarEvent)); + + return StreamBuilder( + stream: vm.recurrenceType, + builder: (context, typeSnapshot) { + final type = typeSnapshot.data ?? RecurrenceType.weekly; + + String frequencyLabel(RecurrenceType value) => switch (value) { + RecurrenceType.daily => context.tr("recurrenceDaily"), + RecurrenceType.weekly => context.tr("recurrenceWeekly"), + RecurrenceType.biweekly => context.tr("recurrenceBiweekly"), + RecurrenceType.monthly => context.tr("recurrenceMonthly"), + _ => "", + }; + + return StreamBuilder( + stream: vm.recurrenceEndType, + builder: (context, endTypeSnapshot) { + final endType = endTypeSnapshot.data ?? RecurrenceEndType.count; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: "Repeat every [week ▾]" + Row( + children: [ + Text( + context.tr("repeatEvery"), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(width: 8), + Expanded( + child: _selectorChip( + context: context, + label: frequencyLabel(type), + onTap: () { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _BottomSheetHandle(), + for (final option in [ + RecurrenceType.daily, + RecurrenceType.weekly, + RecurrenceType.biweekly, + RecurrenceType.monthly, + ]) + ListTile( + title: Text(frequencyLabel(option)), + trailing: option == type + ? Icon( + Icons.check_rounded, + color: context.primaryColor, + ) + : null, + onTap: () { + vm.setRecurrenceType(option); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + const SizedBox(height: 10), + // Row 2: "[for ▾] [4] times" or "[until ▾] [Apr 30]" + Row( + children: [ + _selectorChip( + context: context, + label: endType == RecurrenceEndType.count + ? context.tr("for") + : context.tr("endsOn"), + compact: true, + onTap: () { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _BottomSheetHandle(), + ListTile( + title: Text(context.tr("for")), + trailing: endType == RecurrenceEndType.count + ? Icon( + Icons.check_rounded, + color: context.primaryColor, + ) + : null, + onTap: () { + vm.setRecurrenceEndType( + RecurrenceEndType.count, + ); + Navigator.pop(context); + }, + ), + ListTile( + title: Text(context.tr("endsOn")), + trailing: + endType == RecurrenceEndType.untilDate + ? Icon( + Icons.check_rounded, + color: context.primaryColor, + ) + : null, + onTap: () { + vm.setRecurrenceEndType( + RecurrenceEndType.untilDate, + ); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ), + const SizedBox(width: 8), + if (endType == RecurrenceEndType.count) + StreamBuilder( + stream: vm.occurrenceCount, + builder: (context, countSnapshot) { + final count = countSnapshot.data ?? 2; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _countChip( + context: context, + count: count, + onDecrement: count > 2 + ? () => vm.setOccurrenceCount(count - 1) + : null, + onIncrement: + count < + CalendarAdditionViewModel.maxOccurrences + ? () => vm.setOccurrenceCount(count + 1) + : null, + ), + const SizedBox(width: 8), + Text( + context.tr("times"), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ); + }, + ), + if (endType == RecurrenceEndType.untilDate) + Expanded( + child: StreamBuilder( + stream: vm.untilDate, + builder: (context, dateSnapshot) { + final date = + dateSnapshot.data ?? + DateTime.now().add(const Duration(days: 30)); + return _selectorChip( + context: context, + label: DateFormat.yMMMd( + context.locale.languageCode, + ).format(date), + onTap: () async { + final eventStartDate = DateUtils.dateOnly( + vm.from.value, + ); + final initialPickerDate = DateUtils.dateOnly( + date.isBefore(eventStartDate) + ? eventStartDate + : date, + ); + final defaultLastDate = DateUtils.dateOnly( + DateTime.now().add(const Duration(days: 730)), + ); + final lastPickerDate = + initialPickerDate.isAfter(defaultLastDate) + ? initialPickerDate + : defaultLastDate; + final picked = await showDatePicker( + context: context, + initialDate: initialPickerDate, + firstDate: eventStartDate, + lastDate: lastPickerDate, + ); + vm.setUntilDate(picked); + }, + ); + }, + ), + ), + ], + ), + ], + ); + }, + ); + }, + ); + } + + Widget _selectorChip({ + required BuildContext context, + required String label, + required VoidCallback onTap, + bool compact = false, + }) { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 8 : 10, + ), + decoration: BoxDecoration( + color: context.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.primaryColor, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down_rounded, color: context.primaryColor), + ], + ), + ), + ); + } + + Widget _countChip({ + required BuildContext context, + required int count, + required VoidCallback? onDecrement, + required VoidCallback? onIncrement, + }) { + return Container( + constraints: const BoxConstraints(minHeight: 40), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + decoration: BoxDecoration( + color: context.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + iconSize: 16, + onPressed: onDecrement, + icon: const Icon(Icons.remove_rounded), + ), + ), + SizedBox( + width: 24, + child: Center( + child: Text( + "$count", + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: context.primaryColor), + ), + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + iconSize: 16, + onPressed: onIncrement, + icon: const Icon(Icons.add_rounded), + ), + ), + ], + ), + ); + } +} + +class _BottomSheetHandle extends StatelessWidget { + const _BottomSheetHandle(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ); + } +} diff --git a/lib/calendarComponent/views/visibility_button_view.dart b/lib/calendarComponent/views/visibility_button_view.dart index eb406e3e..12316260 100644 --- a/lib/calendarComponent/views/visibility_button_view.dart +++ b/lib/calendarComponent/views/visibility_button_view.dart @@ -29,7 +29,14 @@ class _VisibilityButtonViewState extends ConsumerState { isVisible = !isVisible; }); }, - child: Icon((isVisible) ? Icons.visibility : Icons.visibility_off), + child: SizedBox( + height: 30, + width: 30, + child: Icon( + isVisible ? Icons.visibility : Icons.visibility_off, + size: 22, + ), + ), ); } } From 5dbe5d1e2b20790382deee0357f8b3051d848007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20M=C3=BChlberger?= Date: Sat, 18 Apr 2026 02:29:31 +0200 Subject: [PATCH 3/4] Add custom event details screen and routing --- lib/base/routing/router.dart | 11 +- .../model/calendar_event.dart | 58 ++- .../services/calendar_view_service.dart | 25 +- .../views/custom_event_view.dart | 414 ++++++++++++------ 4 files changed, 336 insertions(+), 172 deletions(-) diff --git a/lib/base/routing/router.dart b/lib/base/routing/router.dart index fa07c7a2..af6c6464 100644 --- a/lib/base/routing/router.dart +++ b/lib/base/routing/router.dart @@ -5,6 +5,7 @@ import 'package:campus_flutter/base/routing/router_service.dart'; import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; import 'package:campus_flutter/calendarComponent/views/calendars_view.dart'; +import 'package:campus_flutter/calendarComponent/views/custom_event_view.dart'; import 'package:campus_flutter/calendarComponent/views/event_creation_view.dart'; import 'package:campus_flutter/campusComponent/screen/campus_screen.dart'; import 'package:campus_flutter/campusComponent/screen/movie_screen.dart'; @@ -145,10 +146,12 @@ final _router = GoRouter( ), GoRoute( path: calendarDetails, - builder: (context, state) => LectureDetailsScaffold( - event: state.extra as CalendarEvent, - scrollController: null, - ), + builder: (context, state) { + final event = state.extra as CalendarEvent; + return event.hasLectureDetailsLink + ? LectureDetailsScaffold(event: event, scrollController: null) + : CustomEventScaffold(calendarEvent: event); + }, ), GoRoute( path: studentClubs, diff --git a/lib/calendarComponent/model/calendar_event.dart b/lib/calendarComponent/model/calendar_event.dart index 880a9729..493d4823 100644 --- a/lib/calendarComponent/model/calendar_event.dart +++ b/lib/calendarComponent/model/calendar_event.dart @@ -32,28 +32,66 @@ class CalendarEvent extends Searchable { } String? get lvNr { - return url?.split("LvNr=").last; + final value = url; + if (value == null || value.isEmpty) { + return null; + } + + final uri = Uri.tryParse(value); + if (uri != null) { + return uri.queryParameters['cLvNr'] ?? + uri.queryParameters['pLvNr'] ?? + uri.queryParameters['pLVNr'] ?? + uri.queryParameters['LvNr'] ?? + value; + } + + final match = RegExp( + r'(?:^|[?&])(?:cLvNr|pLvNr|pLVNr|LvNr)=([^&]+)', + ).firstMatch(value); + return match?.group(1) ?? value; + } + + bool get hasLectureDetailsLink { + final value = url; + if (value == null || value.isEmpty) { + return false; + } + + final uri = Uri.tryParse(value); + if (uri == null || !uri.path.contains('lv.detail')) { + return false; + } + + return uri.queryParameters['cLvNr']?.isNotEmpty == true || + uri.queryParameters['pLvNr']?.isNotEmpty == true || + uri.queryParameters['pLVNr']?.isNotEmpty == true || + uri.queryParameters['LvNr']?.isNotEmpty == true; } String get timePeriod { return "${DateFormat.Hm().format(startDate)} - ${DateFormat.Hm().format(endDate)}"; } - String _dateTimePeriod(BuildContext context) { - final start = DateFormat( + String _localizedDateTime(BuildContext context, DateTime value) { + return DateFormat( "EE, dd.MM.yyyy, HH:mm", context.locale.languageCode, - ).format(startDate); + ).format(value); + } + + String _dateTimePeriod(BuildContext context) { + final start = _localizedDateTime(context, startDate); final end = DateFormat("HH:mm").format(endDate); return "$start - $end"; } String timePeriodText(BuildContext context) { - if (startDate.day == endDate.day) { + if (_isSameCalendarDay(startDate, endDate)) { return _dateTimePeriod(context); } else { - final start = DateFormat(null, "de").format(startDate); - final end = DateFormat(null, "de").format(endDate); + final start = _localizedDateTime(context, startDate); + final end = _localizedDateTime(context, endDate); return "$start ${context.tr("to").toLowerCase()}\n$end"; } } @@ -62,6 +100,12 @@ class CalendarEvent extends Searchable { return status == "CANCEL"; } + bool _isSameCalendarDay(DateTime left, DateTime right) { + return left.year == right.year && + left.month == right.month && + left.day == right.day; + } + String get subject { final location = locations.firstOrNull ?? ""; return "$title\n$location"; diff --git a/lib/calendarComponent/services/calendar_view_service.dart b/lib/calendarComponent/services/calendar_view_service.dart index 6e460d87..e83c578f 100644 --- a/lib/calendarComponent/services/calendar_view_service.dart +++ b/lib/calendarComponent/services/calendar_view_service.dart @@ -1,9 +1,6 @@ import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; -import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; -import 'package:campus_flutter/calendarComponent/views/custom_event_view.dart'; -import 'package:campus_flutter/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -59,28 +56,8 @@ class CalendarViewService { calendarEvent = event; } - if (calendarEvent != null && calendarEvent.url != null) { + if (calendarEvent != null) { context.push(calendarDetails, extra: calendarEvent); - } else if (calendarEvent != null) { - final canDeleteSeries = ref - .read(calendarViewModel) - .canDeleteRecurringSeriesFrom(calendarEvent); - final isSeries = - getIt().getSeriesId(calendarEvent.id) != - null; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) => CustomEventView( - calendarEvent: calendarEvent!, - canDeleteSeries: canDeleteSeries, - isSeries: isSeries, - ), - ); } } } diff --git a/lib/calendarComponent/views/custom_event_view.dart b/lib/calendarComponent/views/custom_event_view.dart index 64452389..56aac744 100644 --- a/lib/calendarComponent/views/custom_event_view.dart +++ b/lib/calendarComponent/views/custom_event_view.dart @@ -1,171 +1,311 @@ import 'package:campus_flutter/base/extensions/context.dart'; import 'package:campus_flutter/base/routing/routes.dart'; import 'package:campus_flutter/base/util/color_picker_view.dart'; +import 'package:campus_flutter/base/util/custom_back_button.dart'; +import 'package:campus_flutter/base/util/last_updated_text.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; +import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; import 'package:campus_flutter/calendarComponent/views/visibility_button_view.dart'; +import 'package:campus_flutter/main.dart'; +import 'package:campus_flutter/studiesComponent/view/lectureDetail/basic_lecture_info_row_view.dart'; +import 'package:campus_flutter/studiesComponent/view/lectureDetail/lecture_info_card_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -class CustomEventView extends ConsumerWidget { - const CustomEventView({ - super.key, - required this.calendarEvent, - required this.canDeleteSeries, - required this.isSeries, - }); +class CustomEventScaffold extends ConsumerWidget { + const CustomEventScaffold({super.key, required this.calendarEvent}); final CalendarEvent calendarEvent; - final bool canDeleteSeries; - final bool isSeries; @override Widget build(BuildContext context, WidgetRef ref) { - return SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(2), - ), - ), + return Scaffold( + appBar: AppBar( + leading: const CustomBackButton(), + actions: [ + VisibilityButtonView( + id: calendarEvent.lvNr ?? calendarEvent.id, + isVisible: calendarEvent.isVisible, ), - const SizedBox(height: 12), Padding( padding: EdgeInsets.symmetric(horizontal: context.padding), - child: Text( - calendarEvent.title ?? "-", - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, - ), - ), - if (isSeries) ...[ - const SizedBox(height: 6), - Chip( - avatar: const Icon(Icons.repeat_rounded, size: 16), - label: Text(context.tr("series")), - visualDensity: VisualDensity.compact, - ), - ], - const SizedBox(height: 8), - const Divider(), - ListTile( - leading: const Icon(Icons.access_time_rounded), - title: Text(calendarEvent.timePeriodText(context)), - ), - if (calendarEvent.description != null) - ListTile( - leading: const Icon(Icons.notes_rounded), - title: Text( - calendarEvent.description!, - maxLines: 5, - overflow: TextOverflow.ellipsis, - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.palette_rounded), - title: Text(context.tr("color")), - trailing: ColorPickerView( + child: ColorPickerView( color: calendarEvent.getColor(), onColorChanged: (color) { - ref.read(calendarViewModel).setEventColor( - calendarEvent.lvNr ?? calendarEvent.id, - color, - ); + ref + .read(calendarViewModel) + .setEventColor( + calendarEvent.lvNr ?? calendarEvent.id, + color, + ); }, ), ), - ListTile( - leading: const Icon(Icons.visibility_rounded), - title: Text(context.tr("visibility")), - trailing: VisibilityButtonView( - id: calendarEvent.lvNr ?? calendarEvent.id, - isVisible: calendarEvent.isVisible, + ], + ), + body: CustomEventDetailsView(calendarEvent: calendarEvent), + ); + } +} + +class CustomEventDetailsView extends ConsumerWidget { + const CustomEventDetailsView({super.key, required this.calendarEvent}); + + final CalendarEvent calendarEvent; + + bool get isSeries => + getIt().getSeriesId(calendarEvent.id) != null; + + Future _confirmDelete( + BuildContext context, { + required String title, + required String message, + }) async { + final shouldDelete = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title, textAlign: TextAlign.center), + content: Text(message), + actionsAlignment: MainAxisAlignment.center, + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(context.tr("cancel")), + ), + ElevatedButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(Colors.redAccent), ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(context.tr("delete")), ), - const Divider(), - ListTile( - leading: const Icon(Icons.edit_rounded), - title: Text(context.tr("edit")), - onTap: () { - context.pop(); - context.push(eventCreation, extra: calendarEvent); - }, + ], + ), + ); + + return shouldDelete ?? false; + } + + Future _deleteEvent(BuildContext context, WidgetRef ref) async { + final confirmed = await _confirmDelete( + context, + title: context.tr("confirmDeleteEventTitle"), + message: context.tr("confirmDeleteEventMessage"), + ); + if (!confirmed) return; + + await ref.read(calendarViewModel).deleteCustomCalendarEvent(calendarEvent); + if (context.mounted) context.pop(); + } + + Future _deleteSeries(BuildContext context, WidgetRef ref) async { + final seriesCount = ref + .read(calendarViewModel) + .getSeriesEventCount(calendarEvent); + final confirmed = await _confirmDelete( + context, + title: context.tr("confirmDeleteSeriesTitle"), + message: context.tr( + "confirmDeleteSeriesMessage", + args: [seriesCount.toString()], + ), + ); + if (!confirmed) return; + + await ref.read(calendarViewModel).deleteRecurringSeries(calendarEvent); + if (context.mounted) context.pop(); + } + + CalendarEvent _latestEvent(WidgetRef ref) { + return ref + .read(calendarViewModel) + .events + .value + ?.firstWhere( + (event) => event.id == calendarEvent.id, + orElse: () => calendarEvent, + ) ?? + calendarEvent; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final lastFetched = ref.read(calendarViewModel).lastFetched.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + calendarEvent.title ?? "-", + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.start, + ), + ), + if (isSeries) ...[ + const SizedBox(width: 12), + const _SeriesIndicator(), + ], + ], + ), + if (!isSeries) + Text(context.tr("customEvent"), textAlign: TextAlign.start), + ], ), - const Divider(), - ListTile( - leading: Icon( - Icons.delete_outline_rounded, - color: Theme.of(context).colorScheme.error, - ), - title: Text( - canDeleteSeries - ? context.tr("deleteThisEvent") - : context.tr("delete"), - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - onTap: () { - ref - .read(calendarViewModel) - .deleteCustomCalendarEvent(calendarEvent) - .then((_) { - if (context.mounted) context.pop(); - }) - .catchError((error) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error.toString()), - backgroundColor: - Theme.of(context).colorScheme.error, + ), + const Padding(padding: EdgeInsets.symmetric(vertical: 3.0)), + if (lastFetched != null) LastUpdatedText(lastFetched), + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + children: [ + LectureInfoCardView( + icon: Icons.info_outline_rounded, + title: context.tr("details"), + widgets: [ + BasicLectureInfoRowView( + information: calendarEvent.timePeriodText(context), + iconData: Icons.access_time_rounded, ), - ); - } - }); - }, - ), - if (canDeleteSeries) - ListTile( - leading: Icon( - Icons.delete_sweep_rounded, - color: Theme.of(context).colorScheme.error, + if (calendarEvent.description != null) + ListTile( + dense: true, + leading: const Icon(Icons.notes_rounded, size: 20), + title: Text(calendarEvent.description!), + ), + ], + ), + LectureInfoCardView( + icon: Icons.tune_rounded, + title: context.tr("actions"), + widgets: [ + ListTile( + dense: true, + leading: const Icon(Icons.edit_rounded, size: 20), + title: Text( + context.tr(isSeries ? "editSeries" : "editEvent"), + ), + onTap: () async { + final shouldClose = await context.push( + eventCreation, + extra: _latestEvent(ref), + ); + if (shouldClose == true && context.mounted) { + context.pop(); + } + }, + ), + ListTile( + dense: true, + leading: Icon( + Icons.delete_outline_rounded, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + isSeries + ? context.tr("deleteThisEvent") + : context.tr("delete"), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + onTap: () => + _deleteEvent(context, ref).catchError((error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: Theme.of( + context, + ).colorScheme.error, + ), + ); + } + }), + ), + if (isSeries) + ListTile( + dense: true, + leading: Icon( + Icons.delete_sweep_rounded, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + context.tr("deleteSeries"), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + onTap: () => + _deleteSeries(context, ref).catchError((error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: Theme.of( + context, + ).colorScheme.error, + ), + ); + } + }), + ), + ], + ), + ], + ), ), - title: Text( - context.tr("deleteThisAndFollowing"), - style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ], + ); + } +} + +class _SeriesIndicator extends StatelessWidget { + const _SeriesIndicator(); + + @override + Widget build(BuildContext context) { + final color = context.primaryColor; + + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat_rounded, size: 16, color: color), + const SizedBox(width: 6), + Text( + context.tr("series"), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: color, + fontWeight: FontWeight.w600, ), - onTap: () { - ref - .read(calendarViewModel) - .deleteRecurringSeriesFrom(calendarEvent) - .then((_) { - if (context.mounted) context.pop(); - }) - .catchError((error) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error.toString()), - backgroundColor: - Theme.of(context).colorScheme.error, - ), - ); - } - }); - }, ), - const SizedBox(height: 8), - ], + ], + ), ), ); } From 81a6a251c26a8b51d9e9a0d9a69790a2eead2121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20M=C3=BChlberger?= Date: Sat, 18 Apr 2026 10:36:36 +0200 Subject: [PATCH 4/4] Persist inferred recurring series --- .../calendar_addition_viewmodel.dart | 27 +-- .../viewModels/calendar_viewmodel.dart | 208 +++++++++++++++++- .../views/custom_event_view.dart | 6 +- 3 files changed, 209 insertions(+), 32 deletions(-) diff --git a/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart b/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart index 5ba2af4e..4b366fc4 100644 --- a/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart +++ b/lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart @@ -46,10 +46,11 @@ class CalendarAdditionViewModel { String? id; String? seriesId; + CalendarSeriesResolution? resolvedSeries; final Ref ref; bool _isSaving = false; bool get isEditing => id != null; - bool get isEditingSeries => isEditing && seriesId != null; + bool get isEditingSeries => isEditing && (resolvedSeries?.isSeries ?? false); bool get hasRecurrenceEnabled => recurrenceType.value != RecurrenceType.none; bool get shouldShowSeriesBanner => isEditing && hasRecurrenceEnabled; bool get hasValidTimeFrame => to.value.isAfter(from.value); @@ -68,7 +69,8 @@ class CalendarAdditionViewModel { CalendarAdditionViewModel.edit(this.ref, CalendarEvent calendarEvent) { id = calendarEvent.id; - seriesId = getIt().getSeriesId(calendarEvent.id); + resolvedSeries = ref.read(calendarViewModel).resolveSeries(calendarEvent); + seriesId = resolvedSeries?.seriesId; final initialEvent = _resolveInitialEditingEvent(calendarEvent); titleController.text = initialEvent.title ?? ""; @@ -90,17 +92,11 @@ class CalendarAdditionViewModel { } CalendarEvent _resolveInitialEditingEvent(CalendarEvent fallbackEvent) { - if (seriesId == null) return fallbackEvent; - - return ref - .read(calendarViewModel) - .getSeriesSiblings(seriesId!) - .firstOrNull ?? - fallbackEvent; + return resolvedSeries?.siblings.firstOrNull ?? fallbackEvent; } void _seedSeriesRecurrence() { - final siblings = ref.read(calendarViewModel).getSeriesSiblings(seriesId!); + final siblings = resolvedSeries?.siblings ?? const []; if (siblings.length < 2) { recurrenceType.add(RecurrenceType.none); occurrenceCount.add(2); @@ -405,10 +401,11 @@ class CalendarAdditionViewModel { if (isEditingSeries) { // ── Series edit: update all siblings ────────────────────────────── - final siblings = ref - .read(calendarViewModel) - .getSeriesSiblings(seriesId!); + final siblings = resolvedSeries?.siblings ?? const []; final occurrences = _generateOccurrences(); + final activeSeriesId = occurrences.length > 1 + ? (seriesId ?? _uuid.v4()) + : null; final snapshots = { for (final sibling in siblings) sibling.id: _capturePreferenceSnapshot( @@ -448,8 +445,8 @@ class CalendarAdditionViewModel { ), ); createdEventIds.add(response.eventId); - if (occurrences.length > 1) { - preferenceService.saveSeriesId(response.eventId, seriesId!); + if (activeSeriesId != null) { + preferenceService.saveSeriesId(response.eventId, activeSeriesId); } } } catch (_) { diff --git a/lib/calendarComponent/viewModels/calendar_viewmodel.dart b/lib/calendarComponent/viewModels/calendar_viewmodel.dart index 8a82ae4d..53177790 100644 --- a/lib/calendarComponent/viewModels/calendar_viewmodel.dart +++ b/lib/calendarComponent/viewModels/calendar_viewmodel.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:home_widget/home_widget.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; final calendarViewModel = Provider((ref) => CalendarViewModel()); @@ -127,13 +128,38 @@ class CalendarViewModel { ..sort((a, b) => a.startDate.compareTo(b.startDate)); } - int getSeriesEventCount(CalendarEvent event) { - final seriesId = getIt().getSeriesId(event.id); - if (seriesId == null) return 1; + CalendarSeriesResolution resolveSeries(CalendarEvent event) { + final preferenceService = getIt(); + final explicitSeriesId = preferenceService.getSeriesId(event.id); + if (explicitSeriesId != null) { + return CalendarSeriesResolution( + seriesId: explicitSeriesId, + siblings: getSeriesSiblings(explicitSeriesId), + ); + } - return getIt() - .getSeriesEventIds(seriesId) - .length; + final inferredSiblings = _inferSeriesSiblings(event); + if (inferredSiblings.length > 1) { + // Persist so inference only runs once per series. + final newSeriesId = const Uuid().v4(); + for (final sibling in inferredSiblings) { + preferenceService.saveSeriesId(sibling.id, newSeriesId); + } + return CalendarSeriesResolution( + seriesId: newSeriesId, + siblings: inferredSiblings, + ); + } + + return CalendarSeriesResolution(siblings: inferredSiblings); + } + + bool isSeriesEvent(CalendarEvent event) { + return resolveSeries(event).count > 1; + } + + int getSeriesEventCount(CalendarEvent event) { + return resolveSeries(event).count; } Future deleteCustomCalendarEvent(CalendarEvent event) async { @@ -144,16 +170,14 @@ class CalendarViewModel { Future deleteRecurringSeries(CalendarEvent event) async { final preferenceService = getIt(); - final seriesId = preferenceService.getSeriesId(event.id); - if (seriesId == null) { + final resolution = resolveSeries(event); + if (!resolution.isSeries) { await deleteCustomCalendarEvent(event); return; } - final eventIdsToDelete = preferenceService.getSeriesEventIds(seriesId); - final originalEvents = (events.value ?? []) - .where((calendarEvent) => eventIdsToDelete.contains(calendarEvent.id)) - .toList(); + final eventIdsToDelete = resolution.siblings.map((sibling) => sibling.id); + final originalEvents = resolution.siblings; final snapshots = { for (final calendarEvent in originalEvents) calendarEvent.id: _capturePreferenceSnapshot( @@ -186,6 +210,155 @@ class CalendarViewModel { await fetch(true); } + List _inferSeriesSiblings(CalendarEvent event) { + if (!_isEligibleForSeriesInference(event)) { + return [event]; + } + + final candidates = + (events.value ?? []) + .where((candidate) => _matchesSeriesSignature(event, candidate)) + .toList() + ..sort((left, right) => left.startDate.compareTo(right.startDate)); + + if (candidates.length < 2) { + return [event]; + } + + final targetIndex = candidates.indexWhere( + (candidate) => candidate.id == event.id, + ); + if (targetIndex == -1) { + return [event]; + } + + List bestRun = [event]; + for (final recurrenceType in const [ + RecurrenceType.daily, + RecurrenceType.weekly, + RecurrenceType.biweekly, + RecurrenceType.monthly, + ]) { + for (int startIndex = 0; startIndex <= targetIndex; startIndex++) { + final run = _buildRecurringRun(candidates, startIndex, recurrenceType); + final containsTarget = run.any((candidate) => candidate.id == event.id); + if (!containsTarget || run.length < 2) { + continue; + } + if (run.length > bestRun.length) { + bestRun = run; + } + } + } + + return bestRun; + } + + static const int _maxConsecutiveGaps = 2; + + List _buildRecurringRun( + List candidates, + int startIndex, + RecurrenceType recurrenceType, + ) { + final anchor = candidates[startIndex]; + final run = [anchor]; + int slotOffset = 1; + int consecutiveGaps = 0; + + for (int index = startIndex + 1; index < candidates.length;) { + final expectedDate = _expectedDate( + anchor.startDate, + slotOffset, + recurrenceType, + ); + final candidateDate = DateUtils.dateOnly(candidates[index].startDate); + final comparison = candidateDate.compareTo(expectedDate); + + if (comparison == 0) { + run.add(candidates[index]); + slotOffset++; + consecutiveGaps = 0; + index++; + continue; + } + + if (comparison > 0) { + // The candidate is past the expected slot — the slot is empty. + consecutiveGaps++; + if (consecutiveGaps > _maxConsecutiveGaps) { + break; + } + slotOffset++; + // Don't advance index — re-check this candidate against the next slot. + continue; + } + + // comparison < 0: candidate is before expected date, skip it. + index++; + } + + return run; + } + + bool _isEligibleForSeriesInference(CalendarEvent event) { + return !event.hasLectureDetailsLink && + (event.url == null || event.url!.trim().isEmpty); + } + + bool _matchesSeriesSignature(CalendarEvent anchor, CalendarEvent candidate) { + return _isEligibleForSeriesInference(candidate) && + anchor.title?.trim() == candidate.title?.trim() && + (anchor.description?.trim().isEmpty ?? true) == + (candidate.description?.trim().isEmpty ?? true) && + (anchor.description?.trim() ?? '') == + (candidate.description?.trim() ?? '') && + anchor.duration == candidate.duration && + anchor.startDate.hour == candidate.startDate.hour && + anchor.startDate.minute == candidate.startDate.minute && + anchor.endDate.hour == candidate.endDate.hour && + anchor.endDate.minute == candidate.endDate.minute && + _sameLocations(anchor.locations, candidate.locations); + } + + bool _sameLocations(List left, List right) { + if (left.length != right.length) { + return false; + } + + for (int index = 0; index < left.length; index++) { + if (left[index].trim() != right[index].trim()) { + return false; + } + } + + return true; + } + + DateTime _expectedDate( + DateTime anchor, + int offset, + RecurrenceType recurrenceType, + ) { + final dateOnly = DateUtils.dateOnly(anchor); + + return switch (recurrenceType) { + RecurrenceType.daily => dateOnly.add(Duration(days: offset)), + RecurrenceType.weekly => dateOnly.add(Duration(days: 7 * offset)), + RecurrenceType.biweekly => dateOnly.add(Duration(days: 14 * offset)), + RecurrenceType.monthly => _addMonths(dateOnly, offset), + RecurrenceType.none => dateOnly, + }; + } + + DateTime _addMonths(DateTime date, int months) { + final targetMonth = date.month + months; + final year = date.year + (targetMonth - 1) ~/ 12; + final month = (targetMonth - 1) % 12 + 1; + final lastDay = DateUtils.getDaysInMonth(year, month); + return DateTime(year, month, date.day.clamp(1, lastDay)); + } + _EventPreferenceSnapshot _capturePreferenceSnapshot( CalendarPreferenceService preferenceService, String eventId, @@ -370,3 +543,14 @@ class _EventPreferenceSnapshot { required this.seriesId, }); } + +class CalendarSeriesResolution { + final String? seriesId; + final List siblings; + + const CalendarSeriesResolution({this.seriesId, required this.siblings}); + + bool get isSeries => siblings.length > 1; + bool get isExplicit => seriesId != null; + int get count => siblings.length; +} diff --git a/lib/calendarComponent/views/custom_event_view.dart b/lib/calendarComponent/views/custom_event_view.dart index 56aac744..f3ca1306 100644 --- a/lib/calendarComponent/views/custom_event_view.dart +++ b/lib/calendarComponent/views/custom_event_view.dart @@ -4,10 +4,8 @@ import 'package:campus_flutter/base/util/color_picker_view.dart'; import 'package:campus_flutter/base/util/custom_back_button.dart'; import 'package:campus_flutter/base/util/last_updated_text.dart'; import 'package:campus_flutter/calendarComponent/model/calendar_event.dart'; -import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart'; import 'package:campus_flutter/calendarComponent/viewModels/calendar_viewmodel.dart'; import 'package:campus_flutter/calendarComponent/views/visibility_button_view.dart'; -import 'package:campus_flutter/main.dart'; import 'package:campus_flutter/studiesComponent/view/lectureDetail/basic_lecture_info_row_view.dart'; import 'package:campus_flutter/studiesComponent/view/lectureDetail/lecture_info_card_view.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -56,9 +54,6 @@ class CustomEventDetailsView extends ConsumerWidget { final CalendarEvent calendarEvent; - bool get isSeries => - getIt().getSeriesId(calendarEvent.id) != null; - Future _confirmDelete( BuildContext context, { required String title, @@ -134,6 +129,7 @@ class CustomEventDetailsView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final lastFetched = ref.read(calendarViewModel).lastFetched.value; + final isSeries = ref.read(calendarViewModel).isSeriesEvent(calendarEvent); return Column( crossAxisAlignment: CrossAxisAlignment.start,