From a74c3fed1f0b6875f33098332ea2491402ede6f9 Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Tue, 7 Apr 2026 23:46:43 +0200 Subject: [PATCH] Add repeated calendar items --- api/lib/converters/ical.dart | 120 +++- api/lib/models/cached.mapper.dart | 15 +- api/lib/models/event/item/database.dart | 700 ++++++++++++++++++-- api/lib/models/event/item/model.dart | 128 +++- api/lib/models/event/item/model.mapper.dart | 345 +--------- api/lib/models/event/model.mapper.dart | 1 + api/lib/models/extra.mapper.dart | 1 + api/lib/models/group/model.mapper.dart | 1 + api/lib/models/label/model.mapper.dart | 1 + api/lib/models/model.mapper.dart | 1 + api/lib/models/note/database.dart | 21 +- api/lib/models/note/model.mapper.dart | 1 + api/lib/models/resource/model.mapper.dart | 1 + api/lib/models/user/model.mapper.dart | 1 + api/lib/services/database.dart | 2 +- api/lib/services/migration.dart | 23 + app/lib/api/storage/remote/caldav.dart | 10 +- app/lib/pages/calendar/day.dart | 8 +- app/lib/pages/calendar/item.dart | 231 +++++++ app/lib/pages/calendar/month.dart | 16 +- app/lib/pages/calendar/pending.dart | 7 +- 21 files changed, 1170 insertions(+), 464 deletions(-) diff --git a/api/lib/converters/ical.dart b/api/lib/converters/ical.dart index ac082f217b4..f8908a1850a 100644 --- a/api/lib/converters/ical.dart +++ b/api/lib/converters/ical.dart @@ -9,7 +9,17 @@ class _RRule { final int interval; final int count; final DateTime? until; - _RRule(this.repeatType, this.interval, this.count, this.until); + final List byWeekDays; + final List byMonthDays; + + _RRule( + this.repeatType, + this.interval, + this.count, + this.until, { + this.byWeekDays = const [], + this.byMonthDays = const [], + }); } class ICalConverter { @@ -100,7 +110,7 @@ class ICalConverter { status: c.status, repeatType: rrule.repeatType, interval: rrule.interval, - variation: 0, + variation: _variationFromRRule(rrule), count: rrule.count, until: rrule.until, ); @@ -197,6 +207,8 @@ class ICalConverter { int interval = 1; int count = 0; DateTime? until; + var byWeekDays = []; + var byMonthDays = []; final parts = value.split(';'); for (final part in parts) { @@ -218,9 +230,70 @@ class ICalConverter { count = int.tryParse(v) ?? 0; } else if (k == 'UNTIL') { until = _parseDateTime(v); + } else if (k == 'BYDAY') { + byWeekDays = _parseByDay(v); + } else if (k == 'BYMONTHDAY') { + byMonthDays = _parseByMonthDay(v); } } - return _RRule(type, interval, count, until); + return _RRule( + type, + interval, + count, + until, + byWeekDays: byWeekDays, + byMonthDays: byMonthDays, + ); + } + + int _variationFromRRule(_RRule rule) { + switch (rule.repeatType) { + case RepeatType.weekly: + return RepeatingCalendarItem.encodeWeeklyWeekdays(rule.byWeekDays); + case RepeatType.monthly: + return RepeatingCalendarItem.encodeMonthlyMonthDays(rule.byMonthDays); + case RepeatType.daily: + case RepeatType.yearly: + return 0; + } + } + + List _parseByDay(String value) { + final weekdays = []; + for (final part in value.split(',')) { + final token = part.trim().toUpperCase(); + if (token.length < 2) continue; + final dayCode = token.substring(token.length - 2); + final weekday = _weekdayFromIcs(dayCode); + if (weekday != null) { + weekdays.add(weekday); + } + } + return weekdays; + } + + List _parseByMonthDay(String value) { + final days = []; + for (final part in value.split(',')) { + final day = int.tryParse(part.trim()); + if (day != null && day >= 1 && day <= 31) { + days.add(day); + } + } + return days; + } + + int? _weekdayFromIcs(String day) { + return switch (day) { + 'MO' => DateTime.monday, + 'TU' => DateTime.tuesday, + 'WE' => DateTime.wednesday, + 'TH' => DateTime.thursday, + 'FR' => DateTime.friday, + 'SA' => DateTime.saturday, + 'SU' => DateTime.sunday, + _ => null, + }; } EventStatus _parseEventStatus(String value) { @@ -254,12 +327,49 @@ class ICalConverter { if (item.location.isNotEmpty) 'LOCATION:${_escape(item.location)}', if (item.start != null) 'DTSTART:${_formatDateTime(item.start!.toUtc())}', if (item.end != null) 'DTEND:${_formatDateTime(item.end!.toUtc())}', - if (item is RepeatingCalendarItem) - 'RRULE:FREQ=${_formatRepeatType(item.repeatType)}${item.interval > 1 ? ';INTERVAL=${item.interval}' : ''}${item.count > 0 ? ';COUNT=${item.count}' : ''}${item.until != null ? ';UNTIL=${_formatDateTime(item.until!.toUtc())}' : ''}', + if (item is RepeatingCalendarItem) _formatRRule(item), 'STATUS:${_formatEventStatus(item.status)}', 'END:VEVENT', ]; + String _formatRRule(RepeatingCalendarItem item) { + final parts = ['FREQ=${_formatRepeatType(item.repeatType)}']; + if (item.interval > 1) { + parts.add('INTERVAL=${item.interval}'); + } + if (item.count > 0) { + parts.add('COUNT=${item.count}'); + } + if (item.until != null) { + parts.add('UNTIL=${_formatDateTime(item.until!.toUtc())}'); + } + if (item.repeatType == RepeatType.weekly) { + final weekdays = item.weeklyVariationWeekdays; + if (weekdays.isNotEmpty) { + parts.add('BYDAY=${weekdays.map(_weekdayToIcs).join(',')}'); + } + } else if (item.repeatType == RepeatType.monthly) { + final monthDays = item.monthlyVariationMonthDays; + if (monthDays.isNotEmpty) { + parts.add('BYMONTHDAY=${monthDays.join(',')}'); + } + } + return 'RRULE:${parts.join(';')}'; + } + + String _weekdayToIcs(int weekday) { + return switch (weekday) { + DateTime.monday => 'MO', + DateTime.tuesday => 'TU', + DateTime.wednesday => 'WE', + DateTime.thursday => 'TH', + DateTime.friday => 'FR', + DateTime.saturday => 'SA', + DateTime.sunday => 'SU', + _ => 'MO', + }; + } + String _formatDateTime(DateTime dateTime) => "${dateTime.year}${dateTime.month.toString().padLeft(2, '0')}${dateTime.day.toString().padLeft(2, '0')}T${dateTime.hour.toString().padLeft(2, '0')}${dateTime.minute.toString().padLeft(2, '0')}00Z"; diff --git a/api/lib/models/cached.mapper.dart b/api/lib/models/cached.mapper.dart index 73320142830..34622f8ef7c 100644 --- a/api/lib/models/cached.mapper.dart +++ b/api/lib/models/cached.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter @@ -142,7 +143,11 @@ abstract class CachedDataCopyWith<$R, $In extends CachedData, $Out> ListCopyWith<$R, Event, EventCopyWith<$R, Event, Event>> get events; ListCopyWith<$R, Notebook, NotebookCopyWith<$R, Notebook, Notebook>> get notebooks; - ListCopyWith<$R, CalendarItem, ObjectCopyWith<$R, CalendarItem, CalendarItem>> + ListCopyWith< + $R, + CalendarItem, + CalendarItemCopyWith<$R, CalendarItem, CalendarItem> + > get items; ListCopyWith<$R, Note, NoteCopyWith<$R, Note, Note>> get notes; $R call({ @@ -178,10 +183,14 @@ class _CachedDataCopyWithImpl<$R, $Out> (v) => call(notebooks: v), ); @override - ListCopyWith<$R, CalendarItem, ObjectCopyWith<$R, CalendarItem, CalendarItem>> + ListCopyWith< + $R, + CalendarItem, + CalendarItemCopyWith<$R, CalendarItem, CalendarItem> + > get items => ListCopyWith( $value.items, - (v, t) => ObjectCopyWith(v, $identity, t), + (v, t) => v.copyWith.$chain(t), (v) => call(items: v), ); @override diff --git a/api/lib/models/event/item/database.dart b/api/lib/models/event/item/database.dart index 75ae3f46f75..4495b521749 100644 --- a/api/lib/models/event/item/database.dart +++ b/api/lib/models/event/item/database.dart @@ -14,6 +14,8 @@ class CalendarItemDatabaseService extends CalendarItemService with TableService { CalendarItemDatabaseService(); + static const _defaultRangeDays = 31; + @override Future create( DatabaseExecutor db, [ @@ -21,7 +23,7 @@ class CalendarItemDatabaseService extends CalendarItemService ]) async { await db.execute(""" CREATE TABLE IF NOT EXISTS $name ( - runtimeType VARCHAR(20) NOT NULL DEFAULT 'fixed', + runtimeType VARCHAR(32) NOT NULL DEFAULT 'FixedCalendarItem', id BLOB(16) PRIMARY KEY, name VARCHAR(100) NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', @@ -36,9 +38,6 @@ class CalendarItemDatabaseService extends CalendarItemService count INTEGER NOT NULL DEFAULT 0, until INTEGER, exceptions TEXT, - autoGroupId BLOB(16), - searchStart INTEGER, - autoDuration INTEGER NOT NULL DEFAULT 60, FOREIGN KEY (eventId) REFERENCES events(id) ON DELETE CASCADE ) """); @@ -59,85 +58,185 @@ class CalendarItemDatabaseService extends CalendarItemService String search = '', }) async { String? where; - List? whereArgs; + final whereArgs = []; + final hasTemporalFilter = start != null || end != null || date != null; + if (status != null) { - where = 'status IN (${status.map((e) => '?').join(', ')})'; - whereArgs = status.map((e) => e.name).toList(); - } - if (start != null) { - where = where == null ? 'start >= ?' : '$where AND start >= ?'; - whereArgs = [...?whereArgs, start.secondsSinceEpoch]; - } - if (end != null) { - where = where == null ? 'end <= ?' : '$where AND end <= ?'; - whereArgs = [...?whereArgs, end.secondsSinceEpoch]; - } - if (date != null) { - var startCalendarItem = date.onlyDate(); - var endCalendarItem = startCalendarItem.add( - const Duration(hours: 23, minutes: 59, seconds: 59), + where = _addWhere( + where, + whereArgs, + 'status IN (${status.map((e) => '?').join(', ')})', + status.map((e) => e.name), ); - where = where == null - ? '(start BETWEEN ? AND ? OR end BETWEEN ? AND ? OR (start <= ? AND end >= ?))' - : '$where AND (start BETWEEN ? AND ? OR end BETWEEN ? AND ? OR (start <= ? AND end >= ?))'; - whereArgs = [ - ...?whereArgs, - startCalendarItem.secondsSinceEpoch, - endCalendarItem.secondsSinceEpoch, - startCalendarItem.secondsSinceEpoch, - endCalendarItem.secondsSinceEpoch, - startCalendarItem.secondsSinceEpoch, - endCalendarItem.secondsSinceEpoch, - ]; } if (pending) { - where = where == null - ? '(start IS NULL AND end IS NULL)' - : '$where AND (start IS NULL AND end IS NULL)'; + where = _addWhere(where, whereArgs, '(start IS NULL AND end IS NULL)'); } if (search.isNotEmpty) { - where = where == null - ? '(name LIKE ? OR description LIKE ?)' - : '$where AND (name LIKE ? OR description LIKE ?)'; - whereArgs = [...?whereArgs, '%$search%', '%$search%']; + where = _addWhere( + where, + whereArgs, + '(name LIKE ? OR description LIKE ?)', + ['%$search%', '%$search%'], + ); } if (groupIds != null) { final placeholders = List.filled(groupIds.length, '?').join(', '); final statement = "(calendarItems.id IN (SELECT itemId FROM calendarItemGroups WHERE groupId IN ($placeholders)) OR " "calendarItems.eventId IN (SELECT eventId FROM eventGroups WHERE groupId IN ($placeholders)))"; - where = where == null ? statement : '$where AND $statement'; - whereArgs = [...?whereArgs, ...groupIds, ...groupIds]; + where = _addWhere(where, whereArgs, statement, [ + ...groupIds, + ...groupIds, + ]); } if (eventId != null) { - where = where == null ? 'eventId = ?' : '$where AND eventId = ?'; - whereArgs = [...?whereArgs, eventId]; + where = _addWhere(where, whereArgs, 'eventId = ?', [eventId]); } if (resourceIds != null) { final placeholders = List.filled(resourceIds.length, '?').join(', '); final statement = "(calendarItems.id IN (SELECT itemId FROM calendarItemResources WHERE resourceId IN ($placeholders)) OR " "calendarItems.eventId IN (SELECT eventId FROM eventResources WHERE resourceId IN ($placeholders)))"; - where = where == null ? statement : '$where AND $statement'; - whereArgs = [...?whereArgs, ...resourceIds, ...resourceIds]; + where = _addWhere(where, whereArgs, statement, [ + ...resourceIds, + ...resourceIds, + ]); } - const eventPrefix = "event_"; + if (!hasTemporalFilter) { + return _queryItems( + where: where, + whereArgs: whereArgs, + offset: offset, + limit: limit, + ); + } + + final windowStart = + date?.onlyDate() ?? + start ?? + end?.subtract(const Duration(days: _defaultRangeDays)) ?? + DateTime.now().subtract(const Duration(days: _defaultRangeDays)); + final windowEnd = date != null + ? _endOfDay(date) + : end ?? + start?.add(const Duration(days: _defaultRangeDays)) ?? + DateTime.now().add(const Duration(days: _defaultRangeDays)); + + final fixedWhereArgs = [...whereArgs]; + var fixedWhere = where; + fixedWhere = _addWhere( + fixedWhere, + fixedWhereArgs, + '(runtimeType NOT IN (?, ?, ?))', + const ['RepeatingCalendarItem', 'repeating', 'AutoCalendarItem'], + ); + fixedWhere = _addWhere( + fixedWhere, + fixedWhereArgs, + '(start BETWEEN ? AND ? OR end BETWEEN ? AND ? OR (start <= ? AND end >= ?))', + [ + windowStart.secondsSinceEpoch, + windowEnd.secondsSinceEpoch, + windowStart.secondsSinceEpoch, + windowEnd.secondsSinceEpoch, + windowStart.secondsSinceEpoch, + windowEnd.secondsSinceEpoch, + ], + ); + + final repeatingWhereArgs = [...whereArgs]; + var repeatingWhere = where; + repeatingWhere = _addWhere( + repeatingWhere, + repeatingWhereArgs, + '(runtimeType IN (?, ?, ?))', + const ['RepeatingCalendarItem', 'repeating', 'AutoCalendarItem'], + ); + // Limit recurrence definitions to rows that can potentially produce + // occurrences inside the requested window. + repeatingWhere = _addWhere( + repeatingWhere, + repeatingWhereArgs, + 'start IS NOT NULL', + ); + repeatingWhere = _addWhere( + repeatingWhere, + repeatingWhereArgs, + 'start <= ?', + [windowEnd.secondsSinceEpoch], + ); + repeatingWhere = _addWhere( + repeatingWhere, + repeatingWhereArgs, + '(until IS NULL OR until >= ?)', + [windowStart.secondsSinceEpoch], + ); + + final fixedItems = await _queryItems( + where: fixedWhere, + whereArgs: fixedWhereArgs, + ); + final repeatingDefinitions = await _queryItems( + where: repeatingWhere, + whereArgs: repeatingWhereArgs, + ); + + final expandedRepeating = repeatingDefinitions.expand( + (entry) => _expandConnectedForRange( + entry, + windowStart, + windowEnd, + start: start, + end: end, + date: date, + ), + ); + + final merged = [...fixedItems, ...expandedRepeating] + ..sort(_compareCalendarItems); + + final startIndex = offset.clamp(0, merged.length); + final endIndex = (startIndex + limit).clamp(0, merged.length); + return merged.sublist(startIndex, endIndex); + } + + String _addWhere( + String? current, + List whereArgs, + String clause, [ + Iterable args = const [], + ]) { + whereArgs.addAll(args); + return current == null ? clause : '$current AND $clause'; + } + + Future>> _queryItems({ + String? where, + List? whereArgs, + int? offset, + int? limit, + }) async { + const eventPrefix = 'event_'; final result = await db?.query( - "calendarItems LEFT JOIN events ON events.id = calendarItems.eventId", + 'calendarItems LEFT JOIN events ON events.id = calendarItems.eventId', columns: [ - "events.id AS ${eventPrefix}id", - "events.parentId AS ${eventPrefix}parentId", - "events.blocked AS ${eventPrefix}blocked", - "events.name AS ${eventPrefix}name", - "events.description AS ${eventPrefix}description", - "events.location AS ${eventPrefix}location", - "events.extra AS ${eventPrefix}extra", - "calendarItems.*", + 'events.id AS ${eventPrefix}id', + 'events.parentId AS ${eventPrefix}parentId', + 'events.blocked AS ${eventPrefix}blocked', + 'events.name AS ${eventPrefix}name', + 'events.description AS ${eventPrefix}description', + 'events.location AS ${eventPrefix}location', + 'events.extra AS ${eventPrefix}extra', + 'calendarItems.*', ], where: where, whereArgs: whereArgs, + offset: offset, + limit: limit, ); + return result ?.map( (e) => ConnectedModel( @@ -165,6 +264,497 @@ class CalendarItemDatabaseService extends CalendarItemService []; } + int _compareCalendarItems( + ConnectedModel a, + ConnectedModel b, + ) { + final aStart = a.source.start; + final bStart = b.source.start; + if (aStart == null && bStart != null) return 1; + if (aStart != null && bStart == null) return -1; + if (aStart != null && bStart != null) { + final startCmp = aStart.compareTo(bStart); + if (startCmp != 0) return startCmp; + } + + final aEnd = a.source.end; + final bEnd = b.source.end; + if (aEnd == null && bEnd != null) return 1; + if (aEnd != null && bEnd == null) return -1; + if (aEnd != null && bEnd != null) { + final endCmp = aEnd.compareTo(bEnd); + if (endCmp != 0) return endCmp; + } + + return a.source.name.compareTo(b.source.name); + } + + Iterable> _expandConnectedForRange( + ConnectedModel entry, + DateTime windowStart, + DateTime windowEnd, { + DateTime? start, + DateTime? end, + DateTime? date, + }) sync* { + final item = entry.source; + final expanded = item is RepeatingCalendarItem + ? _expandRepeatingCalendarItem(item, windowStart, windowEnd) + : [item]; + + for (final occurrence in expanded) { + if (_matchesTemporalFilter( + occurrence, + start: start, + end: end, + date: date, + )) { + yield ConnectedModel(occurrence, entry.model); + } + } + } + + bool _matchesTemporalFilter( + CalendarItem item, { + DateTime? start, + DateTime? end, + DateTime? date, + }) { + if (start != null && (item.start == null || item.start!.isBefore(start))) { + return false; + } + if (end != null && (item.end == null || item.end!.isAfter(end))) { + return false; + } + if (date != null) { + final dayStart = date.onlyDate(); + final dayEnd = _endOfDay(date); + if (!_overlapsRange(item.start, item.end, dayStart, dayEnd)) { + return false; + } + } + return true; + } + + bool _overlapsRange( + DateTime? itemStart, + DateTime? itemEnd, + DateTime rangeStart, + DateTime rangeEnd, + ) { + return (itemEnd == null || !itemEnd.isBefore(rangeStart)) && + (itemStart == null || !itemStart.isAfter(rangeEnd)); + } + + DateTime _endOfDay(DateTime date) => + date.onlyDate().add(const Duration(hours: 23, minutes: 59, seconds: 59)); + + List _expandRepeatingCalendarItem( + RepeatingCalendarItem item, + DateTime windowStart, + DateTime windowEnd, + ) { + final baseStart = item.start; + if (baseStart == null) { + return [item]; + } + final baseEnd = item.end; + final duration = baseEnd?.difference(baseStart) ?? Duration.zero; + final searchStart = duration > Duration.zero + ? windowStart.subtract(duration) + : windowStart; + return _expandRecurring( + item, + repeatType: item.repeatType, + interval: item.interval, + count: item.count, + until: item.until, + exceptions: item.exceptions, + baseStart: baseStart, + duration: duration, + weeklyWeekdays: item.weeklyWeekdays, + monthlyMonthDays: item.monthlyMonthDays, + searchStart: searchStart, + windowEnd: windowEnd, + ); + } + + List _expandRecurring( + CalendarItem source, { + required RepeatType repeatType, + required int interval, + required int count, + required DateTime? until, + required List exceptions, + required DateTime baseStart, + required Duration duration, + required List weeklyWeekdays, + required List monthlyMonthDays, + required DateTime searchStart, + required DateTime windowEnd, + }) { + final occurrences = []; + final safeInterval = interval <= 0 ? 1 : interval; + final exceptionSet = exceptions.toSet(); + + void addOccurrence(DateTime occurrenceStart) { + if (until != null && occurrenceStart.isAfter(until)) { + return; + } + final occurrenceStartSeconds = occurrenceStart.secondsSinceEpoch; + final occurrenceDateSeconds = occurrenceStart + .onlyDate() + .secondsSinceEpoch; + if (exceptionSet.contains(occurrenceStartSeconds) || + exceptionSet.contains(occurrenceDateSeconds)) { + return; + } + final occurrenceEnd = duration == Duration.zero + ? source.end == null + ? null + : occurrenceStart + : occurrenceStart.add(duration); + if (_overlapsRange( + occurrenceStart, + occurrenceEnd, + searchStart, + windowEnd, + )) { + occurrences.add(_copyWithDates(source, occurrenceStart, occurrenceEnd)); + } + } + + switch (repeatType) { + case RepeatType.daily: + _expandDaily( + addOccurrence, + baseStart: baseStart, + interval: safeInterval, + count: count, + until: until, + searchStart: searchStart, + windowEnd: windowEnd, + ); + break; + case RepeatType.weekly: + _expandWeekly( + addOccurrence, + baseStart: baseStart, + interval: safeInterval, + count: count, + until: until, + weekdays: weeklyWeekdays, + searchStart: searchStart, + windowEnd: windowEnd, + ); + break; + case RepeatType.monthly: + _expandMonthly( + addOccurrence, + baseStart: baseStart, + interval: safeInterval, + count: count, + until: until, + monthDays: monthlyMonthDays, + searchStart: searchStart, + windowEnd: windowEnd, + ); + break; + case RepeatType.yearly: + _expandYearly( + addOccurrence, + baseStart: baseStart, + interval: safeInterval, + count: count, + until: until, + searchStart: searchStart, + windowEnd: windowEnd, + ); + break; + } + + return occurrences; + } + + void _expandDaily( + void Function(DateTime) addOccurrence, { + required DateTime baseStart, + required int interval, + required int count, + required DateTime? until, + required DateTime searchStart, + required DateTime windowEnd, + }) { + if (count > 0) { + for (var i = 0; i < count; i++) { + final occurrenceStart = baseStart.add(Duration(days: i * interval)); + if (until != null && occurrenceStart.isAfter(until)) break; + if (occurrenceStart.isAfter(windowEnd)) break; + addOccurrence(occurrenceStart); + } + return; + } + + var occurrenceStart = baseStart; + if (searchStart.isAfter(baseStart)) { + final diffDays = searchStart.difference(baseStart).inDays; + final jump = diffDays ~/ interval; + occurrenceStart = baseStart.add(Duration(days: jump * interval)); + while (occurrenceStart.isBefore(searchStart)) { + occurrenceStart = occurrenceStart.add(Duration(days: interval)); + } + } + + while (!occurrenceStart.isAfter(windowEnd)) { + if (until != null && occurrenceStart.isAfter(until)) break; + addOccurrence(occurrenceStart); + occurrenceStart = occurrenceStart.add(Duration(days: interval)); + } + } + + void _expandWeekly( + void Function(DateTime) addOccurrence, { + required DateTime baseStart, + required int interval, + required int count, + required DateTime? until, + required List weekdays, + required DateTime searchStart, + required DateTime windowEnd, + }) { + final normalizedWeekdays = + (weekdays.isEmpty ? [baseStart.weekday] : weekdays) + .where( + (weekday) => + weekday >= DateTime.monday && weekday <= DateTime.sunday, + ) + .toSet() + .toList() + ..sort(); + final baseWeekStart = baseStart.onlyDate().subtract( + Duration(days: baseStart.weekday - 1), + ); + + if (count > 0) { + var produced = 0; + var weekOffset = 0; + while (produced < count) { + final weekStart = baseWeekStart.add(Duration(days: weekOffset * 7)); + for (final weekday in normalizedWeekdays) { + final day = weekStart.add(Duration(days: weekday - 1)); + final occurrenceStart = DateTime( + day.year, + day.month, + day.day, + baseStart.hour, + baseStart.minute, + baseStart.second, + baseStart.millisecond, + baseStart.microsecond, + ); + if (occurrenceStart.isBefore(baseStart)) continue; + if (until != null && occurrenceStart.isAfter(until)) return; + if (occurrenceStart.isAfter(windowEnd)) return; + produced++; + addOccurrence(occurrenceStart); + if (produced >= count) return; + } + weekOffset += interval; + } + return; + } + + var cursor = searchStart.onlyDate(); + final endDate = windowEnd.onlyDate(); + while (!cursor.isAfter(endDate)) { + final occurrenceStart = DateTime( + cursor.year, + cursor.month, + cursor.day, + baseStart.hour, + baseStart.minute, + baseStart.second, + baseStart.millisecond, + baseStart.microsecond, + ); + if (!occurrenceStart.isBefore(baseStart) && + normalizedWeekdays.contains(cursor.weekday)) { + final cursorWeekStart = cursor.subtract( + Duration(days: cursor.weekday - 1), + ); + final weekDiff = cursorWeekStart.difference(baseWeekStart).inDays ~/ 7; + if (weekDiff >= 0 && weekDiff % interval == 0) { + if (until != null && occurrenceStart.isAfter(until)) return; + addOccurrence(occurrenceStart); + } + } + cursor = cursor.add(const Duration(days: 1)); + } + } + + void _expandMonthly( + void Function(DateTime) addOccurrence, { + required DateTime baseStart, + required int interval, + required int count, + required DateTime? until, + required List monthDays, + required DateTime searchStart, + required DateTime windowEnd, + }) { + final normalizedMonthDays = + (monthDays.isEmpty ? [baseStart.day] : monthDays) + .where((day) => day >= 1 && day <= 31) + .toSet() + .toList() + ..sort(); + final baseMonth = DateTime(baseStart.year, baseStart.month); + + DateTime createOccurrenceStart(DateTime month, int day) => DateTime( + month.year, + month.month, + day, + baseStart.hour, + baseStart.minute, + baseStart.second, + baseStart.millisecond, + baseStart.microsecond, + ); + + void emitMonth(DateTime month) { + final maxDay = _daysInMonth(month.year, month.month); + for (final day in normalizedMonthDays) { + if (day > maxDay) continue; + final occurrenceStart = createOccurrenceStart(month, day); + if (occurrenceStart.isBefore(baseStart)) continue; + addOccurrence(occurrenceStart); + } + } + + if (count > 0) { + var produced = 0; + var monthOffset = 0; + while (produced < count) { + final month = DateTime(baseMonth.year, baseMonth.month + monthOffset); + final maxDay = _daysInMonth(month.year, month.month); + for (final day in normalizedMonthDays) { + if (day > maxDay) continue; + final occurrenceStart = createOccurrenceStart(month, day); + if (occurrenceStart.isBefore(baseStart)) continue; + if (until != null && occurrenceStart.isAfter(until)) return; + if (occurrenceStart.isAfter(windowEnd)) return; + produced++; + addOccurrence(occurrenceStart); + if (produced >= count) return; + } + monthOffset += interval; + } + return; + } + + final startMonth = DateTime(searchStart.year, searchStart.month); + var monthDiff = + (startMonth.year - baseMonth.year) * 12 + + (startMonth.month - baseMonth.month); + if (monthDiff < 0) { + monthDiff = 0; + } + monthDiff -= monthDiff % interval; + + var currentMonth = DateTime(baseMonth.year, baseMonth.month + monthDiff); + while (!currentMonth.isAfter(DateTime(windowEnd.year, windowEnd.month))) { + if (until != null && + DateTime(currentMonth.year, currentMonth.month, 1).isAfter(until)) { + break; + } + emitMonth(currentMonth); + currentMonth = DateTime(currentMonth.year, currentMonth.month + interval); + } + } + + void _expandYearly( + void Function(DateTime) addOccurrence, { + required DateTime baseStart, + required int interval, + required int count, + required DateTime? until, + required DateTime searchStart, + required DateTime windowEnd, + }) { + if (count > 0) { + for (var i = 0; i < count; i++) { + final year = baseStart.year + i * interval; + if (!_isValidDayInMonth(year, baseStart.month, baseStart.day)) { + continue; + } + final occurrenceStart = DateTime( + year, + baseStart.month, + baseStart.day, + baseStart.hour, + baseStart.minute, + baseStart.second, + baseStart.millisecond, + baseStart.microsecond, + ); + if (until != null && occurrenceStart.isAfter(until)) break; + if (occurrenceStart.isAfter(windowEnd)) break; + addOccurrence(occurrenceStart); + } + return; + } + + var year = baseStart.year; + if (searchStart.year > baseStart.year) { + var diff = searchStart.year - baseStart.year; + diff -= diff % interval; + year = baseStart.year + diff; + } + while (true) { + if (!_isValidDayInMonth(year, baseStart.month, baseStart.day)) { + year += interval; + continue; + } + final occurrenceStart = DateTime( + year, + baseStart.month, + baseStart.day, + baseStart.hour, + baseStart.minute, + baseStart.second, + baseStart.millisecond, + baseStart.microsecond, + ); + if (occurrenceStart.isBefore(baseStart)) { + year += interval; + continue; + } + if (occurrenceStart.isAfter(windowEnd)) break; + if (until != null && occurrenceStart.isAfter(until)) break; + addOccurrence(occurrenceStart); + year += interval; + } + } + + int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; + + bool _isValidDayInMonth(int year, int month, int day) => + day <= _daysInMonth(year, month); + + CalendarItem _copyWithDates( + CalendarItem item, + DateTime? start, + DateTime? end, + ) { + return switch (item) { + FixedCalendarItem fixed => fixed.copyWith(start: start, end: end), + RepeatingCalendarItem repeating => repeating.copyWith( + start: start, + end: end, + ), + }; + } + @override Future createCalendarItem(CalendarItem item) async { final id = item.id ?? createUniqueUint8List(); diff --git a/api/lib/models/event/item/model.dart b/api/lib/models/event/item/model.dart index 1ebb8315784..eea66859f39 100644 --- a/api/lib/models/event/item/model.dart +++ b/api/lib/models/event/item/model.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:dart_mappable/dart_mappable.dart'; @@ -7,6 +8,50 @@ import '../model.dart'; part 'model.mapper.dart'; +List _decodeExceptions(dynamic value) { + if (value == null) return const []; + if (value is List) { + return value + .map((e) => e is num ? e.toInt() : int.tryParse('$e')) + .whereType() + .toList(); + } + if (value is String && value.isNotEmpty) { + try { + final decoded = jsonDecode(value); + if (decoded is List) { + return decoded + .map((e) => e is num ? e.toInt() : int.tryParse('$e')) + .whereType() + .toList(); + } + } catch (_) { + return const []; + } + } + return const []; +} + +int _encodeBitMask(Iterable values, {required int min, required int max}) { + var mask = 0; + for (final value in values.toSet()) { + if (value < min || value > max) continue; + mask |= 1 << (value - min); + } + return mask; +} + +List _decodeBitMask(int mask, {required int min, required int max}) { + if (mask <= 0) return const []; + final values = []; + for (var i = min; i <= max; i++) { + if ((mask & (1 << (i - min))) != 0) { + values.add(i); + } + } + return values; +} + @MappableEnum() enum CalendarItemType { appointment, moment, pending } @@ -33,8 +78,14 @@ sealed class CalendarItem this.status = EventStatus.confirmed, }); - factory CalendarItem.fromDatabase(Map row) => - FixedCalendarItemMapper.fromMap(row); + factory CalendarItem.fromDatabase(Map row) { + final mapped = {...row, 'exceptions': _decodeExceptions(row['exceptions'])}; + final runtimeType = row['runtimeType']?.toString(); + if (runtimeType == 'RepeatingCalendarItem' || runtimeType == 'repeating') { + return RepeatingCalendarItemMapper.fromMap(mapped); + } + return FixedCalendarItemMapper.fromMap(mapped); + } CalendarItemType get type { if (start == null && end == null) { @@ -67,6 +118,12 @@ final class FixedCalendarItem extends CalendarItem super.end, super.status, }); + + @override + Map toDatabase() => { + ...toMap(), + 'runtimeType': 'FixedCalendarItem', + }; } @MappableClass() @@ -77,6 +134,36 @@ final class RepeatingCalendarItem extends CalendarItem final DateTime? until; final List exceptions; + static int encodeWeeklyWeekdays(Iterable weekdays) => + _encodeBitMask(weekdays, min: DateTime.monday, max: DateTime.sunday); + + static List decodeWeeklyWeekdays(int variation) => + _decodeBitMask(variation, min: DateTime.monday, max: DateTime.sunday); + + static int encodeMonthlyMonthDays(Iterable monthDays) => + _encodeBitMask(monthDays, min: 1, max: 31); + + static List decodeMonthlyMonthDays(int variation) => + _decodeBitMask(variation, min: 1, max: 31); + + List get weeklyVariationWeekdays => decodeWeeklyWeekdays(variation); + + List get monthlyVariationMonthDays => decodeMonthlyMonthDays(variation); + + List get weeklyWeekdays { + final weekdays = weeklyVariationWeekdays; + if (weekdays.isNotEmpty) return weekdays; + final weekday = start?.weekday; + return weekday == null ? const [] : [weekday]; + } + + List get monthlyMonthDays { + final monthDays = monthlyVariationMonthDays; + if (monthDays.isNotEmpty) return monthDays; + final day = start?.day; + return day == null ? const [] : [day]; + } + const RepeatingCalendarItem({ super.id, super.name, @@ -93,36 +180,11 @@ final class RepeatingCalendarItem extends CalendarItem this.until, this.exceptions = const [], }); -} - -@MappableClass() -final class AutoCalendarItem extends CalendarItem - with AutoCalendarItemMappable { - final RepeatType repeatType; - final int interval, variation, count; - final DateTime? until; - final List exceptions; - final Uint8List? autoGroupId; - final DateTime? searchStart; - final int autoDuration; - const AutoCalendarItem({ - super.id, - super.name, - super.description, - super.location, - super.eventId, - super.status, - super.start, - super.end, - this.repeatType = RepeatType.daily, - this.interval = 1, - this.variation = 0, - this.count = 0, - this.until, - this.exceptions = const [], - this.autoGroupId, - this.searchStart, - this.autoDuration = 60, - }); + @override + Map toDatabase() => { + ...toMap(), + 'runtimeType': 'RepeatingCalendarItem', + 'exceptions': jsonEncode(exceptions), + }; } diff --git a/api/lib/models/event/item/model.mapper.dart b/api/lib/models/event/item/model.mapper.dart index 78baf038db3..af770007e11 100644 --- a/api/lib/models/event/item/model.mapper.dart +++ b/api/lib/models/event/item/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter @@ -66,7 +67,6 @@ class CalendarItemMapper extends ClassMapperBase { MapperContainer.globals.use(_instance = CalendarItemMapper._()); FixedCalendarItemMapper.ensureInitialized(); RepeatingCalendarItemMapper.ensureInitialized(); - AutoCalendarItemMapper.ensureInitialized(); EventStatusMapper.ensureInitialized(); } return _instance!; @@ -721,346 +721,3 @@ class _RepeatingCalendarItemCopyWithImpl<$R, $Out> _RepeatingCalendarItemCopyWithImpl<$R2, $Out2>($value, $cast, t); } -class AutoCalendarItemMapper extends ClassMapperBase { - AutoCalendarItemMapper._(); - - static AutoCalendarItemMapper? _instance; - static AutoCalendarItemMapper ensureInitialized() { - if (_instance == null) { - MapperContainer.globals.use(_instance = AutoCalendarItemMapper._()); - CalendarItemMapper.ensureInitialized(); - EventStatusMapper.ensureInitialized(); - RepeatTypeMapper.ensureInitialized(); - } - return _instance!; - } - - @override - final String id = 'AutoCalendarItem'; - - static Uint8List? _$id(AutoCalendarItem v) => v.id; - static const Field _f$id = Field( - 'id', - _$id, - opt: true, - ); - static String _$name(AutoCalendarItem v) => v.name; - static const Field _f$name = Field( - 'name', - _$name, - opt: true, - def: '', - ); - static String _$description(AutoCalendarItem v) => v.description; - static const Field _f$description = Field( - 'description', - _$description, - opt: true, - def: '', - ); - static String _$location(AutoCalendarItem v) => v.location; - static const Field _f$location = Field( - 'location', - _$location, - opt: true, - def: '', - ); - static Uint8List? _$eventId(AutoCalendarItem v) => v.eventId; - static const Field _f$eventId = Field( - 'eventId', - _$eventId, - opt: true, - ); - static EventStatus _$status(AutoCalendarItem v) => v.status; - static const Field _f$status = Field( - 'status', - _$status, - opt: true, - def: EventStatus.confirmed, - ); - static DateTime? _$start(AutoCalendarItem v) => v.start; - static const Field _f$start = Field( - 'start', - _$start, - opt: true, - ); - static DateTime? _$end(AutoCalendarItem v) => v.end; - static const Field _f$end = Field( - 'end', - _$end, - opt: true, - ); - static RepeatType _$repeatType(AutoCalendarItem v) => v.repeatType; - static const Field _f$repeatType = Field( - 'repeatType', - _$repeatType, - opt: true, - def: RepeatType.daily, - ); - static int _$interval(AutoCalendarItem v) => v.interval; - static const Field _f$interval = Field( - 'interval', - _$interval, - opt: true, - def: 1, - ); - static int _$variation(AutoCalendarItem v) => v.variation; - static const Field _f$variation = Field( - 'variation', - _$variation, - opt: true, - def: 0, - ); - static int _$count(AutoCalendarItem v) => v.count; - static const Field _f$count = Field( - 'count', - _$count, - opt: true, - def: 0, - ); - static DateTime? _$until(AutoCalendarItem v) => v.until; - static const Field _f$until = Field( - 'until', - _$until, - opt: true, - ); - static List _$exceptions(AutoCalendarItem v) => v.exceptions; - static const Field> _f$exceptions = Field( - 'exceptions', - _$exceptions, - opt: true, - def: const [], - ); - static Uint8List? _$autoGroupId(AutoCalendarItem v) => v.autoGroupId; - static const Field _f$autoGroupId = Field( - 'autoGroupId', - _$autoGroupId, - opt: true, - ); - static DateTime? _$searchStart(AutoCalendarItem v) => v.searchStart; - static const Field _f$searchStart = Field( - 'searchStart', - _$searchStart, - opt: true, - ); - static int _$autoDuration(AutoCalendarItem v) => v.autoDuration; - static const Field _f$autoDuration = Field( - 'autoDuration', - _$autoDuration, - opt: true, - def: 60, - ); - - @override - final MappableFields fields = const { - #id: _f$id, - #name: _f$name, - #description: _f$description, - #location: _f$location, - #eventId: _f$eventId, - #status: _f$status, - #start: _f$start, - #end: _f$end, - #repeatType: _f$repeatType, - #interval: _f$interval, - #variation: _f$variation, - #count: _f$count, - #until: _f$until, - #exceptions: _f$exceptions, - #autoGroupId: _f$autoGroupId, - #searchStart: _f$searchStart, - #autoDuration: _f$autoDuration, - }; - - static AutoCalendarItem _instantiate(DecodingData data) { - return AutoCalendarItem( - id: data.dec(_f$id), - name: data.dec(_f$name), - description: data.dec(_f$description), - location: data.dec(_f$location), - eventId: data.dec(_f$eventId), - status: data.dec(_f$status), - start: data.dec(_f$start), - end: data.dec(_f$end), - repeatType: data.dec(_f$repeatType), - interval: data.dec(_f$interval), - variation: data.dec(_f$variation), - count: data.dec(_f$count), - until: data.dec(_f$until), - exceptions: data.dec(_f$exceptions), - autoGroupId: data.dec(_f$autoGroupId), - searchStart: data.dec(_f$searchStart), - autoDuration: data.dec(_f$autoDuration), - ); - } - - @override - final Function instantiate = _instantiate; - - static AutoCalendarItem fromMap(Map map) { - return ensureInitialized().decodeMap(map); - } - - static AutoCalendarItem fromJson(String json) { - return ensureInitialized().decodeJson(json); - } -} - -mixin AutoCalendarItemMappable { - String toJson() { - return AutoCalendarItemMapper.ensureInitialized() - .encodeJson(this as AutoCalendarItem); - } - - Map toMap() { - return AutoCalendarItemMapper.ensureInitialized() - .encodeMap(this as AutoCalendarItem); - } - - AutoCalendarItemCopyWith - get copyWith => - _AutoCalendarItemCopyWithImpl( - this as AutoCalendarItem, - $identity, - $identity, - ); - @override - String toString() { - return AutoCalendarItemMapper.ensureInitialized().stringifyValue( - this as AutoCalendarItem, - ); - } - - @override - bool operator ==(Object other) { - return AutoCalendarItemMapper.ensureInitialized().equalsValue( - this as AutoCalendarItem, - other, - ); - } - - @override - int get hashCode { - return AutoCalendarItemMapper.ensureInitialized().hashValue( - this as AutoCalendarItem, - ); - } -} - -extension AutoCalendarItemValueCopy<$R, $Out> - on ObjectCopyWith<$R, AutoCalendarItem, $Out> { - AutoCalendarItemCopyWith<$R, AutoCalendarItem, $Out> - get $asAutoCalendarItem => - $base.as((v, t, t2) => _AutoCalendarItemCopyWithImpl<$R, $Out>(v, t, t2)); -} - -abstract class AutoCalendarItemCopyWith<$R, $In extends AutoCalendarItem, $Out> - implements CalendarItemCopyWith<$R, $In, $Out> { - ListCopyWith<$R, int, ObjectCopyWith<$R, int, int>> get exceptions; - @override - $R call({ - Uint8List? id, - String? name, - String? description, - String? location, - Uint8List? eventId, - EventStatus? status, - DateTime? start, - DateTime? end, - RepeatType? repeatType, - int? interval, - int? variation, - int? count, - DateTime? until, - List? exceptions, - Uint8List? autoGroupId, - DateTime? searchStart, - int? autoDuration, - }); - AutoCalendarItemCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( - Then<$Out2, $R2> t, - ); -} - -class _AutoCalendarItemCopyWithImpl<$R, $Out> - extends ClassCopyWithBase<$R, AutoCalendarItem, $Out> - implements AutoCalendarItemCopyWith<$R, AutoCalendarItem, $Out> { - _AutoCalendarItemCopyWithImpl(super.value, super.then, super.then2); - - @override - late final ClassMapperBase $mapper = - AutoCalendarItemMapper.ensureInitialized(); - @override - ListCopyWith<$R, int, ObjectCopyWith<$R, int, int>> get exceptions => - ListCopyWith( - $value.exceptions, - (v, t) => ObjectCopyWith(v, $identity, t), - (v) => call(exceptions: v), - ); - @override - $R call({ - Object? id = $none, - String? name, - String? description, - String? location, - Object? eventId = $none, - EventStatus? status, - Object? start = $none, - Object? end = $none, - RepeatType? repeatType, - int? interval, - int? variation, - int? count, - Object? until = $none, - List? exceptions, - Object? autoGroupId = $none, - Object? searchStart = $none, - int? autoDuration, - }) => $apply( - FieldCopyWithData({ - if (id != $none) #id: id, - if (name != null) #name: name, - if (description != null) #description: description, - if (location != null) #location: location, - if (eventId != $none) #eventId: eventId, - if (status != null) #status: status, - if (start != $none) #start: start, - if (end != $none) #end: end, - if (repeatType != null) #repeatType: repeatType, - if (interval != null) #interval: interval, - if (variation != null) #variation: variation, - if (count != null) #count: count, - if (until != $none) #until: until, - if (exceptions != null) #exceptions: exceptions, - if (autoGroupId != $none) #autoGroupId: autoGroupId, - if (searchStart != $none) #searchStart: searchStart, - if (autoDuration != null) #autoDuration: autoDuration, - }), - ); - @override - AutoCalendarItem $make(CopyWithData data) => AutoCalendarItem( - id: data.get(#id, or: $value.id), - name: data.get(#name, or: $value.name), - description: data.get(#description, or: $value.description), - location: data.get(#location, or: $value.location), - eventId: data.get(#eventId, or: $value.eventId), - status: data.get(#status, or: $value.status), - start: data.get(#start, or: $value.start), - end: data.get(#end, or: $value.end), - repeatType: data.get(#repeatType, or: $value.repeatType), - interval: data.get(#interval, or: $value.interval), - variation: data.get(#variation, or: $value.variation), - count: data.get(#count, or: $value.count), - until: data.get(#until, or: $value.until), - exceptions: data.get(#exceptions, or: $value.exceptions), - autoGroupId: data.get(#autoGroupId, or: $value.autoGroupId), - searchStart: data.get(#searchStart, or: $value.searchStart), - autoDuration: data.get(#autoDuration, or: $value.autoDuration), - ); - - @override - AutoCalendarItemCopyWith<$R2, AutoCalendarItem, $Out2> $chain<$R2, $Out2>( - Then<$Out2, $R2> t, - ) => _AutoCalendarItemCopyWithImpl<$R2, $Out2>($value, $cast, t); -} - diff --git a/api/lib/models/event/model.mapper.dart b/api/lib/models/event/model.mapper.dart index 9291634de8e..3bb15645dfc 100644 --- a/api/lib/models/event/model.mapper.dart +++ b/api/lib/models/event/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/extra.mapper.dart b/api/lib/models/extra.mapper.dart index 9e2bedca036..fcc7fead9d3 100644 --- a/api/lib/models/extra.mapper.dart +++ b/api/lib/models/extra.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/group/model.mapper.dart b/api/lib/models/group/model.mapper.dart index 44706570a72..8988daf6c43 100644 --- a/api/lib/models/group/model.mapper.dart +++ b/api/lib/models/group/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/label/model.mapper.dart b/api/lib/models/label/model.mapper.dart index f9f616c3904..eb8ebb4edf1 100644 --- a/api/lib/models/label/model.mapper.dart +++ b/api/lib/models/label/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/model.mapper.dart b/api/lib/models/model.mapper.dart index 199d0796e14..36e2e4fcd61 100644 --- a/api/lib/models/model.mapper.dart +++ b/api/lib/models/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/note/database.dart b/api/lib/models/note/database.dart index 6a76a6e01f5..3187f6f94ac 100644 --- a/api/lib/models/note/database.dart +++ b/api/lib/models/note/database.dart @@ -403,15 +403,14 @@ abstract class NoteDatabaseServiceLinker extends NoteService with TableService { null, }, String search = '', - }) => - service.getNotes( - offset: offset, - limit: limit, - parent: parent, - notebook: notebook, - statuses: statuses, - search: search, - ); + }) => service.getNotes( + offset: offset, + limit: limit, + parent: parent, + notebook: notebook, + statuses: statuses, + search: search, + ); @override FutureOr createNote(Note note) => service.createNote(note); @@ -431,8 +430,7 @@ abstract class NoteDatabaseServiceLinker extends NoteService with TableService { int offset = 0, int limit = 50, String search = '', - }) => - service.getNotebooks(offset: offset, limit: limit, search: search); + }) => service.getNotebooks(offset: offset, limit: limit, search: search); @override FutureOr createNotebook(Notebook notebook) => @@ -451,4 +449,3 @@ abstract class NoteDatabaseServiceLinker extends NoteService with TableService { @override FutureOr clear() => service.clear(); } - diff --git a/api/lib/models/note/model.mapper.dart b/api/lib/models/note/model.mapper.dart index 436371fdab6..3064ca9ac31 100644 --- a/api/lib/models/note/model.mapper.dart +++ b/api/lib/models/note/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/resource/model.mapper.dart b/api/lib/models/resource/model.mapper.dart index 02f0911e0f9..d1cc6077088 100644 --- a/api/lib/models/resource/model.mapper.dart +++ b/api/lib/models/resource/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/models/user/model.mapper.dart b/api/lib/models/user/model.mapper.dart index 7db5f504540..1229d779108 100644 --- a/api/lib/models/user/model.mapper.dart +++ b/api/lib/models/user/model.mapper.dart @@ -2,6 +2,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // dart format off // ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member // ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member // ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter diff --git a/api/lib/services/database.dart b/api/lib/services/database.dart index c6f070c1f94..d1e08d816b7 100644 --- a/api/lib/services/database.dart +++ b/api/lib/services/database.dart @@ -38,7 +38,7 @@ typedef DatabaseFactory = FutureOr Function(Database, int)? onCreate, }); -const databaseVersion = 4; +const databaseVersion = 5; class DatabaseService extends SourceService { late final Database db; diff --git a/api/lib/services/migration.dart b/api/lib/services/migration.dart index 9d51687a0ff..56c7e87f1a7 100644 --- a/api/lib/services/migration.dart +++ b/api/lib/services/migration.dart @@ -96,4 +96,27 @@ Future migrateDatabase( }); await db.execute("PRAGMA foreign_keys=on"); } + + if (oldVersion < 5) { + await db.execute("PRAGMA foreign_keys=off"); + await db.transaction((txn) async { + await service.calendarItem.create(txn, 'calendarItems_temp_v5'); + await txn.execute( + "INSERT INTO calendarItems_temp_v5 " + "SELECT " + "CASE " + "WHEN runtimeType IN ('RepeatingCalendarItem', 'repeating', 'AutoCalendarItem', 'auto') THEN 'RepeatingCalendarItem' " + "ELSE 'FixedCalendarItem' " + "END, " + "id, name, description, location, eventId, start, end, " + "status, repeatType, interval, variation, count, until, exceptions " + "FROM calendarItems", + ); + await txn.execute("DROP TABLE calendarItems"); + await txn.execute( + "ALTER TABLE calendarItems_temp_v5 RENAME TO calendarItems", + ); + }); + await db.execute("PRAGMA foreign_keys=on"); + } } diff --git a/app/lib/api/storage/remote/caldav.dart b/app/lib/api/storage/remote/caldav.dart index 60f0e0b5012..c10bf792b3a 100644 --- a/app/lib/api/storage/remote/caldav.dart +++ b/app/lib/api/storage/remote/caldav.dart @@ -99,7 +99,8 @@ class NoteCalDavRemoteService extends NoteDatabaseServiceLinker { @override Future createNote(Note note) async { var notebookId = note.notebookId; - if (notebookId != null && await remote.note.getNotebook(notebookId) == null) { + if (notebookId != null && + await remote.note.getNotebook(notebookId) == null) { notebookId = (await remote.note.createNotebook( Notebook(name: note.name, id: notebookId), @@ -178,13 +179,16 @@ class CalendarItemCalDavRemoteService } } -Future _sendUpdatedCalendarObject(CalDavRemoteService remote, Uint8List id) async { +Future _sendUpdatedCalendarObject( + CalDavRemoteService remote, + Uint8List id, +) async { final authority = remote.remoteStorage.uri.replace(path: '').toString(); final items = (await remote.calendarItem.getCalendarItems( eventId: id, )).map((e) => e.source).toList(); final notes = await remote.note.getNotes(notebook: id, limit: 1000000); - + if (items.isEmpty && notes.isEmpty) { await remote.addRequest( APIRequest( diff --git a/app/lib/pages/calendar/day.dart b/app/lib/pages/calendar/day.dart index ec9d29d7c85..92e8b172919 100644 --- a/app/lib/pages/calendar/day.dart +++ b/app/lib/pages/calendar/day.dart @@ -477,8 +477,12 @@ class _SingleDayListState extends State { height = 24 * SingleDayList._hourHeight - top; } - final key = - '${position.appointment.source}@${position.appointment.main.id}'; + final key = [ + position.appointment.source, + position.appointment.main.id, + position.appointment.main.start?.millisecondsSinceEpoch, + position.appointment.main.end?.millisecondsSinceEpoch, + ].join('@'); if (_resizingItems.containsKey(key)) { height = max( _resizingItems[key]!.height, diff --git a/app/lib/pages/calendar/item.dart b/app/lib/pages/calendar/item.dart index 959233ce339..a6291581e6c 100644 --- a/app/lib/pages/calendar/item.dart +++ b/app/lib/pages/calendar/item.dart @@ -43,6 +43,13 @@ class _CalendarItemDialogState extends State { late String _source; CalendarItemService? _service; late CalendarItem _item; + late bool _isRepeating; + late RepeatType _repeatType; + late int _repeatInterval; + late int _repeatCount; + DateTime? _repeatUntil; + late Set _repeatWeeklyDays; + late Set _repeatMonthlyDays; @override void initState() { @@ -51,6 +58,92 @@ class _CalendarItemDialogState extends State { _source = widget.source ?? ''; _item = widget.item ?? FixedCalendarItem(eventId: widget.event?.id); _service = context.read().getService(_source).calendarItem; + if (_item is RepeatingCalendarItem) { + final repeating = _item as RepeatingCalendarItem; + _isRepeating = true; + _repeatType = repeating.repeatType; + _repeatInterval = repeating.interval; + _repeatCount = repeating.count; + _repeatUntil = repeating.until; + _repeatWeeklyDays = repeating.weeklyWeekdays.toSet(); + _repeatMonthlyDays = repeating.monthlyMonthDays.toSet(); + } else { + _isRepeating = false; + _repeatType = RepeatType.daily; + _repeatInterval = 1; + _repeatCount = 0; + _repeatUntil = null; + _repeatWeeklyDays = {}; + _repeatMonthlyDays = {}; + } + } + + String _repeatTypeLabel(BuildContext context, RepeatType type) { + return switch (type) { + RepeatType.daily => 'Daily', + RepeatType.weekly => 'Weekly', + RepeatType.monthly => 'Monthly', + RepeatType.yearly => 'Yearly', + }; + } + + String _weekdayLabel(int weekday) { + return const { + DateTime.monday: 'Mon', + DateTime.tuesday: 'Tue', + DateTime.wednesday: 'Wed', + DateTime.thursday: 'Thu', + DateTime.friday: 'Fri', + DateTime.saturday: 'Sat', + DateTime.sunday: 'Sun', + }[weekday]!; + } + + CalendarItem _buildPersistedItem() { + final start = _item.start; + if (!_isRepeating || + start == null || + _item.type == CalendarItemType.pending) { + return FixedCalendarItem( + id: _item.id, + name: _item.name, + description: _item.description, + location: _item.location, + eventId: _item.eventId, + start: _item.start, + end: _item.end, + status: _item.status, + ); + } + + final variation = switch (_repeatType) { + RepeatType.weekly => RepeatingCalendarItem.encodeWeeklyWeekdays( + _repeatWeeklyDays.isNotEmpty ? _repeatWeeklyDays : {start.weekday}, + ), + RepeatType.monthly => RepeatingCalendarItem.encodeMonthlyMonthDays( + _repeatMonthlyDays.isNotEmpty ? _repeatMonthlyDays : {start.day}, + ), + RepeatType.daily || RepeatType.yearly => 0, + }; + + return RepeatingCalendarItem( + id: _item.id, + name: _item.name, + description: _item.description, + location: _item.location, + eventId: _item.eventId, + start: _item.start, + end: _item.end, + status: _item.status, + repeatType: _repeatType, + interval: _repeatInterval, + variation: variation, + count: _repeatCount, + until: _repeatUntil, + exceptions: _item is RepeatingCalendarItem + ? (_item as RepeatingCalendarItem).exceptions + : const [], + ); } void _convertTo(CalendarItemType type) { @@ -72,6 +165,7 @@ class _CalendarItemDialogState extends State { break; case CalendarItemType.pending: _item = _item.copyWith(start: null, end: null); + _isRepeating = false; break; } }); @@ -409,6 +503,142 @@ class _CalendarItemDialogState extends State { canBeEmpty: true, ), ], + if (type != CalendarItemType.pending) ...[ + const SizedBox(height: 16), + CheckboxListTile( + title: const Text('Repeat'), + value: _isRepeating, + onChanged: (value) { + if (value == null) return; + setState(() { + _isRepeating = value; + }); + }, + ), + if (_isRepeating) ...[ + const SizedBox(height: 8), + DropdownMenu( + initialSelection: _repeatType, + dropdownMenuEntries: RepeatType.values + .map( + (value) => DropdownMenuEntry( + value: value, + label: _repeatTypeLabel(context, value), + ), + ) + .toList(), + onSelected: (value) { + if (value == null) return; + setState(() { + _repeatType = value; + }); + }, + label: const Text('Frequency'), + expandedInsets: const EdgeInsets.all(4), + ), + const SizedBox(height: 8), + TextFormField( + initialValue: _repeatInterval.toString(), + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Interval', + filled: true, + helperText: 'Repeat every n periods', + ), + onChanged: (value) { + final parsed = int.tryParse(value); + if (parsed == null || parsed < 1) return; + _repeatInterval = parsed; + }, + ), + if (_repeatType == RepeatType.weekly) ...[ + const SizedBox(height: 8), + const Text('Weekdays'), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(7, (index) { + final weekday = index + 1; + final selected = _repeatWeeklyDays.contains( + weekday, + ); + return FilterChip( + label: Text(_weekdayLabel(weekday)), + selected: selected, + onSelected: (value) { + setState(() { + if (value) { + _repeatWeeklyDays.add(weekday); + } else { + _repeatWeeklyDays.remove(weekday); + } + }); + }, + ); + }), + ), + ], + if (_repeatType == RepeatType.monthly) ...[ + const SizedBox(height: 8), + const Text('Month days'), + Wrap( + spacing: 6, + runSpacing: 6, + children: List.generate(31, (index) { + final day = index + 1; + final selected = _repeatMonthlyDays.contains( + day, + ); + return FilterChip( + label: Text('$day'), + selected: selected, + onSelected: (value) { + setState(() { + if (value) { + _repeatMonthlyDays.add(day); + } else { + _repeatMonthlyDays.remove(day); + } + }); + }, + ); + }), + ), + ], + const SizedBox(height: 8), + TextFormField( + initialValue: _repeatCount > 0 + ? _repeatCount.toString() + : '', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Occurrences (optional)', + filled: true, + helperText: 'Leave empty for no count limit', + ), + onChanged: (value) { + final parsed = int.tryParse(value); + _repeatCount = parsed == null || parsed < 1 + ? 0 + : parsed; + }, + ), + const SizedBox(height: 8), + DateTimeField( + label: 'Until (optional)', + initialValue: _repeatUntil, + icon: const PhosphorIcon( + PhosphorIconsLight.calendarCheck, + ), + onChanged: (value) { + _repeatUntil = value; + }, + canBeEmpty: true, + filled: true, + showTime: !isAllDay, + ), + ], + ], ], ), ), @@ -449,6 +679,7 @@ class _CalendarItemDialogState extends State { ), ElevatedButton( onPressed: () async { + _item = _buildPersistedItem(); if (_create) { final created = await _service?.createCalendarItem(_item); if (created == null) { diff --git a/app/lib/pages/calendar/month.dart b/app/lib/pages/calendar/month.dart index e0d7ab9ccf8..96b8e5e09ae 100644 --- a/app/lib/pages/calendar/month.dart +++ b/app/lib/pages/calendar/month.dart @@ -374,14 +374,20 @@ class CalendarDayDialog extends StatelessWidget { if (appointments.isEmpty) Center(child: Text(AppLocalizations.of(context).noEvents)) else - ...appointments.map( - (e) => CalendarListTile( - key: ValueKey('${e.source}@${e.main.id}'), + ...appointments.map((e) { + final occurrenceKey = [ + e.source, + e.main.id, + e.main.start?.millisecondsSinceEpoch, + e.main.end?.millisecondsSinceEpoch, + ]; + return CalendarListTile( + key: ValueKey(occurrenceKey), eventItem: e, date: date, onRefresh: () => Navigator.of(context).pop(), - ), - ), + ); + }), ], ), actions: [ diff --git a/app/lib/pages/calendar/pending.dart b/app/lib/pages/calendar/pending.dart index 5f72bb91541..30e2b5c468b 100644 --- a/app/lib/pages/calendar/pending.dart +++ b/app/lib/pages/calendar/pending.dart @@ -88,7 +88,12 @@ class _CalendarPendingViewState extends State { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1000), child: CalendarListTile( - key: ValueKey('${item.source}@${item.main.id}'), + key: ValueKey([ + item.source, + item.main.id, + item.main.start?.millisecondsSinceEpoch, + item.main.end?.millisecondsSinceEpoch, + ]), eventItem: item, onRefresh: _bloc.refresh, ),