Skip to content

Recurring Calendar Series#337

Open
ManuelMuehlberger wants to merge 5 commits into
TUM-Dev:mainfrom
ManuelMuehlberger:feature/recurring-calendar-series
Open

Recurring Calendar Series#337
ManuelMuehlberger wants to merge 5 commits into
TUM-Dev:mainfrom
ManuelMuehlberger:feature/recurring-calendar-series

Conversation

@ManuelMuehlberger
Copy link
Copy Markdown
Contributor

@ManuelMuehlberger ManuelMuehlberger commented Apr 18, 2026

This PR adds support for creating recurring calendar events. While the original goal was limited to just recurrence support, some of the surrounding calendar UI had to be adapted so series creation, editing, and deletion remain usable with the new behavior.

Changes

  • add support for recurring custom calendar events
  • support daily, weekly, biweekly, and monthly repetitions
  • keep track of series membership locally so repeated events can be handled as a group
  • update the event creation flow to support recurrence
  • update the custom event details to support series-specific actions
  • add cross-device detection for recurring custom event series when local series membership is unavailable. This is a "best-effort" detection since the API is very restrictive

Notes

AFAICT The current API does not expose recurrence support in a way that fits this flow cleanly, so recurring entries are currently created as multiple individual events with bounded repetition options.

Testing

  • I tested the feature on iOS Simulator as well as Android
  • I manually tested this as thoroughly as I could, but since this is a large PR, there likely are corner cases I have overlooked. If you find anything or have any notes, please let me know and I will happily revise.

Related

@ManuelMuehlberger ManuelMuehlberger marked this pull request as ready for review April 18, 2026 08:46
@jakobkoerber jakobkoerber requested a review from Copilot May 1, 2026 11:05
@jakobkoerber jakobkoerber self-requested a review May 1, 2026 11:07
@jakobkoerber jakobkoerber added the feature New feature label May 1, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds creation/editing/deletion support for recurring custom calendar events (daily/weekly/biweekly/monthly) by generating multiple bounded single events and tracking series membership locally (with best-effort cross-device series inference when local membership is missing).

Changes:

  • Adds recurrence controls and a revised event creation flow (including series editing UI and save progress handling).
  • Introduces local persistence for series membership and series-level actions (edit/delete series).
  • Updates routing and calendar details UI to handle custom-event details in a full screen scaffold (instead of a dialog).

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
lib/calendarComponent/views/visibility_button_view.dart Adjusts visibility icon sizing/layout.
lib/calendarComponent/views/event_creation_view.dart Reworks event creation UI, adds recurrence section + submit bar + color picker in AppBar.
lib/calendarComponent/views/event_creation_form_field.dart Simplifies form field layout into inline TextFormFields within cards.
lib/calendarComponent/views/event_creation_date_time_picker.dart Replaces old picker with compact row/chip UI and localized labels.
lib/calendarComponent/views/custom_event_view.dart Introduces full-screen custom event details scaffold with series actions and confirmations.
lib/calendarComponent/views/calendar_event_view.dart Improves clipping and text truncation/line-limit behavior.
lib/calendarComponent/viewModels/calendar_viewmodel.dart Adds series resolution/inference, series deletion helpers, and refactors fetch error handling.
lib/calendarComponent/viewModels/calendar_addition_viewmodel.dart Adds recurrence state, occurrence generation, series-aware editing, and transactional save logic.
lib/calendarComponent/services/calendar_view_service.dart Routes all event taps to calendar details route (no dialog).
lib/calendarComponent/services/calendar_service.dart Fixes delete call to properly await the API request.
lib/calendarComponent/services/calendar_preference_service.dart Persists series membership and refactors preference persistence/loading.
lib/calendarComponent/model/calendar_preferences.dart Extends stored preferences with seriesPreferences.
lib/calendarComponent/model/calendar_preferences.g.dart Updates JSON serialization for seriesPreferences.
lib/calendarComponent/model/calendar_event.dart Adds hasLectureDetailsLink and improves localized time range formatting.
lib/calendarComponent/model/calendar_editing.dart Adds recurrence enums used by the UI/viewmodels.
lib/base/util/color_picker_view.dart Keeps internal state in sync when the parent-provided color changes.
lib/base/routing/router.dart Routes calendar details to lecture details vs custom event details based on link detection.
assets/translations/en.json Adds recurrence + series-related strings.
assets/translations/de.json Adds recurrence + series-related strings.
Comments suppressed due to low confidence (1)

lib/calendarComponent/services/calendar_preference_service.dart:117

  • resetPreferences() removes the SharedPreferences key but leaves the in-memory colorPreferences/visibilityPreferences/seriesPreferences maps intact and keeps _loaded == true. After calling reset, subsequent get*Preference calls will continue returning stale cached values and will not reload from disk. Consider clearing the maps and resetting _loaded (or calling loadPreferences() after clearing) inside resetPreferences().
      _loaded = true;
    } catch (e) {
      log('Failed to load calendar preferences: $e');
    }
  }

  void resetPreferences() {
    sharedPreferences.remove(key);
  }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}, onError: (error) => events.addError(error));
} catch (error) {
events.addError(error);
rethrow;
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch() now rethrows after pushing the error into events. There are multiple call sites (e.g. in initState) that invoke fetch(...) without awaiting/handling the returned Future, so a network/API failure can become an unhandled async exception (red screen / crash) even though the stream error is already surfaced to the UI. Consider removing the rethrow, or ensure all call sites await and handle errors explicitly.

Suggested change
rethrow;

Copilot uses AI. Check for mistakes.
Comment on lines 35 to +51
return url?.split("LvNr=").last;
}

bool get hasLectureDetailsLink {
final value = url;
final lectureNumber = lvNr;
if (value == null || lectureNumber == null || lectureNumber.isEmpty) {
return false;
}

final uri = Uri.tryParse(value);
if (uri == null || !uri.path.contains('lv.detail')) {
return false;
}

return uri.queryParameters['cLvNr'] == lectureNumber ||
uri.queryParameters['pLvNr'] == lectureNumber;
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasLectureDetailsLink compares uri.queryParameters['cLvNr'/'pLvNr'] against lvNr, but lvNr is derived via url?.split('LvNr=').last and can include trailing query params (e.g. 123&foo=bar). In that case the comparison will always fail and lecture events can be misrouted to CustomEventScaffold (enabling edit/delete UI for non-custom events) and break lecture-details navigation. Prefer parsing the Uri once and deriving the lecture number directly from query parameters (e.g. cLvNr/pLvNr/pLVNr) instead of string-splitting, and base hasLectureDetailsLink on that parsed value.

Suggested change
return url?.split("LvNr=").last;
}
bool get hasLectureDetailsLink {
final value = url;
final lectureNumber = lvNr;
if (value == null || lectureNumber == null || lectureNumber.isEmpty) {
return false;
}
final uri = Uri.tryParse(value);
if (uri == null || !uri.path.contains('lv.detail')) {
return false;
}
return uri.queryParameters['cLvNr'] == lectureNumber ||
uri.queryParameters['pLvNr'] == lectureNumber;
final value = url;
if (value == null || value.isEmpty) {
return null;
}
final uri = Uri.tryParse(value);
if (uri == null) {
return null;
}
return uri.queryParameters['cLvNr'] ??
uri.queryParameters['pLvNr'] ??
uri.queryParameters['pLVNr'];
}
bool get hasLectureDetailsLink {
final value = url;
if (value == null || value.isEmpty) {
return false;
}
final uri = Uri.tryParse(value);
final lectureNumber = lvNr;
if (uri == null ||
!uri.path.contains('lv.detail') ||
lectureNumber == null ||
lectureNumber.isEmpty) {
return false;
}
return true;

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +153
void setUntilDate(DateTime? date) {
if (date != null) {
untilDate.add(date);
checkValidity();
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setUntilDate accepts any picked date, but _generateOccurrences() will return an empty list if untilDate is before from (the loop breaks at i == 0). In the edit flow this can lead to deleting the original event (id != null) while creating zero replacement events. Consider clamping/validating untilDate to be on/after from (and updating checkValidity() accordingly), and/or guaranteeing _generateOccurrences() always includes at least the first occurrence.

Copilot uses AI. Check for mistakes.
Comment on lines +530 to +537
final picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 730),
),
);
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "ends on" date picker uses firstDate: DateTime.now() with initialDate: date. If date is in the past (e.g. editing an older event/series), showDatePicker will assert because initialDate must be on/after firstDate. Also, using DateTime.now() allows picking an until-date earlier than the event start date, which can result in zero generated occurrences. Consider setting firstDate to at least the event's start date (and no later than initialDate), e.g. based on vm.from/DateUtils.dateOnly(...).

Suggested change
final picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 730),
),
);
final eventStartDate = DateUtils.dateOnly(
calendarEvent.from,
);
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,
);

Copilot uses AI. Check for mistakes.
@jakobkoerber
Copy link
Copy Markdown
Member

Thanks for your contribution @ManuelMuehlberger! And sorry for the delay, I will review the PR this week :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Allow Creation of Recurring Calendar Events

3 participants