From 1cac5afcc7f84efe6470e29b0709b23219540722 Mon Sep 17 00:00:00 2001 From: Alexis Choupault Date: Mon, 7 Apr 2025 14:27:06 +0200 Subject: [PATCH 1/4] created pigeon Event attribute rRule --- .../connect/tech/eventide/CalendarApi.g.kt | 12 +- .../connect/tech/eventide/CalendarImplem.kt | 33 ++- .../EventKitExtensionsTests.swift | 185 +++++++++++++++++ example/ios/EventideTests/EventTests.swift | 4 +- example/ios/Runner.xcodeproj/project.pbxproj | 4 + .../Sources/eventide/CalendarApi.g.swift | 11 +- .../Sources/eventide/CalendarImplem.swift | 1 + .../EasyEventStore/EasyEventStore.swift | 19 +- .../EasyEventStoreProtocol.swift | 11 +- .../EasyEventStore/EventKitExtensions.swift | 188 ++++++++++++++++++ lib/src/calendar_api.g.dart | 127 +++++------- lib/src/eventide.dart | 2 + lib/src/eventide_platform_interface.dart | 2 + pigeons/calendar_api.dart | 5 +- 14 files changed, 516 insertions(+), 88 deletions(-) create mode 100644 example/ios/EventideTests/EventKitExtensionsTests.swift create mode 100644 ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt index 3e9c8da..8e59dae 100644 --- a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt @@ -87,7 +87,8 @@ data class Event ( val reminders: List, val attendees: List, val description: String? = null, - val url: String? = null + val url: String? = null, + val rRule: String? = null ) { companion object { @@ -102,7 +103,8 @@ data class Event ( val attendees = pigeonVar_list[7] as List val description = pigeonVar_list[8] as String? val url = pigeonVar_list[9] as String? - return Event(id, calendarId, title, isAllDay, startDate, endDate, reminders, attendees, description, url) + val rRule = pigeonVar_list[10] as String? + return Event(id, calendarId, title, isAllDay, startDate, endDate, reminders, attendees, description, url, rRule) } } fun toList(): List { @@ -117,6 +119,7 @@ data class Event ( attendees, description, url, + rRule, ) } } @@ -227,7 +230,7 @@ interface CalendarApi { fun createCalendar(title: String, color: Long, localAccountName: String, callback: (Result) -> Unit) fun retrieveCalendars(onlyWritableCalendars: Boolean, fromLocalAccountName: String?, callback: (Result>) -> Unit) fun deleteCalendar(calendarId: String, callback: (Result) -> Unit) - fun createEvent(calendarId: String, title: String, startDate: Long, endDate: Long, isAllDay: Boolean, description: String?, url: String?, callback: (Result) -> Unit) + fun createEvent(calendarId: String, title: String, startDate: Long, endDate: Long, isAllDay: Boolean, description: String?, url: String?, rRule: String?, callback: (Result) -> Unit) fun retrieveEvents(calendarId: String, startDate: Long, endDate: Long, callback: (Result>) -> Unit) fun deleteEvent(eventId: String, callback: (Result) -> Unit) fun createReminder(reminder: Long, eventId: String, callback: (Result) -> Unit) @@ -336,7 +339,8 @@ interface CalendarApi { val isAllDayArg = args[4] as Boolean val descriptionArg = args[5] as String? val urlArg = args[6] as String? - api.createEvent(calendarIdArg, titleArg, startDateArg, endDateArg, isAllDayArg, descriptionArg, urlArg) { result: Result -> + val rRuleArg = args[7] as String? + api.createEvent(calendarIdArg, titleArg, startDateArg, endDateArg, isAllDayArg, descriptionArg, urlArg, rRuleArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(wrapError(error)) diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt index 6723607..2051bf3 100644 --- a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt @@ -4,15 +4,20 @@ import android.content.ContentResolver import android.content.ContentValues import android.net.Uri import android.provider.CalendarContract +import androidx.core.database.getStringOrNull import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.concurrent.CountDownLatch import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration class CalendarImplem( private var contentResolver: ContentResolver, @@ -283,6 +288,7 @@ class CalendarImplem( isAllDay: Boolean, description: String?, url: String?, + rRule: String?, callback: (Result) -> Unit ) { permissionHandler.requestWritePermission { granted -> @@ -295,9 +301,25 @@ class CalendarImplem( put(CalendarContract.Events.TITLE, title) put(CalendarContract.Events.DESCRIPTION, description) put(CalendarContract.Events.DTSTART, startDate) - put(CalendarContract.Events.DTEND, endDate) put(CalendarContract.Events.EVENT_TIMEZONE, "UTC") put(CalendarContract.Events.ALL_DAY, isAllDay) + + // FIXME: temporary + put(CalendarContract.Events.DTEND, endDate) + + if (rRule != null) { + // https://developer.android.com/reference/android/provider/CalendarContract.Events#operations + //val duration = endDate - startDate + //put(CalendarContract.Events.DURATION, duration) + + // https://stackoverflow.com/a/49515728/24891894 + if (!rRule.contains("COUNT=") && !rRule.contains("UNTIL=")) { + rRule.plus(";COUNT=1000") + } + put(CalendarContract.Events.RRULE, rRule.replace("RRULE:", "")) + } else { + //put(CalendarContract.Events.DTEND, endDate) + } } val eventUri = contentResolver.insert(eventContentUri, eventValues) @@ -393,6 +415,7 @@ class CalendarImplem( CalendarContract.Events.DTEND, CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.ALL_DAY, + CalendarContract.Events.RRULE, ) val selection = CalendarContract.Events.CALENDAR_ID + " = ? AND " + CalendarContract.Events.DTSTART + " >= ? AND " + CalendarContract.Events.DTEND + " <= ?" @@ -410,6 +433,7 @@ class CalendarImplem( val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) val end = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTEND)) val isAllDay = c.getInt(c.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)) == 1 + val rRule = c.getStringOrNull(c.getColumnIndexOrThrow(CalendarContract.Events.RRULE)) val attendees = mutableListOf() val attendeesLatch = CountDownLatch(1) @@ -441,14 +465,15 @@ class CalendarImplem( events.add( Event( id = id, + calendarId = calendarId, title = title, startDate = start, endDate = end, - calendarId = calendarId, + reminders = reminders, + attendees = attendees, description = description, isAllDay = isAllDay, - reminders = reminders, - attendees = attendees + rRule = rRule ) ) } diff --git a/example/ios/EventideTests/EventKitExtensionsTests.swift b/example/ios/EventideTests/EventKitExtensionsTests.swift new file mode 100644 index 0000000..2c374e2 --- /dev/null +++ b/example/ios/EventideTests/EventKitExtensionsTests.swift @@ -0,0 +1,185 @@ +// +// EventKitExtensionsTests.swift +// EventideTests +// +// Created by CHOUPAULT Alexis on 07/04/2025. +// + +import XCTest +import EventKit + +final class EventKitExtensionsTests: XCTestCase { + func test_emptyString() { + let rrule = "RRULE:" + + let recurrenceRule = EKRecurrenceRule(from: rrule) + + XCTAssert(recurrenceRule == nil) + } + + func test_invalidFreq() { + let rrule = "RRULE:FREQ=INVALID" + + let recurrenceRule = EKRecurrenceRule(from: rrule) + + XCTAssert(recurrenceRule == nil) + } + + func test_invalidInterval() { + let rrule = "RRULE:FREQ=DAILY;INTERVAL=0" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .daily) + XCTAssert(recurrenceRule.interval == 1) + } + + func test_invalidWeekday() { + let rrule = "RRULE:FREQ=WEEKLY;BYDAY=X" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .weekly) + XCTAssert(recurrenceRule.daysOfTheWeek!.isEmpty) + } + + func test_invalidWeekNo() { + let rrule = "RRULE:FREQ=MONTHLY;BYWEEKNO=89" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.weeksOfTheYear!.isEmpty) + } + + func test_invalidMonth() { + let rrule = "RRULE:FREQ=MONTHLY;BYMONTH=13" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.monthsOfTheYear!.isEmpty) + } + + func test_invalidMonthDay() { + let rrule = "RRULE:FREQ=MONTHLY;BYMONTHDAY=32" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.daysOfTheMonth!.isEmpty) + } + + func test_eachOtherDayUntil() { + let rrule = "RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20240101T000000Z" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmssZ" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + let expectedDate = dateFormatter.date(from: "20240101T000000Z")! + + XCTAssert(recurrenceRule.frequency == .daily) + XCTAssert(recurrenceRule.interval == 2) + XCTAssert(recurrenceRule.recurrenceEnd == EKRecurrenceEnd(end: expectedDate)) + } + + func test_eachDayCount() { + let rrule = "RRULE:FREQ=DAILY;COUNT=15" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .daily) + XCTAssert(recurrenceRule.recurrenceEnd == EKRecurrenceEnd(occurrenceCount: 15)) + } + + func test_eachMondayWednesdayFridayOfTheWeek() { + let rrule = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .weekly) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.monday))) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.wednesday))) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.friday))) + } + + func test_eachFirstMondaySecondWednesdayAndThirdFridayOfTheMonth() { + let rrule = "RRULE:FREQ=MONTHLY;BYDAY=1MO,2WE,3FR" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.monday, weekNumber: 1))) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.wednesday, weekNumber: 2))) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.friday, weekNumber: 3))) + } + + func test_eachFirstAndFifteenthDayOfEachOtherMonth() { + let rrule = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15;INTERVAL=2" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.interval == 2) + XCTAssert(recurrenceRule.daysOfTheMonth!.contains(1)) + XCTAssert(recurrenceRule.daysOfTheMonth!.contains(15)) + } + + func test_eachFifteenthOfMarch() { + let rrule = "RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .yearly) + XCTAssert(recurrenceRule.monthsOfTheYear!.contains(3)) + XCTAssert(recurrenceRule.daysOfTheMonth!.contains(15)) + } + + func test_lastSundayOfEachMonth() { + let rrule = "RRULE:FREQ=MONTHLY;BYDAY=SU;BYMONTHDAY=-1" + + guard let recurrenceRule = EKRecurrenceRule(from: rrule) else { + XCTFail("recurrenceRule should be instanciated") + return + } + + XCTAssert(recurrenceRule.frequency == .monthly) + XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.sunday))) + XCTAssert(recurrenceRule.daysOfTheMonth!.contains(-1)) + } +} diff --git a/example/ios/EventideTests/EventTests.swift b/example/ios/EventideTests/EventTests.swift index 6e1776a..a659f61 100644 --- a/example/ios/EventideTests/EventTests.swift +++ b/example/ios/EventideTests/EventTests.swift @@ -43,7 +43,8 @@ final class EventTests: XCTestCase { endDate: endDate, isAllDay: false, description: "description", - url: "url" + url: "url", + rRule: "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2" ) { createEventResult in switch (createEventResult) { case .success(let event): @@ -54,6 +55,7 @@ final class EventTests: XCTestCase { XCTAssert(event.description == "description") XCTAssert(event.url == "url") XCTAssert(event.isAllDay == false) + XCTAssert(event.) expectation.fulfill() case .failure: XCTFail("Event should have been created") diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 5e58adc..8bb7e8a 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ EA8AABCD2D8D889600A1D69B /* CalendarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8AABCC2D8D889600A1D69B /* CalendarTests.swift */; }; EA8AABCF2D8D88D600A1D69B /* PermissionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8AABCE2D8D88D600A1D69B /* PermissionHandlerTests.swift */; }; EA8AABD12D8D891A00A1D69B /* EventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8AABD02D8D891A00A1D69B /* EventTests.swift */; }; + EAE983EA2DA42D9B00C53390 /* EventKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE983E92DA42D9B00C53390 /* EventKitExtensionsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -67,6 +68,7 @@ EA8AABCC2D8D889600A1D69B /* CalendarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarTests.swift; sourceTree = ""; }; EA8AABCE2D8D88D600A1D69B /* PermissionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandlerTests.swift; sourceTree = ""; }; EA8AABD02D8D891A00A1D69B /* EventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTests.swift; sourceTree = ""; }; + EAE983E92DA42D9B00C53390 /* EventKitExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventKitExtensionsTests.swift; sourceTree = ""; }; EAECB9432D395CA3000FAA80 /* UtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -150,6 +152,7 @@ EA8AABCC2D8D889600A1D69B /* CalendarTests.swift */, EA8AABD02D8D891A00A1D69B /* EventTests.swift */, EA8AABCE2D8D88D600A1D69B /* PermissionHandlerTests.swift */, + EAE983E92DA42D9B00C53390 /* EventKitExtensionsTests.swift */, EAECB9432D395CA3000FAA80 /* UtilsTests.swift */, ); path = EventideTests; @@ -325,6 +328,7 @@ EA5CD6CF2D8C9A3B004CFD37 /* MockEasyEventStore.swift in Sources */, EA5CD6CE2D8C9A3B004CFD37 /* MockPermissionHandler.swift in Sources */, EA8AABCB2D8D884300A1D69B /* ReminderTests.swift in Sources */, + EAE983EA2DA42D9B00C53390 /* EventKitExtensionsTests.swift in Sources */, EA8AABD12D8D891A00A1D69B /* EventTests.swift in Sources */, EA8AABCD2D8D889600A1D69B /* CalendarTests.swift in Sources */, ); diff --git a/ios/eventide/Sources/eventide/CalendarApi.g.swift b/ios/eventide/Sources/eventide/CalendarApi.g.swift index 06f57af..43ff851 100644 --- a/ios/eventide/Sources/eventide/CalendarApi.g.swift +++ b/ios/eventide/Sources/eventide/CalendarApi.g.swift @@ -112,6 +112,7 @@ struct Event { var attendees: [Attendee] var description: String? = nil var url: String? = nil + var rRule: String? = nil // swift-format-ignore: AlwaysUseLowerCamelCase @@ -126,6 +127,7 @@ struct Event { let attendees = pigeonVar_list[7] as! [Attendee] let description: String? = nilOrValue(pigeonVar_list[8]) let url: String? = nilOrValue(pigeonVar_list[9]) + let rRule: String? = nilOrValue(pigeonVar_list[10]) return Event( id: id, @@ -137,7 +139,8 @@ struct Event { reminders: reminders, attendees: attendees, description: description, - url: url + url: url, + rRule: rRule ) } func toList() -> [Any?] { @@ -152,6 +155,7 @@ struct Event { attendees, description, url, + rRule, ] } } @@ -274,7 +278,7 @@ protocol CalendarApi { func createCalendar(title: String, color: Int64, localAccountName: String, completion: @escaping (Result) -> Void) func retrieveCalendars(onlyWritableCalendars: Bool, fromLocalAccountName: String?, completion: @escaping (Result<[Calendar], Error>) -> Void) func deleteCalendar(_ calendarId: String, completion: @escaping (Result) -> Void) - func createEvent(calendarId: String, title: String, startDate: Int64, endDate: Int64, isAllDay: Bool, description: String?, url: String?, completion: @escaping (Result) -> Void) + func createEvent(calendarId: String, title: String, startDate: Int64, endDate: Int64, isAllDay: Bool, description: String?, url: String?, rRule: String?, completion: @escaping (Result) -> Void) func retrieveEvents(calendarId: String, startDate: Int64, endDate: Int64, completion: @escaping (Result<[Event], Error>) -> Void) func deleteEvent(withId eventId: String, completion: @escaping (Result) -> Void) func createReminder(_ reminder: Int64, forEventId eventId: String, completion: @escaping (Result) -> Void) @@ -369,7 +373,8 @@ class CalendarApiSetup { let isAllDayArg = args[4] as! Bool let descriptionArg: String? = nilOrValue(args[5]) let urlArg: String? = nilOrValue(args[6]) - api.createEvent(calendarId: calendarIdArg, title: titleArg, startDate: startDateArg, endDate: endDateArg, isAllDay: isAllDayArg, description: descriptionArg, url: urlArg) { result in + let rRuleArg: String? = nilOrValue(args[7]) + api.createEvent(calendarId: calendarIdArg, title: titleArg, startDate: startDateArg, endDate: endDateArg, isAllDay: isAllDayArg, description: descriptionArg, url: urlArg, rRule: rRuleArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/ios/eventide/Sources/eventide/CalendarImplem.swift b/ios/eventide/Sources/eventide/CalendarImplem.swift index 1a4e3dd..432f8ed 100644 --- a/ios/eventide/Sources/eventide/CalendarImplem.swift +++ b/ios/eventide/Sources/eventide/CalendarImplem.swift @@ -103,6 +103,7 @@ class CalendarImplem: CalendarApi { isAllDay: Bool, description: String?, url: String?, + rRule: String?, completion: @escaping (Result ) -> Void) { permissionHandler.checkCalendarAccessThenExecute { [self] in diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift index 782ec87..2411cd0 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift @@ -94,7 +94,8 @@ final class EasyEventStore: EasyEventStoreProtocol { endDate: Date, isAllDay: Bool, description: String?, - url: String? + url: String?, + rRule: String? ) throws -> Event { let ekEvent = EKEvent(eventStore: eventStore) @@ -114,6 +115,22 @@ final class EasyEventStore: EasyEventStoreProtocol { ekEvent.timeZone = TimeZone(identifier: "UTC") ekEvent.isAllDay = isAllDay + if let rRule = rRule { + guard let recurrenceRule = EKRecurrenceRule(from: rRule) else { + throw PigeonError( + code: "GENERIC_ERROR", + message: "Unable to parse EKRecurrenceRule from rRule", + details: "rRule must respect RFC5545 format convention" + ) + } + + if (ekEvent.recurrenceRules == nil) { + ekEvent.recurrenceRules = [recurrenceRule] + } else { + ekEvent.recurrenceRules?.append(recurrenceRule) + } + } + if url != nil { ekEvent.url = URL(string: url!) } diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift index 89e17f3..64f141b 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift @@ -15,7 +15,16 @@ protocol EasyEventStoreProtocol { func deleteCalendar(calendarId: String) throws -> Void - func createEvent(calendarId: String, title: String, startDate: Date, endDate: Date, isAllDay: Bool, description: String?, url: String?) throws -> Event + func createEvent( + calendarId: String, + title: String, + startDate: Date, + endDate: Date, + isAllDay: Bool, + description: String?, + url: String?, + rRule: String? + ) throws -> Event func retrieveEvents(calendarId: String, startDate: Date, endDate: Date) throws -> [Event] diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift new file mode 100644 index 0000000..13fb48a --- /dev/null +++ b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift @@ -0,0 +1,188 @@ +// +// EventKitExtensions.swift +// eventide +// +// Created by CHOUPAULT Alexis on 07/04/2025. +// + +import EventKit + +public extension EKRecurrenceRule { + convenience init?(from rRule: String) { + let workableRRule: String + + if rRule.starts(with: "RRULE:") { + workableRRule = rRule.replacingOccurrences(of: "RRULE:", with: "") + } else { + workableRRule = rRule + } + + + let components = workableRRule.components(separatedBy: ";") + var frequency: EKRecurrenceFrequency? // FREQ + var interval: Int? // INTERVAL + var daysOfWeek: [EKRecurrenceDayOfWeek]? // BYDAY + var daysOfTheMonth: [NSNumber]? // BYMONTHDAY + var monthsOfTheYear: [NSNumber]? // BYMONTH + var weeksOfTheYear: [NSNumber]? // BYWEEKNO + var daysOfTheYear: [NSNumber]? // BYYEARDAY + var end: EKRecurrenceEnd? // UNTIL + + for component in components { + let keyValue = component.components(separatedBy: "=") + guard keyValue.count == 2 else { continue } + + let key = keyValue[0] + let value = keyValue[1] + + switch key { + case "FREQ": + frequency = Self.parseFrequency(value) + case "INTERVAL": + interval = Self.parseInterval(value) + case "BYDAY": + daysOfWeek = Self.parseDaysOfWeek(value) + case "BYMONTHDAY": + daysOfTheMonth = Self.parseString(value, in: -30...31) + case "BYMONTH": + monthsOfTheYear = Self.parseString(value, in: -11...12) + case "BYWEEKNO": + weeksOfTheYear = Self.parseString(value, in: -52...53) + case "BYYEARDAY": + daysOfTheYear = Self.parseString(value, in: -355...366) + case "UNTIL": + end = Self.parseRecurrenceEndDate(value) + case "COUNT": + end = Self.parseRecurrenceEndCount(value) + default: + break + } + } + + guard let validFrequency = frequency else { return nil } + self.init( + recurrenceWith: validFrequency, + interval: interval ?? 1, + daysOfTheWeek: daysOfWeek, + daysOfTheMonth: daysOfTheMonth, + monthsOfTheYear: monthsOfTheYear, + weeksOfTheYear: weeksOfTheYear, + daysOfTheYear: daysOfTheYear, + setPositions: nil, + end: end + ) + } + + static private func parseInterval(_ intervalString: String) -> Int { + guard let interval = Int(intervalString), interval > 0 else { + return 1 + } + + return interval + } + + static private func parseFrequency(_ freq: String) -> EKRecurrenceFrequency? { + switch freq.uppercased() { + case "DAILY": return .daily + case "WEEKLY": return .weekly + case "MONTHLY": return .monthly + case "YEARLY": return .yearly + default: return nil + } + } + + static private func parseDaysOfWeek(_ days: String) -> [EKRecurrenceDayOfWeek]? { + var daysOfWeek: [EKRecurrenceDayOfWeek] = [] + for dayString in days.components(separatedBy: ",") { + guard let (dayOfWeek, weekNumber) = parseDayOfWeekComponent(component: dayString) else { + continue + } + + if let weekNumber = weekNumber { + daysOfWeek.append(EKRecurrenceDayOfWeek(dayOfWeek, weekNumber: weekNumber)) + } else { + daysOfWeek.append(EKRecurrenceDayOfWeek(dayOfWeek)) + } + } + return daysOfWeek + } + + static private func parseDayOfWeekComponent(component: String) -> (dayOfWeek: EKWeekday, weekNumber: Int?)? { + do { + let regex = try NSRegularExpression(pattern: "([1-5]|-1)?((MO)|(TU)|(WE)|(TH)|(FR)|(SA)|(SU))") + let matches = regex.matches(in: component, range: NSRange(component.startIndex..., in: component)) + + guard let match = matches.first else { + return nil + } + + let weekNumberRange = match.range(at: 1) + let dayOfWeekRange = match.range(at: 2) + + let weekNumberString = (weekNumberRange.location != NSNotFound) ? String(component[Range(weekNumberRange, in: component)!]) : nil + let dayOfWeekString = String(component[Range(dayOfWeekRange, in: component)!]) + + let weekNumber = weekNumberString != nil ? Int(weekNumberString!) : nil + guard let dayOfWeek = dayOfWeekFromString(dayOfWeekString) else { + return nil + } + + return (dayOfWeek, weekNumber) + + } catch { + return nil + } + } + + static private func dayOfWeekFromString(_ day: String) -> EKWeekday? { + switch day.uppercased() { + case "SU": return EKWeekday.sunday + case "MO": return EKWeekday.monday + case "TU": return EKWeekday.tuesday + case "WE": return EKWeekday.wednesday + case "TH": return EKWeekday.thursday + case "FR": return EKWeekday.friday + case "SA": return EKWeekday.saturday + default: return nil + } + } + + static private func parseString(_ byString: String, in range: ClosedRange) -> [NSNumber] { + var values: [NSNumber] = [] + for number in byString.components(separatedBy: ",") { + guard let int = Int(number), range.contains(int) else { + continue + } + values.append(NSNumber(integerLiteral: int)) + } + return values + } + + static private func parseRecurrenceEndDate(_ untilString: String) -> EKRecurrenceEnd? { + guard !untilString.isEmpty else { + return nil + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + guard let untilDate = dateFormatter.date(from: untilString) else { + return nil + } + + return EKRecurrenceEnd(end: untilDate) + } + + static private func parseRecurrenceEndCount(_ countString: String) -> EKRecurrenceEnd? { + guard !countString.isEmpty else { + return nil + } + + guard let count = Int(countString) else { + return nil + } + + return EKRecurrenceEnd(occurrenceCount: count) + } +} diff --git a/lib/src/calendar_api.g.dart b/lib/src/calendar_api.g.dart index 5ba1239..b7f3dc9 100644 --- a/lib/src/calendar_api.g.dart +++ b/lib/src/calendar_api.g.dart @@ -68,6 +68,7 @@ class Event { required this.attendees, this.description, this.url, + this.rRule, }); String id; @@ -90,6 +91,8 @@ class Event { String? url; + String? rRule; + Object encode() { return [ id, @@ -102,6 +105,7 @@ class Event { attendees, description, url, + rRule, ]; } @@ -118,6 +122,7 @@ class Event { attendees: (result[7] as List?)!.cast(), description: result[8] as String?, url: result[9] as String?, + rRule: result[10] as String?, ); } } @@ -189,6 +194,7 @@ class Attendee { } } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -196,16 +202,16 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Calendar) { + } else if (value is Calendar) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is Event) { + } else if (value is Event) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is Account) { + } else if (value is Account) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is Attendee) { + } else if (value is Attendee) { buffer.putUint8(132); writeValue(buffer, value.encode()); } else { @@ -216,13 +222,13 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return Calendar.decode(readValue(buffer)!); - case 130: + case 130: return Event.decode(readValue(buffer)!); - case 131: + case 131: return Account.decode(readValue(buffer)!); - case 132: + case 132: return Attendee.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -244,15 +250,15 @@ class CalendarApi { final String pigeonVar_messageChannelSuffix; Future requestCalendarPermission() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.requestCalendarPermission$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.requestCalendarPermission$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -271,20 +277,16 @@ class CalendarApi { } } - Future createCalendar({ - required String title, - required int color, - required String localAccountName, - }) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.createCalendar$pigeonVar_messageChannelSuffix'; + Future createCalendar({required String title, required int color, required String localAccountName, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createCalendar$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([title, color, localAccountName]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -303,18 +305,16 @@ class CalendarApi { } } - Future> retrieveCalendars( - {required bool onlyWritableCalendars, required String? fromLocalAccountName}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.retrieveCalendars$pigeonVar_messageChannelSuffix'; + Future> retrieveCalendars({required bool onlyWritableCalendars, required String? fromLocalAccountName}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.retrieveCalendars$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = - pigeonVar_channel.send([onlyWritableCalendars, fromLocalAccountName]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final Future pigeonVar_sendFuture = pigeonVar_channel.send([onlyWritableCalendars, fromLocalAccountName]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -334,15 +334,15 @@ class CalendarApi { } Future deleteCalendar({required String calendarId}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.deleteCalendar$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteCalendar$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -356,25 +356,16 @@ class CalendarApi { } } - Future createEvent({ - required String calendarId, - required String title, - required int startDate, - required int endDate, - required bool isAllDay, - required String? description, - required String? url, - }) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.createEvent$pigeonVar_messageChannelSuffix'; + Future createEvent({required String calendarId, required String title, required int startDate, required int endDate, required bool isAllDay, required String? description, required String? url, required String? rRule, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createEvent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = - pigeonVar_channel.send([calendarId, title, startDate, endDate, isAllDay, description, url]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId, title, startDate, endDate, isAllDay, description, url, rRule]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -393,20 +384,16 @@ class CalendarApi { } } - Future> retrieveEvents({ - required String calendarId, - required int startDate, - required int endDate, - }) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.retrieveEvents$pigeonVar_messageChannelSuffix'; + Future> retrieveEvents({required String calendarId, required int startDate, required int endDate, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.retrieveEvents$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId, startDate, endDate]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -426,15 +413,15 @@ class CalendarApi { } Future deleteEvent({required String eventId}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.deleteEvent$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteEvent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -449,15 +436,15 @@ class CalendarApi { } Future createReminder({required int reminder, required String eventId}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.createReminder$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createReminder$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([reminder, eventId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -477,15 +464,15 @@ class CalendarApi { } Future deleteReminder({required int reminder, required String eventId}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.deleteReminder$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteReminder$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([reminder, eventId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -504,22 +491,16 @@ class CalendarApi { } } - Future createAttendee({ - required String eventId, - required String name, - required String email, - required int role, - required int type, - }) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.createAttendee$pigeonVar_messageChannelSuffix'; + Future createAttendee({required String eventId, required String name, required String email, required int role, required int type, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createAttendee$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId, name, email, role, type]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -539,15 +520,15 @@ class CalendarApi { } Future deleteAttendee({required String eventId, required String email}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.eventide.CalendarApi.deleteAttendee$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteAttendee$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId, email]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/lib/src/eventide.dart b/lib/src/eventide.dart index 0510dcd..306c9d6 100644 --- a/lib/src/eventide.dart +++ b/lib/src/eventide.dart @@ -130,6 +130,7 @@ class Eventide extends EventidePlatform { String? description, String? url, List? reminders, + String? rRule, }) async { try { final event = await _calendarApi.createEvent( @@ -140,6 +141,7 @@ class Eventide extends EventidePlatform { isAllDay: isAllDay, description: description, url: url, + rRule: rRule, ); if (reminders != null) { diff --git a/lib/src/eventide_platform_interface.dart b/lib/src/eventide_platform_interface.dart index 063ad37..d17b027 100644 --- a/lib/src/eventide_platform_interface.dart +++ b/lib/src/eventide_platform_interface.dart @@ -43,9 +43,11 @@ abstract class EventidePlatform extends PlatformInterface { required String title, required DateTime startDate, required DateTime endDate, + bool isAllDay = false, String? description, String? url, List? reminders, + String? rRule, }); Future> retrieveEvents({ diff --git a/pigeons/calendar_api.dart b/pigeons/calendar_api.dart index a934a93..c6c976e 100644 --- a/pigeons/calendar_api.dart +++ b/pigeons/calendar_api.dart @@ -43,6 +43,7 @@ abstract class CalendarApi { required bool isAllDay, required String? description, required String? url, + required String? rRule, }); @async @@ -115,6 +116,7 @@ final class Event { final List attendees; final String? description; final String? url; + final String? rRule; const Event({ required this.id, @@ -127,6 +129,7 @@ final class Event { required this.attendees, required this.description, required this.url, + required this.rRule, }); } @@ -154,4 +157,4 @@ final class Attendee { required this.type, required this.status, }); -} +} \ No newline at end of file From 85ed33a520c53446a9999deb3e6bd5d6c90aba94 Mon Sep 17 00:00:00 2001 From: Alexis Choupault Date: Wed, 9 Apr 2025 17:21:48 +0200 Subject: [PATCH 2/4] example app --- .../connect/tech/eventide/CalendarImplem.kt | 55 ++- example/lib/calendar/ui/calendar_screen.dart | 250 ++++++------ .../lib/event_details/ui/event_details.dart | 16 + .../event_list/logic/event_list_cubit.dart | 3 + example/lib/event_list/ui/event_form.dart | 355 +++++++++++------- example/lib/event_list/ui/event_list.dart | 182 ++++----- example/lib/main.dart | 1 + example/pubspec.lock | 24 ++ example/pubspec.yaml | 1 + lib/src/calendar_api.g.dart | 123 +++--- lib/src/eventide_platform_interface.dart | 6 +- lib/src/extensions/event_extensions.dart | 1 + pigeons/calendar_api.dart | 2 +- test/event_test.dart | 10 + 14 files changed, 635 insertions(+), 394 deletions(-) diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt index 2051bf3..73dba15 100644 --- a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt @@ -4,6 +4,7 @@ import android.content.ContentResolver import android.content.ContentValues import android.net.Uri import android.provider.CalendarContract +import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -14,7 +15,9 @@ import kotlinx.coroutines.withContext import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset +import java.time.ZonedDateTime import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.time.Duration @@ -304,13 +307,22 @@ class CalendarImplem( put(CalendarContract.Events.EVENT_TIMEZONE, "UTC") put(CalendarContract.Events.ALL_DAY, isAllDay) - // FIXME: temporary - put(CalendarContract.Events.DTEND, endDate) - if (rRule != null) { // https://developer.android.com/reference/android/provider/CalendarContract.Events#operations - //val duration = endDate - startDate - //put(CalendarContract.Events.DURATION, duration) + val durationInSeconds = (endDate - startDate) / 1000 + val days = durationInSeconds / (24 * 3600) + val hours = (durationInSeconds % (24 * 3600)) / 3600 + val minutes = (durationInSeconds % 3600) / 60 + val seconds = durationInSeconds % 60 + + val rfc2445Duration = "P" + + (if (days > 0) "${days}D" else "") + + "T" + + (if (hours > 0) "${hours}H" else "") + + (if (minutes > 0) "${minutes}M" else "") + + (if (seconds > 0) "${seconds}S" else "") + + put(CalendarContract.Events.DURATION, rfc2445Duration) // https://stackoverflow.com/a/49515728/24891894 if (!rRule.contains("COUNT=") && !rRule.contains("UNTIL=")) { @@ -318,7 +330,7 @@ class CalendarImplem( } put(CalendarContract.Events.RRULE, rRule.replace("RRULE:", "")) } else { - //put(CalendarContract.Events.DTEND, endDate) + put(CalendarContract.Events.DTEND, endDate) } } @@ -413,12 +425,14 @@ class CalendarImplem( CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DTSTART, CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.ALL_DAY, CalendarContract.Events.RRULE, ) val selection = - CalendarContract.Events.CALENDAR_ID + " = ? AND " + CalendarContract.Events.DTSTART + " >= ? AND " + CalendarContract.Events.DTEND + " <= ?" + CalendarContract.Events.CALENDAR_ID + " = ? AND " + CalendarContract.Events.DTSTART + " >= ?" + " AND " + + CalendarContract.Events.DTSTART + " <= ?" val selectionArgs = arrayOf(calendarId, startDate.toString(), endDate.toString()) val cursor = contentResolver.query(eventContentUri, projection, selection, selectionArgs, null) @@ -431,6 +445,7 @@ class CalendarImplem( val description = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION)) val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) + val duration = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.DURATION)) val end = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTEND)) val isAllDay = c.getInt(c.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)) == 1 val rRule = c.getStringOrNull(c.getColumnIndexOrThrow(CalendarContract.Events.RRULE)) @@ -462,18 +477,24 @@ class CalendarImplem( attendeesLatch.await() remindersLatch.await() + val dtEnd = if (end == 0L) { + start + rfc2445DurationToMillis(duration) + } else { + end + } + events.add( Event( id = id, calendarId = calendarId, title = title, startDate = start, - endDate = end, + endDate = dtEnd, reminders = reminders, attendees = attendees, description = description, isAllDay = isAllDay, - rRule = rRule + rRule = "RRULE:$rRule" ) ) } @@ -507,6 +528,22 @@ class CalendarImplem( } } + private fun rfc2445DurationToMillis(rfc2445Duration: String): Long { + val regex = Regex("P(?:(\\d+)D)?T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?") + val matchResult = regex.matchEntire(rfc2445Duration) + ?: throw IllegalArgumentException("Invalid RFC2445 duration format") + + val days = matchResult.groups[1]?.value?.toLong() ?: 0 + val hours = matchResult.groups[2]?.value?.toLong() ?: 0 + val minutes = matchResult.groups[3]?.value?.toLong() ?: 0 + val seconds = matchResult.groups[4]?.value?.toLong() ?: 0 + + return TimeUnit.DAYS.toMillis(days) + + TimeUnit.HOURS.toMillis(hours) + + TimeUnit.MINUTES.toMillis(minutes) + + TimeUnit.SECONDS.toMillis(seconds) + } + override fun deleteEvent(eventId: String, callback: (Result) -> Unit) { permissionHandler.requestWritePermission { granted -> if (granted) { diff --git a/example/lib/calendar/ui/calendar_screen.dart b/example/lib/calendar/ui/calendar_screen.dart index 3af95af..3239ae0 100644 --- a/example/lib/calendar/ui/calendar_screen.dart +++ b/example/lib/calendar/ui/calendar_screen.dart @@ -7,137 +7,149 @@ import 'package:eventide_example/calendar/ui/calendar_form.dart'; import 'package:eventide_example/event_list/logic/event_list_cubit.dart'; import 'package:value_state/value_state.dart'; -class CalendarScreen extends StatelessWidget { +class CalendarScreen extends StatefulWidget { const CalendarScreen({super.key}); + @override + State createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends State { + bool onlyWritableCalendars = true; + @override Widget build(BuildContext context) { - return SafeArea( - child: BlocBuilder>>(builder: (context, state) { - return Stack( - children: [ - CustomScrollView(slivers: [ - SliverAppBar( - pinned: true, - title: const Text('Calendar plugin example app'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Create calendar'), - content: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: CalendarForm( - onSubmit: (title, color) async { - await BlocProvider.of(context) - .createCalendar(title: title, color: color); - }, - ), + return Scaffold( + body: SafeArea( + child: BlocBuilder>>( + builder: (context, state) => Stack( + children: [ + CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + title: const Text('Eventide'), + actions: [ + Row( + children: [ + const Text('writable only'), + const SizedBox(width: 8), + Switch( + value: onlyWritableCalendars, + onChanged: (value) { + setState(() { + onlyWritableCalendars = value; + }); + BlocProvider.of(context).fetchCalendars(onlyWritable: value); + }, ), - ), - ); - }, + ], + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => + BlocProvider.of(context).fetchCalendars(onlyWritable: onlyWritableCalendars), + ), + ], ), - ], - ), - if (state case Value(:final data?)) - SliverList( - delegate: SliverChildListDelegate( - data - .map((calendar) => SizedBox( - height: 50, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: InkWell( - onTap: () async { - try { - await BlocProvider.of(context).selectCalendar(calendar); - if (context.mounted) { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const EventList()), - ); - } - } catch (error) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Error: ${error.toString()}'))); - } - } - }, - child: Row( - children: [ - Container( - color: calendar.color, - width: 16, - height: 16, + if (state case Value(:final data?)) + SliverList( + delegate: SliverChildListDelegate( + data + .map((calendar) => SizedBox( + height: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: InkWell( + onTap: () async { + try { + await BlocProvider.of(context).selectCalendar(calendar); + if (context.mounted) { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const EventList()), + ); + } + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: ${error.toString()}'))); + } + } + }, + child: Row( + children: [ + Container( + color: calendar.color, + width: 16, + height: 16, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + calendar.title, + maxLines: 3, + overflow: TextOverflow.fade, + ), + ), + const SizedBox(width: 16), + if (calendar.isWritable) + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + BlocProvider.of(context).deleteCalendar(calendar.id); + }, + ), + const SizedBox(width: 16), + const Icon(Icons.arrow_right), + ], ), - const SizedBox(width: 16), - Expanded( - child: Text( - calendar.title, - maxLines: 3, - overflow: TextOverflow.fade, - ), - ), - const SizedBox(width: 16), - if (calendar.isWritable) - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - BlocProvider.of(context).deleteCalendar(calendar.id); - }, - ), - const SizedBox(width: 16), - const Icon(Icons.arrow_right), - ], + ), ), - ), - ), - )) - .toList(), - ), - ), - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (state case Value(:final data?) when data.isEmpty) ...[ - const Text('No calendars found'), - const SizedBox(height: 16), - ], - if (state case Value(:final error?)) ...[ - Text('Error: ${error.toString()}'), - const SizedBox(height: 16), - ], - ], - ), - ), - ]), - Positioned( - right: 16, - bottom: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ElevatedButton( - onPressed: () => BlocProvider.of(context).fetchCalendars(onlyWritable: true), - child: const Text('Writable calendars'), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () => BlocProvider.of(context).fetchCalendars(onlyWritable: false), - child: const Text('All calendars'), + )) + .toList(), + ), + ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (state case Value(:final data?) when data.isEmpty) ...[ + const Text('No calendars found'), + const SizedBox(height: 16), + ], + if (state case Value(:final error?)) ...[ + Text('Error: ${error.toString()}'), + const SizedBox(height: 16), + ], + ], + ), ), ], ), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create calendar'), + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: CalendarForm( + onSubmit: (title, color) async { + await BlocProvider.of(context).createCalendar(title: title, color: color); + }, + ), + ), ), - ], - ); - }), + ); + }, + ), ); } } diff --git a/example/lib/event_details/ui/event_details.dart b/example/lib/event_details/ui/event_details.dart index 7cb3af6..eb734c5 100644 --- a/example/lib/event_details/ui/event_details.dart +++ b/example/lib/event_details/ui/event_details.dart @@ -6,6 +6,7 @@ import 'package:eventide_example/event_details/logic/event_details_cubit.dart'; import 'package:eventide_example/event_details/ui/attendee_form.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rrule/rrule.dart'; import 'package:value_state/value_state.dart'; class EventDetails extends StatelessWidget { @@ -40,6 +41,21 @@ class EventDetails extends StatelessWidget { if (event.description != null) Text(event.description!), Text("${event.startDate.toString()} -> ${event.endDate.toString()}"), if (event.url != null) Text(event.url!), + if (event.rRule != null) ...[ + const Divider(), + const Text('Recurrence Rule', style: TextStyle(fontWeight: FontWeight.bold)), + FutureBuilder( + future: RruleL10nEn.create(), + builder: (context, snapshot) { + final l10n = snapshot.data; + if (l10n != null) { + return Text(RecurrenceRule.fromString(event.rRule!).toText(l10n: snapshot.data!)); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], const Divider(), Row( children: [ diff --git a/example/lib/event_list/logic/event_list_cubit.dart b/example/lib/event_list/logic/event_list_cubit.dart index 862a9a0..62ae04e 100644 --- a/example/lib/event_list/logic/event_list_cubit.dart +++ b/example/lib/event_list/logic/event_list_cubit.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:eventide_example/event_list/logic/event_list_state.dart'; import 'package:eventide/eventide.dart'; +import 'package:rrule/rrule.dart'; import 'package:timezone/timezone.dart'; import 'package:value_state/value_state.dart'; @@ -18,6 +19,7 @@ class EventListCubit extends Cubit { required bool isAllDay, required TZDateTime startDate, required TZDateTime endDate, + required RecurrenceRule? rRule, }) async { if (state case Value(:final data?)) { await state.fetchFrom(() async { @@ -28,6 +30,7 @@ class EventListCubit extends Cubit { startDate: startDate, endDate: endDate, calendarId: data.calendar.id, + rRule: rRule.toString(), ); return EventValue( calendar: data.calendar, diff --git a/example/lib/event_list/ui/event_form.dart b/example/lib/event_list/ui/event_form.dart index f12e87c..94934d2 100644 --- a/example/lib/event_list/ui/event_form.dart +++ b/example/lib/event_list/ui/event_form.dart @@ -1,12 +1,52 @@ import 'package:flutter/material.dart'; import 'package:timezone/timezone.dart'; +import 'package:rrule/rrule.dart'; +final _supportedLocations = [ + 'Europe/Paris', + 'America/Los_Angeles', + 'America/Montreal', + 'Asia/Beirut', +]; + +final _recurrenceRules = { + 'daily': RecurrenceRule( + frequency: Frequency.daily, + interval: 1, + ), + 'sun./2weeks dec.': RecurrenceRule( + frequency: Frequency.weekly, + interval: 2, + byMonths: [12], + byWeekDays: [ + ByWeekDayEntry(DateTime.sunday), + ], + ), + 'weekly': RecurrenceRule( + frequency: Frequency.weekly, + interval: 1, + ), + 'every two weeks': RecurrenceRule( + frequency: Frequency.weekly, + interval: 2, + ), + 'monthly': RecurrenceRule( + frequency: Frequency.monthly, + interval: 1, + ), + '1st wed./month': RecurrenceRule.fromString("RRULE:FREQ=MONTHLY;BYDAY=WE;BYMONTHDAY=1,2,3,4,5,6,-1"), + 'yearly': RecurrenceRule( + frequency: Frequency.yearly, + interval: 1, + ), +}; typedef OnEventFormSubmit = void Function( String title, String description, bool isAllDay, TZDateTime startDate, TZDateTime endDate, + RecurrenceRule? rRule, ); class EventForm extends StatefulWidget { @@ -24,15 +64,26 @@ class EventForm extends StatefulWidget { class _EventFormState extends State { late final TextEditingController _titleController; late final TextEditingController _descriptionController; - DateTime _selectedStartDate = DateTime.now(); - DateTime _selectedEndDate = DateTime.now().add(const Duration(hours: 1)); - bool isAllDay = false; + late String _startLocation; + late String _endLocation; + late TZDateTime _selectedStartDate; + late TZDateTime _selectedEndDate; + late bool _isAllDay; + RecurrenceRule? _recurrenceRule; @override void initState() { super.initState(); _titleController = TextEditingController(); _descriptionController = TextEditingController(); + + _startLocation = _supportedLocations.first; + _endLocation = _supportedLocations.first; + + _selectedStartDate = TZDateTime.now(getLocation(_startLocation)); + _selectedEndDate = TZDateTime.now(getLocation(_endLocation)).add(const Duration(hours: 1)); + + _isAllDay = false; } @override @@ -44,175 +95,225 @@ class _EventFormState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Event title', - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Event description', + return SingleChildScrollView( + child: Column( + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'title', + ), ), - ), - const SizedBox(height: 16), - Row( - children: [ - const Expanded(child: Text('isAllDay')), - Expanded( - child: Switch( - value: isAllDay, - onChanged: (value) => setState(() { - isAllDay = value; - }), - ), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'description', ), - ], - ), - const SizedBox(height: 16), - Text('Start date: ${_selectedStartDate.toLocal()}'), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () async { - final lastDate = _selectedEndDate; - final pickedDate = await showDatePicker( - context: context, - initialDate: _selectedStartDate, - firstDate: DateTime.now(), - lastDate: lastDate, - ); - - if (pickedDate != null) { - final timeOfDay = TimeOfDay.fromDateTime(_selectedStartDate); - final duration = Duration(hours: timeOfDay.hour, minutes: timeOfDay.minute); - - setState(() { - _selectedStartDate = pickedDate.add(duration); - }); - } - }, - child: const Icon(Icons.calendar_month), + ), + const SizedBox(height: 8), + Row( + children: [ + const Expanded(child: Text('isAllDay')), + Expanded( + child: Switch( + value: _isAllDay, + onChanged: (value) => setState(() { + _isAllDay = value; + }), + ), ), - ), - if (!isAllDay) ...[ - const SizedBox(width: 16), + ], + ), + const SizedBox(height: 8), + Text('Start date: ${_selectedStartDate.toIso8601String()}'), + DropdownButton( + value: _startLocation, + items: _supportedLocations.map((location) { + return DropdownMenuItem( + value: location, + child: Text(location), + ); + }).toList(), + onChanged: (newValue) { + if (newValue == null) return; + setState(() { + _startLocation = newValue; + _selectedStartDate = TZDateTime.from(_selectedStartDate, getLocation(newValue)); + if (_selectedEndDate.toUtc().isBefore(_selectedStartDate.toUtc())) { + _selectedEndDate = TZDateTime.from(_selectedStartDate, getLocation(_endLocation)); + } + }); + }, + ), + Row( + children: [ Expanded( child: ElevatedButton( onPressed: () async { - final timeOfDay = await showTimePicker( + final pickedDate = await showDatePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_selectedStartDate), + initialDate: _selectedStartDate, + firstDate: TZDateTime.now(getLocation(_startLocation)), + lastDate: TZDateTime.now(getLocation(_startLocation)).add(Duration(days: 365)), ); - if (timeOfDay != null) { + if (pickedDate != null) { + final timeOfDay = TimeOfDay.fromDateTime(_selectedStartDate); + setState(() { - _selectedStartDate = (_selectedStartDate).copyWith(time: timeOfDay); + _selectedStartDate = + TZDateTime.from(pickedDate, getLocation(_startLocation)).copyWith(time: timeOfDay); }); } }, - child: const Icon(Icons.access_time), + child: const Icon(Icons.calendar_month), ), ), + if (!_isAllDay) ...[ + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () async { + final timeOfDay = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_selectedStartDate), + ); + + if (timeOfDay != null) { + setState(() { + _selectedStartDate = _selectedStartDate.copyWith(time: timeOfDay); + }); + } + }, + child: const Icon(Icons.access_time), + ), + ), + ], ], - ], - ), - const SizedBox(height: 16), - Text('End date: ${_selectedEndDate.toLocal()}'), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () async { - final firstDate = _selectedStartDate; - final pickedDate = await showDatePicker( - context: context, - initialDate: _selectedEndDate, - firstDate: firstDate, - lastDate: firstDate.add(const Duration(days: 365)), - ); - - if (pickedDate != null) { - final timeOfDay = TimeOfDay.fromDateTime(_selectedEndDate); - final duration = Duration(hours: timeOfDay.hour, minutes: timeOfDay.minute); - - setState(() { - _selectedEndDate = pickedDate.add(duration); - }); - } - }, - child: const Icon(Icons.calendar_month), - ), - ), - if (!isAllDay) ...[ - const SizedBox(width: 16), + ), + const SizedBox(height: 8), + Text('End date: ${_selectedEndDate.toIso8601String()}'), + DropdownButton( + value: _endLocation, + items: _supportedLocations.map((location) { + return DropdownMenuItem( + value: location, + child: Text(location), + ); + }).toList(), + onChanged: (newValue) { + if (newValue == null) return; + setState(() { + _endLocation = newValue; + _selectedEndDate = TZDateTime.from(_selectedEndDate, getLocation(newValue)); + }); + }, + ), + Row( + children: [ Expanded( child: ElevatedButton( onPressed: () async { - final timeOfDay = await showTimePicker( + final firstDate = TZDateTime.from(_selectedStartDate, getLocation(_endLocation)); + final TZDateTime initialDate; + if (_selectedStartDate.isAfter(_selectedEndDate)) { + initialDate = firstDate; + } else { + initialDate = _selectedEndDate; + } + + final pickedDate = await showDatePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_selectedEndDate), + initialDate: initialDate, + firstDate: firstDate, + lastDate: firstDate.add(const Duration(days: 365)), ); - if (timeOfDay != null) { + if (pickedDate != null) { + final timeOfDay = TimeOfDay.fromDateTime(_selectedEndDate); + setState(() { - _selectedEndDate = (_selectedEndDate).copyWith(time: timeOfDay); + _selectedEndDate = + TZDateTime.from(pickedDate, getLocation(_endLocation)).copyWith(time: timeOfDay); }); } }, - child: const Icon(Icons.access_time), + child: const Icon(Icons.calendar_month), ), ), + if (!_isAllDay) ...[ + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () async { + final timeOfDay = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_selectedEndDate), + ); + + if (timeOfDay != null) { + setState(() { + _selectedEndDate = (_selectedEndDate).copyWith(time: timeOfDay); + }); + } + }, + child: const Icon(Icons.access_time), + ), + ), + ], ], - ], - ), - if (!isAllDay) ...[ - const SizedBox(height: 16), + ), + const SizedBox(height: 8), + const Text('Recurrence rule'), + DropdownButton( + value: _recurrenceRule, + items: [ + DropdownMenuItem(value: null, child: Text('None')), + ..._recurrenceRules.entries.map((entry) { + return DropdownMenuItem( + value: entry.value, + child: Text( + entry.key, + overflow: TextOverflow.ellipsis, + ), + ); + }), + ], + onChanged: (newValue) { + setState(() { + _recurrenceRule = newValue; + }); + }, + ), + const SizedBox(height: 8), ElevatedButton( onPressed: () { widget.onSubmit( - 'Paris - Montreal', + _titleController.text, _descriptionController.text, - false, - TZDateTime(getLocation('Europe/Paris'), 2025, 9, 8, 13, 30), - TZDateTime(getLocation('America/Montreal'), 2025, 9, 8, 15, 00), + _isAllDay, + TZDateTime.from(_selectedStartDate, getLocation('Europe/Paris')), + TZDateTime.from(_selectedEndDate, getLocation('Europe/Paris')), + _recurrenceRule, ); Navigator.of(context).pop(); }, - child: const Text('Create event in different timezones'), + child: const Text('Create event'), ), ], - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - widget.onSubmit( - _titleController.text, - _descriptionController.text, - isAllDay, - TZDateTime.from(_selectedStartDate, getLocation('Europe/Paris')), - TZDateTime.from(_selectedEndDate, getLocation('Europe/Paris')), - ); - - Navigator.of(context).pop(); - }, - child: const Text('Create event'), - ), - ], + ), ); } } -extension on DateTime { - DateTime copyWith({ +extension on TZDateTime { + TZDateTime copyWith({ required TimeOfDay time, }) => - DateTime( + TZDateTime( + location, year, month, day, diff --git a/example/lib/event_list/ui/event_list.dart b/example/lib/event_list/ui/event_list.dart index 642fff6..4d1174b 100644 --- a/example/lib/event_list/ui/event_list.dart +++ b/example/lib/event_list/ui/event_list.dart @@ -11,106 +11,112 @@ class EventList extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: BlocBuilder(builder: (context, state) { - return CustomScrollView( - slivers: [ - SliverAppBar( - pinned: true, - title: Text(state.data?.calendar.title ?? ''), - actions: [ - if (state.data?.calendar.isWritable ?? false) - IconButton( - icon: const Icon(Icons.add), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Create event'), - content: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: EventForm( - onSubmit: (title, description, isAllDay, startDate, endDate) { - BlocProvider.of(context).createEvent( - title: title, - description: description, - isAllDay: isAllDay, - startDate: startDate, - endDate: endDate, - ); - }, + return BlocBuilder( + builder: (context, state) { + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + title: Text(state.data?.calendar.title ?? ''), + ), + if (state case Value(:final data?) when data.events.isNotEmpty) + SliverList( + delegate: SliverChildListDelegate([ + for (final event in data.events..sort((a, b) => a.id.compareTo(b.id))) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => EventDetails( + event: event, + isCalendarWritable: state.data?.calendar.isWritable ?? false, ), ), - ); - }, - ); - }, - ), - ], - ), - if (state case Value(:final data?) when data.events.isNotEmpty) - SliverList( - delegate: SliverChildListDelegate([ - for (final event in data.events..sort((a, b) => a.id.compareTo(b.id))) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => EventDetails( - event: event, - isCalendarWritable: state.data?.calendar.isWritable ?? false, - ), ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.title, - style: const TextStyle(fontWeight: FontWeight.w700), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (event.description != null) + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - event.description!, + event.title, + style: const TextStyle(fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis, ), - ], + if (event.description != null) + Text( + event.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - Expanded( - child: Column( - children: [ - Text(event.startDate.toFormattedString()), - Text(event.endDate.toFormattedString()), - ], + Expanded( + child: Column( + children: [ + Text(event.startDate.toFormattedString()), + Text(event.endDate.toFormattedString()), + ], + ), ), - ), - ], + if (data.calendar.isWritable) + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + context.read().deleteEvent(event.id); + }, + ), + ], + ), ), ), - ), - ]), - ), - if (state case Value(:final data?) when data.events.isEmpty) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: Text('No events found'), + ]), ), - ), - ], - ); - }), - ), + if (state case Value(:final data?) when data.events.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('No events found'), + ), + ), + ], + ), + ), + floatingActionButton: (state.data?.calendar.isWritable ?? false) + ? FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Create event'), + content: EventForm( + onSubmit: (title, description, isAllDay, startDate, endDate, rRule) { + BlocProvider.of(context).createEvent( + title: title, + description: description, + isAllDay: isAllDay, + startDate: startDate, + endDate: endDate, + rRule: rRule, + ); + }, + ), + ); + }, + ); + }, + ) + : null, + ); + }, ); } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 266d5f4..e26673b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -32,6 +32,7 @@ class MyApp extends StatelessWidget { ), ], child: const MaterialApp( + debugShowCheckedModeBanner: false, home: Scaffold( body: CalendarScreen(), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 48b5e8b..bb116b1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -122,6 +122,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -210,6 +218,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + rrule: + dependency: "direct main" + description: + name: rrule + sha256: b7425410c594d4b6717c9f17ec8ef83c9d1ff2e513c428a135b5924fc2e8e045 + url: "https://pub.dev" + source: hosted + version: "0.2.17" sky_engine: dependency: transitive description: flutter @@ -271,6 +287,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + time: + dependency: transitive + description: + name: time + sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + url: "https://pub.dev" + source: hosted + version: "2.1.5" timezone: dependency: "direct main" description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 78f48dd..e50e4b9 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: value_state: ^2.0.1 timezone: ^0.10.0 provider: ^6.1.2 + rrule: ^0.2.17 dev_dependencies: flutter_test: diff --git a/lib/src/calendar_api.g.dart b/lib/src/calendar_api.g.dart index b7f3dc9..2f07ff2 100644 --- a/lib/src/calendar_api.g.dart +++ b/lib/src/calendar_api.g.dart @@ -194,7 +194,6 @@ class Attendee { } } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -202,16 +201,16 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Calendar) { + } else if (value is Calendar) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is Event) { + } else if (value is Event) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is Account) { + } else if (value is Account) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is Attendee) { + } else if (value is Attendee) { buffer.putUint8(132); writeValue(buffer, value.encode()); } else { @@ -222,13 +221,13 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return Calendar.decode(readValue(buffer)!); - case 130: + case 130: return Event.decode(readValue(buffer)!); - case 131: + case 131: return Account.decode(readValue(buffer)!); - case 132: + case 132: return Attendee.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -250,15 +249,15 @@ class CalendarApi { final String pigeonVar_messageChannelSuffix; Future requestCalendarPermission() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.requestCalendarPermission$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.requestCalendarPermission$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -277,16 +276,20 @@ class CalendarApi { } } - Future createCalendar({required String title, required int color, required String localAccountName, }) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createCalendar$pigeonVar_messageChannelSuffix'; + Future createCalendar({ + required String title, + required int color, + required String localAccountName, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.createCalendar$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([title, color, localAccountName]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -305,16 +308,18 @@ class CalendarApi { } } - Future> retrieveCalendars({required bool onlyWritableCalendars, required String? fromLocalAccountName}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.retrieveCalendars$pigeonVar_messageChannelSuffix'; + Future> retrieveCalendars( + {required bool onlyWritableCalendars, required String? fromLocalAccountName}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.retrieveCalendars$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([onlyWritableCalendars, fromLocalAccountName]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([onlyWritableCalendars, fromLocalAccountName]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -334,15 +339,15 @@ class CalendarApi { } Future deleteCalendar({required String calendarId}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteCalendar$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.deleteCalendar$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -356,16 +361,26 @@ class CalendarApi { } } - Future createEvent({required String calendarId, required String title, required int startDate, required int endDate, required bool isAllDay, required String? description, required String? url, required String? rRule, }) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createEvent$pigeonVar_messageChannelSuffix'; + Future createEvent({ + required String calendarId, + required String title, + required int startDate, + required int endDate, + required bool isAllDay, + required String? description, + required String? url, + required String? rRule, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.createEvent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId, title, startDate, endDate, isAllDay, description, url, rRule]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([calendarId, title, startDate, endDate, isAllDay, description, url, rRule]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -384,16 +399,20 @@ class CalendarApi { } } - Future> retrieveEvents({required String calendarId, required int startDate, required int endDate, }) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.retrieveEvents$pigeonVar_messageChannelSuffix'; + Future> retrieveEvents({ + required String calendarId, + required int startDate, + required int endDate, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.retrieveEvents$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId, startDate, endDate]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -413,15 +432,15 @@ class CalendarApi { } Future deleteEvent({required String eventId}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteEvent$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.deleteEvent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -436,15 +455,15 @@ class CalendarApi { } Future createReminder({required int reminder, required String eventId}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createReminder$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.createReminder$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([reminder, eventId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -464,15 +483,15 @@ class CalendarApi { } Future deleteReminder({required int reminder, required String eventId}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteReminder$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.deleteReminder$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([reminder, eventId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -491,16 +510,22 @@ class CalendarApi { } } - Future createAttendee({required String eventId, required String name, required String email, required int role, required int type, }) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.createAttendee$pigeonVar_messageChannelSuffix'; + Future createAttendee({ + required String eventId, + required String name, + required String email, + required int role, + required int type, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.createAttendee$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId, name, email, role, type]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -520,15 +545,15 @@ class CalendarApi { } Future deleteAttendee({required String eventId, required String email}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteAttendee$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.eventide.CalendarApi.deleteAttendee$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId, email]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/lib/src/eventide_platform_interface.dart b/lib/src/eventide_platform_interface.dart index d17b027..85f41f4 100644 --- a/lib/src/eventide_platform_interface.dart +++ b/lib/src/eventide_platform_interface.dart @@ -152,6 +152,7 @@ final class ETEvent { final List attendees; final String? description; final String? url; + final String? rRule; @override int get hashCode => Object.hashAll([ @@ -165,6 +166,7 @@ final class ETEvent { ...attendees, description, url, + rRule, ]); const ETEvent({ @@ -178,6 +180,7 @@ final class ETEvent { this.attendees = const [], this.description, this.url, + this.rRule, }); @override @@ -194,7 +197,8 @@ final class ETEvent { listEquals(other.reminders, reminders) && listEquals(other.attendees, attendees) && other.description == description && - other.url == url; + other.url == url && + other.rRule == rRule; } /// Represents an account. diff --git a/lib/src/extensions/event_extensions.dart b/lib/src/extensions/event_extensions.dart index d37ce4d..6b08f95 100644 --- a/lib/src/extensions/event_extensions.dart +++ b/lib/src/extensions/event_extensions.dart @@ -16,6 +16,7 @@ extension EventToETEvent on Event { url: url, reminders: reminders.toDurationList(), attendees: attendees.toETAttendeeList(), + rRule: rRule, ); } } diff --git a/pigeons/calendar_api.dart b/pigeons/calendar_api.dart index c6c976e..e6e9b10 100644 --- a/pigeons/calendar_api.dart +++ b/pigeons/calendar_api.dart @@ -157,4 +157,4 @@ final class Attendee { required this.type, required this.status, }); -} \ No newline at end of file +} diff --git a/test/event_test.dart b/test/event_test.dart index e3c0f58..8aa1f20 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -64,6 +64,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).thenAnswer((_) async => event); // When @@ -85,6 +86,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).called(1); }); @@ -98,6 +100,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).thenThrow(ETGenericException(message: 'API Error')); // When @@ -118,6 +121,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).called(1); }); @@ -141,6 +145,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).thenAnswer((_) async => event); when(() => mockCalendarApi.createReminder(reminder: any(named: 'reminder'), eventId: any(named: 'eventId'))) .thenAnswer((_) async => event.copyWithReminders(reminders.toNativeList())); @@ -164,6 +169,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).called(1); verify(() => mockCalendarApi.createReminder(reminder: 10 * 60, eventId: event.id)).called(1); verify(() => mockCalendarApi.createReminder(reminder: 20 * 60, eventId: event.id)).called(1); @@ -190,6 +196,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).thenAnswer((_) async => event); when(() => mockCalendarApi.createReminder(reminder: any(named: 'reminder'), eventId: any(named: 'eventId'))) .thenAnswer((_) async => event.copyWithReminders(reminders.toNativeList())); @@ -213,6 +220,7 @@ void main() { calendarId: any(named: 'calendarId'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).called(1); verify(() => mockCalendarApi.createReminder(reminder: 10, eventId: event.id)).called(1); verify(() => mockCalendarApi.createReminder(reminder: 20, eventId: event.id)).called(1); @@ -261,6 +269,7 @@ void main() { isAllDay: any(named: 'isAllDay'), description: any(named: 'description'), url: any(named: 'url'), + rRule: any(named: 'rRule'), )).thenAnswer((_) async => mockEvent); await eventide.createEvent( @@ -278,6 +287,7 @@ void main() { isAllDay: false, description: null, url: null, + rRule: null, )).called(1); }); From d4a5e4f8575fa89dd0b8388cf836a57c5a05f709 Mon Sep 17 00:00:00 2001 From: Alexis Choupault Date: Tue, 22 Apr 2025 16:57:18 +0200 Subject: [PATCH 3/4] ios ok --- .../EventKitExtensionsTests.swift | 126 ++++++++++++++++++ example/ios/EventideTests/EventTests.swift | 11 +- .../Mocks/MockEasyEventStore.swift | 24 +++- .../Sources/eventide/CalendarImplem.swift | 3 +- .../EasyEventStore/EasyEventStore.swift | 3 +- .../EasyEventStore/EventKitExtensions.swift | 61 +++++++++ 6 files changed, 218 insertions(+), 10 deletions(-) diff --git a/example/ios/EventideTests/EventKitExtensionsTests.swift b/example/ios/EventideTests/EventKitExtensionsTests.swift index 2c374e2..2a765ed 100644 --- a/example/ios/EventideTests/EventKitExtensionsTests.swift +++ b/example/ios/EventideTests/EventKitExtensionsTests.swift @@ -182,4 +182,130 @@ final class EventKitExtensionsTests: XCTestCase { XCTAssert(recurrenceRule.daysOfTheWeek!.contains(EKRecurrenceDayOfWeek(.sunday))) XCTAssert(recurrenceRule.daysOfTheMonth!.contains(-1)) } + + func test_toRfc5545String_daily() { + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .daily, + interval: 1, + daysOfTheWeek: nil, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: nil + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=DAILY") + } + + func test_toRfc5545String_weekly_withDays() { + let daysOfWeek = [ + EKRecurrenceDayOfWeek(.monday), + EKRecurrenceDayOfWeek(.wednesday), + EKRecurrenceDayOfWeek(.friday) + ] + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .weekly, + interval: 1, + daysOfTheWeek: daysOfWeek, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: nil + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR") + } + + func test_toRfc5545String_withIntervalAndEndDate() { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + let endDate = dateFormatter.date(from: "20240101T000000Z")! + + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .daily, + interval: 2, + daysOfTheWeek: nil, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: EKRecurrenceEnd(end: endDate) + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20240101T000000Z") + } + + func test_toRfc5545String_withCount() { + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .weekly, + interval: 1, + daysOfTheWeek: nil, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: EKRecurrenceEnd(occurrenceCount: 10) + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=WEEKLY;COUNT=10") + } + + func test_toRfc5545String_withDaysOfWeekAndInterval() { + let daysOfWeek = [ + EKRecurrenceDayOfWeek(.monday), + EKRecurrenceDayOfWeek(.friday) + ] + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .weekly, + interval: 2, + daysOfTheWeek: daysOfWeek, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: nil + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR") + } + + func test_toRfc5545String_withNegativeDaysOfMonth() { + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .monthly, + interval: 1, + daysOfTheWeek: nil, + daysOfTheMonth: [-1], + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: nil + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=MONTHLY;BYMONTHDAY=-1") + } + + func test_toRfc5545String_withMultipleMonthsAndDays() { + let recurrenceRule = EKRecurrenceRule( + recurrenceWith: .yearly, + interval: 1, + daysOfTheWeek: nil, + daysOfTheMonth: [1, 15], + monthsOfTheYear: [3, 7], + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: nil + ) + + XCTAssertEqual(recurrenceRule.toRfc5545String(), "RRULE:FREQ=YEARLY;BYMONTHDAY=1,15;BYMONTH=3,7") + } } diff --git a/example/ios/EventideTests/EventTests.swift b/example/ios/EventideTests/EventTests.swift index a659f61..1a8f6e3 100644 --- a/example/ios/EventideTests/EventTests.swift +++ b/example/ios/EventideTests/EventTests.swift @@ -55,7 +55,7 @@ final class EventTests: XCTestCase { XCTAssert(event.description == "description") XCTAssert(event.url == "url") XCTAssert(event.isAllDay == false) - XCTAssert(event.) + XCTAssert(event.rRule == "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2") expectation.fulfill() case .failure: XCTFail("Event should have been created") @@ -93,7 +93,8 @@ final class EventTests: XCTestCase { endDate: Date().addingTimeInterval(TimeInterval(10)).millisecondsSince1970, isAllDay: false, description: "description", - url: "url" + url: "url", + rRule: nil ) { createEventResult in switch (createEventResult) { case .success: @@ -485,7 +486,8 @@ final class EventTests: XCTestCase { endDate: Date().addingTimeInterval(TimeInterval(10)).millisecondsSince1970, isAllDay: false, description: "description", - url: "url" + url: "url", + rRule: nil ) { createEventResult in switch (createEventResult) { case .success: @@ -625,7 +627,8 @@ final class EventTests: XCTestCase { endDate: Date().addingTimeInterval(TimeInterval(10)).millisecondsSince1970, isAllDay: false, description: "description", - url: "url" + url: "url", + rRule: nil ) { createEventResult in switch (createEventResult) { case .success: diff --git a/example/ios/EventideTests/Mocks/MockEasyEventStore.swift b/example/ios/EventideTests/Mocks/MockEasyEventStore.swift index fa81f75..c8114bd 100644 --- a/example/ios/EventideTests/Mocks/MockEasyEventStore.swift +++ b/example/ios/EventideTests/Mocks/MockEasyEventStore.swift @@ -63,7 +63,7 @@ class MockEasyEventStore: EasyEventStoreProtocol { calendars.remove(at: index) } - func createEvent(calendarId: String, title: String, startDate: Date, endDate: Date, isAllDay: Bool, description: String?, url: String?) throws -> Event { + func createEvent(calendarId: String, title: String, startDate: Date, endDate: Date, isAllDay: Bool, description: String?, url: String?, rRule: String?) throws -> Event { guard let mockCalendar = calendars.first(where: { $0.id == calendarId }) else { throw PigeonError( code: "NOT_FOUND", @@ -80,7 +80,8 @@ class MockEasyEventStore: EasyEventStoreProtocol { calendarId: mockCalendar.id, isAllDay: isAllDay, description: description, - url: url + url: url, + rRule: rRule ) mockCalendar.events.append(mockEvent) @@ -225,8 +226,21 @@ class MockEvent { let url: String? var reminders: [TimeInterval]? let attendees: [MockAttendee]? + let rRule: String? - init(id: String, title: String, startDate: Date, endDate: Date, calendarId: String, isAllDay: Bool, description: String?, url: String?, reminders: [TimeInterval]? = nil, attendees: [MockAttendee]? = nil) { + init( + id: String, + title: String, + startDate: Date, + endDate: Date, + calendarId: String, + isAllDay: Bool, + description: String?, + url: String?, + reminders: [TimeInterval]? = nil, + attendees: [MockAttendee]? = nil, + rRule: String? = nil + ) { self.id = id self.title = title self.startDate = startDate @@ -237,6 +251,7 @@ class MockEvent { self.url = url self.reminders = reminders?.map({ $0 }) self.attendees = attendees + self.rRule = rRule } fileprivate func toEvent() -> Event { @@ -250,7 +265,8 @@ class MockEvent { reminders: reminders?.map({ Int64($0) }) ?? [], attendees: attendees?.map { $0.toAttendee() } ?? [], description: description, - url: url + url: url, + rRule: rRule ) } } diff --git a/ios/eventide/Sources/eventide/CalendarImplem.swift b/ios/eventide/Sources/eventide/CalendarImplem.swift index 432f8ed..1640bbe 100644 --- a/ios/eventide/Sources/eventide/CalendarImplem.swift +++ b/ios/eventide/Sources/eventide/CalendarImplem.swift @@ -115,7 +115,8 @@ class CalendarImplem: CalendarApi { endDate: Date(from: endDate), isAllDay: isAllDay, description: description, - url: url + url: url, + rRule: rRule ) completion(.success(createdEvent)) diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift index 2411cd0..71053da 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift @@ -338,7 +338,8 @@ fileprivate extension EKEvent { ) } ?? [], description: notes, - url: url?.absoluteString + url: url?.absoluteString, + rRule: recurrenceRules?.first?.toRfc5545String() ) } } diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift index 13fb48a..099175f 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift @@ -73,6 +73,67 @@ public extension EKRecurrenceRule { ) } + func toRfc5545String() -> String? { + var rruleString = "RRULE:FREQ=" + + switch frequency { + case .daily: + rruleString += "DAILY" + case .weekly: + rruleString += "WEEKLY" + case .monthly: + rruleString += "MONTHLY" + case .yearly: + rruleString += "YEARLY" + @unknown default: + return nil + } + + if interval > 1 { + rruleString += ";INTERVAL=\(interval)" + } + + if let daysOfTheWeek = daysOfTheWeek { + let dayStrings = daysOfTheWeek.map({ + switch $0.dayOfTheWeek { + case .monday: return "MO" + case .tuesday: return "TU" + case .wednesday: return "WE" + case .thursday: return "TH" + case .friday: return "FR" + case .saturday: return "SA" + case .sunday: return "SU" + @unknown default: return "" + } + }) + + if !dayStrings.isEmpty { + rruleString += ";BYDAY=" + dayStrings.joined(separator: ",") + } + } + + if let daysOfTheMonth = daysOfTheMonth { + rruleString += ";BYMONTHDAY=" + daysOfTheMonth.map({ $0.stringValue }).joined(separator: ",") + } + + if let monthsOfTheYear = monthsOfTheYear { + rruleString += ";BYMONTH=" + monthsOfTheYear.map({ $0.stringValue }).joined(separator: ",") + } + + if let end = recurrenceEnd { + if let endDate = end.endDate { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + rruleString += ";UNTIL=" + dateFormatter.string(from: endDate) + } else if end.occurrenceCount > 0 { + rruleString += ";COUNT=\(end.occurrenceCount)" + } + } + + return rruleString + } + static private func parseInterval(_ intervalString: String) -> Int { guard let interval = Int(intervalString), interval > 0 else { return 1 From db9a9fa02c2747dfe6150e69e3273d76731521bb Mon Sep 17 00:00:00 2001 From: Alexis Choupault Date: Wed, 4 Jun 2025 16:07:01 +0200 Subject: [PATCH 4/4] wip --- .../connect/tech/eventide/CalendarApi.g.kt | 50 +- .../connect/tech/eventide/CalendarImplem.kt | 509 +++++++++++++----- .../tech/eventide/ICalendarFormatter.kt | 96 ++++ .../event_list/logic/event_list_cubit.dart | 6 +- example/lib/event_list/ui/event_list.dart | 39 +- .../Sources/eventide/CalendarApi.g.swift | 55 +- .../Sources/eventide/CalendarImplem.swift | 10 +- .../EasyEventStore/EasyEventStore.swift | 10 +- .../EasyEventStoreProtocol.swift | 2 +- .../EasyEventStore/EventKitExtensions.swift | 9 + lib/eventide.dart | 2 +- lib/src/calendar_api.g.dart | 39 +- lib/src/eventide.dart | 4 +- lib/src/eventide_platform_interface.dart | 8 + lib/src/extensions/event_extensions.dart | 16 + pigeons/calendar_api.dart | 16 +- 16 files changed, 673 insertions(+), 198 deletions(-) create mode 100644 android/src/main/kotlin/sncf/connect/tech/eventide/ICalendarFormatter.kt diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt index 8e59dae..0afa31e 100644 --- a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarApi.g.kt @@ -46,6 +46,18 @@ class FlutterError ( val details: Any? = null ) : Throwable() +enum class EventSpan(val raw: Int) { + CURRENT_EVENT(0), + FUTURE_EVENTS(1), + ALL_EVENTS(2); + + companion object { + fun ofRaw(raw: Int): EventSpan? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class Calendar ( val id: String, @@ -88,7 +100,8 @@ data class Event ( val attendees: List, val description: String? = null, val url: String? = null, - val rRule: String? = null + val rRule: String? = null, + val originalEventId: String? = null ) { companion object { @@ -104,7 +117,8 @@ data class Event ( val description = pigeonVar_list[8] as String? val url = pigeonVar_list[9] as String? val rRule = pigeonVar_list[10] as String? - return Event(id, calendarId, title, isAllDay, startDate, endDate, reminders, attendees, description, url, rRule) + val originalEventId = pigeonVar_list[11] as String? + return Event(id, calendarId, title, isAllDay, startDate, endDate, reminders, attendees, description, url, rRule, originalEventId) } } fun toList(): List { @@ -120,6 +134,7 @@ data class Event ( description, url, rRule, + originalEventId, ) } } @@ -178,21 +193,26 @@ private open class CalendarApiPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + EventSpan.ofRaw(it.toInt()) + } + } + 130.toByte() -> { return (readValue(buffer) as? List)?.let { Calendar.fromList(it) } } - 130.toByte() -> { + 131.toByte() -> { return (readValue(buffer) as? List)?.let { Event.fromList(it) } } - 131.toByte() -> { + 132.toByte() -> { return (readValue(buffer) as? List)?.let { Account.fromList(it) } } - 132.toByte() -> { + 133.toByte() -> { return (readValue(buffer) as? List)?.let { Attendee.fromList(it) } @@ -202,20 +222,24 @@ private open class CalendarApiPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is Calendar -> { + is EventSpan -> { stream.write(129) + writeValue(stream, value.raw) + } + is Calendar -> { + stream.write(130) writeValue(stream, value.toList()) } is Event -> { - stream.write(130) + stream.write(131) writeValue(stream, value.toList()) } is Account -> { - stream.write(131) + stream.write(132) writeValue(stream, value.toList()) } is Attendee -> { - stream.write(132) + stream.write(133) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) @@ -232,7 +256,7 @@ interface CalendarApi { fun deleteCalendar(calendarId: String, callback: (Result) -> Unit) fun createEvent(calendarId: String, title: String, startDate: Long, endDate: Long, isAllDay: Boolean, description: String?, url: String?, rRule: String?, callback: (Result) -> Unit) fun retrieveEvents(calendarId: String, startDate: Long, endDate: Long, callback: (Result>) -> Unit) - fun deleteEvent(eventId: String, callback: (Result) -> Unit) + fun deleteEvent(calendarId: String, eventId: String, span: EventSpan, callback: (Result) -> Unit) fun createReminder(reminder: Long, eventId: String, callback: (Result) -> Unit) fun deleteReminder(reminder: Long, eventId: String, callback: (Result) -> Unit) fun createAttendee(eventId: String, name: String, email: String, role: Long, type: Long, callback: (Result) -> Unit) @@ -381,8 +405,10 @@ interface CalendarApi { if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List - val eventIdArg = args[0] as String - api.deleteEvent(eventIdArg) { result: Result -> + val calendarIdArg = args[0] as String + val eventIdArg = args[1] as String + val spanArg = args[2] as EventSpan + api.deleteEvent(calendarIdArg, eventIdArg, spanArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(wrapError(error)) diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt index 73dba15..dd42ce6 100644 --- a/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt @@ -1,26 +1,19 @@ package sncf.connect.tech.eventide import android.content.ContentResolver +import android.content.ContentUris import android.content.ContentValues +import android.database.Cursor import android.net.Uri import android.provider.CalendarContract -import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneOffset -import java.time.ZonedDateTime +import sncf.connect.tech.eventide.ICalendarFormatter.formatDateTimeForICalendarUtc import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.time.Duration class CalendarImplem( private var contentResolver: ContentResolver, @@ -28,7 +21,8 @@ class CalendarImplem( private var calendarContentUri: Uri = CalendarContract.Calendars.CONTENT_URI, private var eventContentUri: Uri = CalendarContract.Events.CONTENT_URI, private var remindersContentUri: Uri = CalendarContract.Reminders.CONTENT_URI, - private var attendeesContentUri: Uri = CalendarContract.Attendees.CONTENT_URI + private var attendeesContentUri: Uri = CalendarContract.Attendees.CONTENT_URI, + private var instancesContentUri: Uri = CalendarContract.Instances.CONTENT_URI ): CalendarApi { override fun requestCalendarPermission(callback: (Result) -> Unit) { val readLatch = CompletableDeferred() @@ -420,35 +414,48 @@ class CalendarImplem( CoroutineScope(Dispatchers.IO).launch { try { val projection = arrayOf( - CalendarContract.Events._ID, - CalendarContract.Events.TITLE, - CalendarContract.Events.DESCRIPTION, - CalendarContract.Events.DTSTART, - CalendarContract.Events.DTEND, - CalendarContract.Events.DURATION, - CalendarContract.Events.EVENT_TIMEZONE, - CalendarContract.Events.ALL_DAY, - CalendarContract.Events.RRULE, + CalendarContract.Instances._ID, + CalendarContract.Instances.EVENT_ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.DESCRIPTION, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.DURATION, + CalendarContract.Instances.EVENT_TIMEZONE, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.RRULE, + ) + + val builder: Uri.Builder = instancesContentUri.buildUpon() + ContentUris.appendId(builder, startDate) + ContentUris.appendId(builder, endDate) + + val selection = "${CalendarContract.Instances.CALENDAR_ID} = ?" + val selectionArgs = arrayOf(calendarId) + val sortOrder = CalendarContract.Instances.BEGIN + " ASC" + + val cursor: Cursor? = contentResolver.query( + builder.build(), + projection, + selection, + selectionArgs, + sortOrder ) - val selection = - CalendarContract.Events.CALENDAR_ID + " = ? AND " + CalendarContract.Events.DTSTART + " >= ?" + " AND " + - CalendarContract.Events.DTSTART + " <= ?" - val selectionArgs = arrayOf(calendarId, startDate.toString(), endDate.toString()) - val cursor = contentResolver.query(eventContentUri, projection, selection, selectionArgs, null) val events = mutableListOf() cursor?.use { c -> while (c.moveToNext()) { - val id = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events._ID)) - val title = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.TITLE)) + val id = c.getString(c.getColumnIndexOrThrow(CalendarContract.Instances._ID)) + val originalId = c.getString(c.getColumnIndexOrThrow(CalendarContract.Instances.EVENT_ID)) + val title = c.getString(c.getColumnIndexOrThrow(CalendarContract.Instances.TITLE)) val description = - c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION)) - val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) - val duration = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.DURATION)) - val end = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTEND)) - val isAllDay = c.getInt(c.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)) == 1 - val rRule = c.getStringOrNull(c.getColumnIndexOrThrow(CalendarContract.Events.RRULE)) + c.getString(c.getColumnIndexOrThrow(CalendarContract.Instances.DESCRIPTION)) + val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Instances.BEGIN)) + val duration = c.getString(c.getColumnIndexOrThrow(CalendarContract.Instances.DURATION)) + val end = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Instances.END)) + val isAllDay = c.getInt(c.getColumnIndexOrThrow(CalendarContract.Instances.ALL_DAY)) == 1 + val rRule = c.getStringOrNull(c.getColumnIndexOrThrow(CalendarContract.Instances.RRULE)) val attendees = mutableListOf() val attendeesLatch = CountDownLatch(1) @@ -494,7 +501,8 @@ class CalendarImplem( attendees = attendees, description = description, isAllDay = isAllDay, - rRule = "RRULE:$rRule" + rRule = "RRULE:$rRule", + originalEventId = originalId ) ) } @@ -514,7 +522,6 @@ class CalendarImplem( ) } } - } else { callback( Result.failure( @@ -528,80 +535,175 @@ class CalendarImplem( } } - private fun rfc2445DurationToMillis(rfc2445Duration: String): Long { - val regex = Regex("P(?:(\\d+)D)?T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?") - val matchResult = regex.matchEntire(rfc2445Duration) - ?: throw IllegalArgumentException("Invalid RFC2445 duration format") + override fun deleteEvent( + calendarId: String, + eventId: String, + span: EventSpan, + callback: (Result) -> Unit + ) { + permissionHandler.requestWritePermission { granted -> + if (!granted) { + callback(Result.failure( + FlutterError( + code = "ACCESS_REFUSED", + message = "Calendar access has been refused or has not been given yet" + ) + )) + return@requestWritePermission + } - val days = matchResult.groups[1]?.value?.toLong() ?: 0 - val hours = matchResult.groups[2]?.value?.toLong() ?: 0 - val minutes = matchResult.groups[3]?.value?.toLong() ?: 0 - val seconds = matchResult.groups[4]?.value?.toLong() ?: 0 + CoroutineScope(Dispatchers.IO).launch { + try { + if (!isCalendarWritable(calendarId)) { + callback(Result.failure( + FlutterError( + code = "NOT_EDITABLE", + message = "Calendar is not writable" + ) + )) - return TimeUnit.DAYS.toMillis(days) + - TimeUnit.HOURS.toMillis(hours) + - TimeUnit.MINUTES.toMillis(minutes) + - TimeUnit.SECONDS.toMillis(seconds) - } + return@launch + } - override fun deleteEvent(eventId: String, callback: (Result) -> Unit) { - permissionHandler.requestWritePermission { granted -> - if (granted) { - CoroutineScope(Dispatchers.IO).launch { - try { - val calendarId = getCalendarId(eventId) - if (isCalendarWritable(calendarId)) { - val selection = CalendarContract.Events._ID + " = ?" - val selectionArgs = arrayOf(eventId) + when (span) { + EventSpan.CURRENT_EVENT -> { + // Suppression d'une occurrence unique + val instanceCursor = CalendarContract.Instances.query( + contentResolver, + arrayOf( + CalendarContract.Instances.BEGIN, + CalendarContract.Instances._ID, + CalendarContract.Instances.EVENT_ID + ), + Long.MIN_VALUE, + Long.MAX_VALUE + ) - val deleted = contentResolver.delete(eventContentUri, selection, selectionArgs) - if (deleted > 0) { + val values = ContentValues() + var originalEventId: Long? = null + + while (instanceCursor.moveToNext()) { + val foundEventId = instanceCursor.getString(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances._ID)) + + if (foundEventId == eventId) { + val instanceStartDate = instanceCursor.getLong(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances.BEGIN)) + values.put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) + values.put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, instanceStartDate) + + originalEventId = instanceCursor.getLong(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances.EVENT_ID)) + break + } + } + + if (originalEventId == null) { + callback(Result.failure( + FlutterError( + code = "NOT_FOUND", + message = "Failed to retrieve instance for deletion" + ) + )) + return@launch + } + + val exceptionUriWithId = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, originalEventId) + + if (contentResolver.insert(exceptionUriWithId, values) != null) { callback(Result.success(Unit)) } else { callback( Result.failure( FlutterError( - code = "NOT_FOUND", - message = "Failed to delete event" + code = "GENERIC_ERROR", + message = "Failed to delete current event occurrence" ) ) ) } - } else { - callback( - Result.failure( + } + EventSpan.FUTURE_EVENTS -> { + val values = ContentValues() + var originalEventId: Long? = null + + val instanceCursor = CalendarContract.Instances.query( + contentResolver, + arrayOf( + CalendarContract.Instances._ID, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.EVENT_ID + ), + Long.MIN_VALUE, + Long.MAX_VALUE + ) + + while (instanceCursor.moveToFirst()) { + val foundEventId = instanceCursor.getString(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances._ID)) + + if (foundEventId == eventId) { + val instanceStartDate = + instanceCursor.getLong(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances.BEGIN)) + values.put(CalendarContract.Events.LAST_DATE, instanceStartDate) + + originalEventId = + instanceCursor.getLong(instanceCursor.getColumnIndexOrThrow(CalendarContract.Instances.EVENT_ID)) + + break + } + } + + if (originalEventId == null) { + callback(Result.failure( FlutterError( - code = "NOT_EDITABLE", - message = "Calendar is not writable" + code = "NOT_FOUND", + message = "Failed to retrieve instance for update" ) - ) - ) - } + )) + return@launch + } - } catch (e: FlutterError) { - callback(Result.failure(e)) + val rowsUpdated = contentResolver.update( + ContentUris.withAppendedId(eventContentUri, originalEventId), + values, + null, + null + ) - } catch (e: Exception) { - callback( - Result.failure( - FlutterError( - code = "GENERIC_ERROR", - message = "An error occurred", - details = e.message + if (rowsUpdated > 0) { + callback(Result.success(Unit)) + } else { + callback( + Result.failure( + FlutterError( + code = "NOT_FOUND", + message = "Failed to update recurring event" + ) + ) ) - ) - ) + } + } + EventSpan.ALL_EVENTS -> { + val uri = ContentUris.withAppendedId(eventContentUri, eventId.toLong()) + val deleted = contentResolver.delete(uri, null, null) + if (deleted > 0) { + callback(Result.success(Unit)) + } else { + callback(Result.failure( + FlutterError( + code = "NOT_FOUND", + message = "Failed to delete recurring event" + ) + )) + } + } } - } - } else { - callback( - Result.failure( + } catch (e: Exception) { + callback(Result.failure( FlutterError( - code = "ACCESS_REFUSED", - message = "Calendar access has been refused or has not been given yet", + code = "GENERIC_ERROR", + message = "An error occurred", + details = e.message ) - ) - ) + )) + } } } } @@ -618,7 +720,7 @@ class CalendarImplem( } contentResolver.insert(remindersContentUri, values) - retrieveEvent(eventId, callback) + retrieveInstance(eventId, callback) } catch (e: Exception) { callback( @@ -656,7 +758,7 @@ class CalendarImplem( val deleted = contentResolver.delete(remindersContentUri, selection, selectionArgs) if (deleted > 0) { - retrieveEvent(eventId, callback) + retrieveInstance(eventId, callback) } else { callback( Result.failure( @@ -713,7 +815,7 @@ class CalendarImplem( } contentResolver.insert(attendeesContentUri, values) - retrieveEvent(eventId, callback) + retrieveInstance(eventId, callback) } catch (e: Exception) { callback( @@ -755,7 +857,7 @@ class CalendarImplem( val deleted = contentResolver.delete(attendeesContentUri, selection, selectionArgs) if (deleted > 0) { - retrieveEvent(eventId, callback) + retrieveInstance(eventId, callback) } else { callback( Result.failure( @@ -819,63 +921,47 @@ class CalendarImplem( ) } - private fun getCalendarId( - eventId: String, - ): String { - val projection = arrayOf( - CalendarContract.Events.CALENDAR_ID - ) - val selection = CalendarContract.Events._ID + " = ?" - val selectionArgs = arrayOf(eventId) - - val cursor = contentResolver.query(eventContentUri, projection, selection, selectionArgs, null) - cursor?.use { - if (it.moveToNext()) { - return it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_ID)) - } else { - throw FlutterError( - code = "NOT_FOUND", - message = "Failed to retrieve event" - ) - } - } - - throw FlutterError( - code = "GENERIC_ERROR", - message = "An error occurred" - ) - } - - private fun retrieveEvent( + private fun retrieveInstance( eventId: String, callback: (Result) -> Unit ) { try { val projection = arrayOf( - CalendarContract.Events._ID, - CalendarContract.Events.TITLE, - CalendarContract.Events.DESCRIPTION, - CalendarContract.Events.DTSTART, - CalendarContract.Events.DTEND, - CalendarContract.Events.EVENT_TIMEZONE, - CalendarContract.Events.CALENDAR_ID, - CalendarContract.Events.ALL_DAY, + CalendarContract.Instances._ID, + CalendarContract.Instances.EVENT_ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.DESCRIPTION, + CalendarContract.Instances.DTSTART, + CalendarContract.Instances.DTEND, + CalendarContract.Instances.EVENT_TIMEZONE, + CalendarContract.Instances.CALENDAR_ID, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.RRULE, ) - val selection = CalendarContract.Events._ID + " = ?" + val selection = "Instances._id = ?" val selectionArgs = arrayOf(eventId) - val cursor = contentResolver.query(eventContentUri, projection, selection, selectionArgs, null) + val startMillis = 0L + val endMillis = Long.MAX_VALUE + + val builder: Uri.Builder = instancesContentUri.buildUpon() + ContentUris.appendId(builder, startMillis) + ContentUris.appendId(builder, endMillis) + + val cursor = contentResolver.query(builder.build(), projection, selection, selectionArgs, null) var event: Event? = null cursor?.use { it -> if (it.moveToNext()) { - val id = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events._ID)) - val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.TITLE)) - val description = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION)) - val isAllDay = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)) == 1 - val startDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) - val endDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Events.DTEND)) - val calendarId = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.CALENDAR_ID)) + val id = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances._ID)) + val originalId = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances.EVENT_ID)) + val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances.TITLE)) + val description = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances.DESCRIPTION)) + val isAllDay = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Instances.ALL_DAY)) == 1 + val startDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Instances.DTSTART)) + val endDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Instances.DTEND)) + val calendarId = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances.CALENDAR_ID)) + val rRule = it.getString(it.getColumnIndexOrThrow(CalendarContract.Instances.RRULE)) val attendees = mutableListOf() val attendeesLatch = CountDownLatch(1) @@ -906,14 +992,16 @@ class CalendarImplem( event = Event( id = id, + originalEventId = originalId, + calendarId = calendarId, title = title, startDate = startDate, endDate = endDate, - calendarId = calendarId, description = description, isAllDay = isAllDay, reminders = reminders, - attendees = attendees + attendees = attendees, + rRule = rRule ) } } @@ -923,7 +1011,7 @@ class CalendarImplem( Result.failure( FlutterError( code = "NOT_FOUND", - message = "Failed to retrieve event" + message = "Failed to retrieve event instance" ) ) ) @@ -931,6 +1019,51 @@ class CalendarImplem( callback(Result.success(event!!)) } + } catch (e: Exception) { + callback( + Result.failure( + FlutterError( + code = "GENERIC_ERROR", + message = "An error occurred", + details = e.message + ) + ) + ) + } + } + + private fun retrieveOriginalTimeMillis( + eventId: String, + callback: (Result) -> Unit + ) { + try { + val projection = arrayOf( + CalendarContract.Events.DTSTART, + ) + val selection = CalendarContract.Events._ID + " = ?" + val selectionArgs = arrayOf(eventId) + + val cursor = contentResolver.query(eventContentUri, projection, selection, selectionArgs, null) + var startDate: Long? = null + + cursor?.use { it -> + if (it.moveToNext()) { + startDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)) + } + } + + if (startDate == null) { + callback( + Result.failure( + FlutterError( + code = "NOT_FOUND", + message = "Failed to retrieve event original timeMillis" + ) + ) + ) + } else { + callback(Result.success(startDate!!)) + } } catch (e: Exception) { callback( @@ -1024,4 +1157,100 @@ class CalendarImplem( )) } } + + private fun rfc2445DurationToMillis(rfc2445Duration: String): Long { + val regex = Regex("P(?:(\\d+)D)?T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?") + val matchResult = regex.matchEntire(rfc2445Duration) + ?: throw IllegalArgumentException("Invalid RFC2445 duration format") + + val days = matchResult.groups[1]?.value?.toLong() ?: 0 + val hours = matchResult.groups[2]?.value?.toLong() ?: 0 + val minutes = matchResult.groups[3]?.value?.toLong() ?: 0 + val seconds = matchResult.groups[4]?.value?.toLong() ?: 0 + + return TimeUnit.DAYS.toMillis(days) + + TimeUnit.HOURS.toMillis(hours) + + TimeUnit.MINUTES.toMillis(minutes) + + TimeUnit.SECONDS.toMillis(seconds) + } + + private fun replaceUntilInRRule(rrule: String, newUntil: String): String { + val untilRegex = Regex("UNTIL=\\d{8}T\\d{6}Z") + return if (untilRegex.containsMatchIn(rrule)) { + rrule.replace(untilRegex, "UNTIL=$newUntil") + } else { + if (rrule.endsWith(";") || rrule.isEmpty()) { + "${rrule}UNTIL=$newUntil" + } else { + "$rrule;UNTIL=$newUntil" + } + } + } + + private fun addExDateToRRule( + eventId: String, + timestamp: Long, + isAllDay: Boolean, + callback: (Result) -> Unit + ) { + try { + val eventUri = CalendarContract.Events.CONTENT_URI.buildUpon().appendPath(eventId).build() + val projection = arrayOf(CalendarContract.Events.RRULE) + var rrule: String? = null + + contentResolver.query(eventUri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + rrule = cursor.getString(cursor.getColumnIndexOrThrow(CalendarContract.Events.RRULE)) + } + } + + if (rrule == null) { + callback(Result.failure( + FlutterError( + code = "NOT_FOUND", + message = "RRULE not found for the event" + ) + )) + return + } + + val exdate = formatDateTimeForICalendarUtc(timestamp, isAllDay) + val newRrule = if (rrule!!.contains("EXDATE=")) { + rrule!!.replace(Regex("EXDATE=([^;]*)")) { matchResult -> + val existing = matchResult.groupValues[1] + "EXDATE=${existing},$exdate" + } + } else { + if (rrule!!.endsWith(";") || rrule!!.isEmpty()) { + "${rrule}EXDATE=$exdate" + } else { + "$rrule;EXDATE=$exdate" + } + } + + val values = ContentValues().apply { + put(CalendarContract.Events.RRULE, newRrule) + } + + val rows = contentResolver.update(eventUri, values, null, null) + if (rows > 0) { + callback(Result.success(Unit)) + } else { + callback(Result.failure( + FlutterError( + code = "UPDATE_FAILED", + message = "Failed to update RRULE with EXDATE" + ) + )) + } + } catch (e: Exception) { + callback(Result.failure( + FlutterError( + code = "GENERIC_ERROR", + message = "An error occurred", + details = e.message + ) + )) + } + } } diff --git a/android/src/main/kotlin/sncf/connect/tech/eventide/ICalendarFormatter.kt b/android/src/main/kotlin/sncf/connect/tech/eventide/ICalendarFormatter.kt new file mode 100644 index 0000000..f7a3ec5 --- /dev/null +++ b/android/src/main/kotlin/sncf/connect/tech/eventide/ICalendarFormatter.kt @@ -0,0 +1,96 @@ +package sncf.connect.tech.eventide + +import android.content.ContentResolver +import android.content.ContentValues +import android.provider.CalendarContract +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +/** + * Helper object for formatting dates/times into iCalendar (RFC 5545) strings. + * Assumes all date/time inputs are in UTC. + */ +object ICalendarFormatter { + + /** + * Formats a given timestamp into an iCalendar string. + * All dates/times are treated as UTC. + * + * @param timestampMillis The timestamp in milliseconds (expected to be in UTC). + * @param isAllDay True if the event is an all-day event. + * If true, format will be YYYYMMDD. + * If false, format will be YYYYMMDDTHHmmssZ. + * @return The formatted iCalendar date/time string. + */ + fun formatDateTimeForICalendarUtc(timestampMillis: Long, isAllDay: Boolean): String { + val calendar = Calendar.getInstance() + calendar.timeInMillis = timestampMillis + + val sdf: SimpleDateFormat + + if (isAllDay) { + sdf = SimpleDateFormat("yyyyMMdd", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + } else { + sdf = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + } + + return sdf.format(calendar.time) + } +} + +/** + * Adds one or more exception dates (EXDATEs) to a recurring event in CalendarContract. + * + * @param contentResolver The ContentResolver instance. + * @param eventId The `_ID` of the main recurring event to modify. + * @param occurrencesToExcludeMillis An array of timestamps (in UTC) for the specific occurrences + * that should be excluded from the recurring series. + * @param isRecurringEventAllDay True if the recurring event itself is an all-day event. + * This affects the EXDATE format. + * @return True if the update was successful, false otherwise. + */ +fun addExdatesToRecurringEvent( + contentResolver: ContentResolver, + eventId: Long, + occurrencesToExcludeMillis: LongArray, + isRecurringEventAllDay: Boolean +): Boolean { + val eventUri = CalendarContract.Events.CONTENT_URI.buildUpon().appendPath(eventId.toString()).build() + val projection = arrayOf(CalendarContract.Events.EXDATE) + var existingExdate: String? = null + + contentResolver.query(eventUri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val exdateColumnIndex = cursor.getColumnIndex(CalendarContract.Events.EXDATE) + if (exdateColumnIndex != -1) { + existingExdate = cursor.getString(exdateColumnIndex) + } + } + } + + // 2. Build the new EXDATE string + val exdateBuilder = StringBuilder(existingExdate ?: "") + + for (timestamp in occurrencesToExcludeMillis) { + val formattedDate = ICalendarFormatter.formatDateTimeForICalendarUtc(timestamp, isRecurringEventAllDay) + if (exdateBuilder.isNotEmpty()) { + exdateBuilder.append(",") + } + exdateBuilder.append(formattedDate) + } + + val finalExdate = exdateBuilder.toString() + + // 3. Update the event with the new EXDATE string + val values = ContentValues().apply { + put(CalendarContract.Events.EXDATE, finalExdate) + } + + val rowsAffected = contentResolver.update(eventUri, values, null, null) + + return rowsAffected > 0 +} diff --git a/example/lib/event_list/logic/event_list_cubit.dart b/example/lib/event_list/logic/event_list_cubit.dart index 62ae04e..ff6a4c3 100644 --- a/example/lib/event_list/logic/event_list_cubit.dart +++ b/example/lib/event_list/logic/event_list_cubit.dart @@ -30,7 +30,7 @@ class EventListCubit extends Cubit { startDate: startDate, endDate: endDate, calendarId: data.calendar.id, - rRule: rRule.toString(), + rRule: rRule?.toString(), ); return EventValue( calendar: data.calendar, @@ -49,10 +49,10 @@ class EventListCubit extends Cubit { }).forEach(emit); } - Future deleteEvent(String eventId) async { + Future deleteEvent(String calendarId, String eventId, ETEventSpan span) async { if (state case Value(:final data?)) { await state.fetchFrom(() async { - await _calendarPlugin.deleteEvent(eventId: eventId); + await _calendarPlugin.deleteEvent(calendarId: calendarId, eventId: eventId, span: span); return EventValue( calendar: data.calendar, events: [...state.data?.events.where((event) => event.id != eventId) ?? []], diff --git a/example/lib/event_list/ui/event_list.dart b/example/lib/event_list/ui/event_list.dart index 4d1174b..38c1ebe 100644 --- a/example/lib/event_list/ui/event_list.dart +++ b/example/lib/event_list/ui/event_list.dart @@ -1,3 +1,4 @@ +import 'package:eventide/eventide.dart'; import 'package:eventide_example/event_details/ui/event_details.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -69,7 +70,43 @@ class EventList extends StatelessWidget { IconButton( icon: const Icon(Icons.delete), onPressed: () { - context.read().deleteEvent(event.id); + if (event.rRule != null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete event'), + content: const Text( + 'Do you want to delete this event and all future occurrences?'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().deleteEvent( + data.calendar.id, event.id, ETEventSpan.currentEvent); + }, + child: const Text('This event'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + // Supprimer cet événement et tous les suivants + context.read().deleteEvent( + data.calendar.id, event.id, ETEventSpan.futureEvents); + }, + child: const Text('All future occurrences'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ), + ); + } else { + context + .read() + .deleteEvent(data.calendar.id, event.id, ETEventSpan.currentEvent); + } }, ), ], diff --git a/ios/eventide/Sources/eventide/CalendarApi.g.swift b/ios/eventide/Sources/eventide/CalendarApi.g.swift index 43ff851..f3d2277 100644 --- a/ios/eventide/Sources/eventide/CalendarApi.g.swift +++ b/ios/eventide/Sources/eventide/CalendarApi.g.swift @@ -64,6 +64,12 @@ private func nilOrValue(_ value: Any?) -> T? { return value as! T? } +enum EventSpan: Int { + case currentEvent = 0 + case futureEvents = 1 + case allEvents = 2 +} + /// Generated class from Pigeon that represents data sent in messages. struct Calendar { var id: String @@ -113,6 +119,7 @@ struct Event { var description: String? = nil var url: String? = nil var rRule: String? = nil + var originalEventId: String? = nil // swift-format-ignore: AlwaysUseLowerCamelCase @@ -128,6 +135,7 @@ struct Event { let description: String? = nilOrValue(pigeonVar_list[8]) let url: String? = nilOrValue(pigeonVar_list[9]) let rRule: String? = nilOrValue(pigeonVar_list[10]) + let originalEventId: String? = nilOrValue(pigeonVar_list[11]) return Event( id: id, @@ -140,7 +148,8 @@ struct Event { attendees: attendees, description: description, url: url, - rRule: rRule + rRule: rRule, + originalEventId: originalEventId ) } func toList() -> [Any?] { @@ -156,6 +165,7 @@ struct Event { description, url, rRule, + originalEventId, ] } } @@ -224,12 +234,18 @@ private class CalendarApiPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { case 129: - return Calendar.fromList(self.readValue() as! [Any?]) + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return EventSpan(rawValue: enumResultAsInt) + } + return nil case 130: - return Event.fromList(self.readValue() as! [Any?]) + return Calendar.fromList(self.readValue() as! [Any?]) case 131: - return Account.fromList(self.readValue() as! [Any?]) + return Event.fromList(self.readValue() as! [Any?]) case 132: + return Account.fromList(self.readValue() as! [Any?]) + case 133: return Attendee.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -239,17 +255,20 @@ private class CalendarApiPigeonCodecReader: FlutterStandardReader { private class CalendarApiPigeonCodecWriter: FlutterStandardWriter { override func writeValue(_ value: Any) { - if let value = value as? Calendar { + if let value = value as? EventSpan { super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? Calendar { + super.writeByte(130) super.writeValue(value.toList()) } else if let value = value as? Event { - super.writeByte(130) + super.writeByte(131) super.writeValue(value.toList()) } else if let value = value as? Account { - super.writeByte(131) + super.writeByte(132) super.writeValue(value.toList()) } else if let value = value as? Attendee { - super.writeByte(132) + super.writeByte(133) super.writeValue(value.toList()) } else { super.writeValue(value) @@ -277,12 +296,12 @@ protocol CalendarApi { func requestCalendarPermission(completion: @escaping (Result) -> Void) func createCalendar(title: String, color: Int64, localAccountName: String, completion: @escaping (Result) -> Void) func retrieveCalendars(onlyWritableCalendars: Bool, fromLocalAccountName: String?, completion: @escaping (Result<[Calendar], Error>) -> Void) - func deleteCalendar(_ calendarId: String, completion: @escaping (Result) -> Void) + func deleteCalendar(calendarId: String, completion: @escaping (Result) -> Void) func createEvent(calendarId: String, title: String, startDate: Int64, endDate: Int64, isAllDay: Bool, description: String?, url: String?, rRule: String?, completion: @escaping (Result) -> Void) func retrieveEvents(calendarId: String, startDate: Int64, endDate: Int64, completion: @escaping (Result<[Event], Error>) -> Void) - func deleteEvent(withId eventId: String, completion: @escaping (Result) -> Void) - func createReminder(_ reminder: Int64, forEventId eventId: String, completion: @escaping (Result) -> Void) - func deleteReminder(_ reminder: Int64, withEventId eventId: String, completion: @escaping (Result) -> Void) + func deleteEvent(calendarId: String, eventId: String, span: EventSpan, completion: @escaping (Result) -> Void) + func createReminder(reminder: Int64, eventId: String, completion: @escaping (Result) -> Void) + func deleteReminder(reminder: Int64, eventId: String, completion: @escaping (Result) -> Void) func createAttendee(eventId: String, name: String, email: String, role: Int64, type: Int64, completion: @escaping (Result) -> Void) func deleteAttendee(eventId: String, email: String, completion: @escaping (Result) -> Void) } @@ -350,7 +369,7 @@ class CalendarApiSetup { deleteCalendarChannel.setMessageHandler { message, reply in let args = message as! [Any?] let calendarIdArg = args[0] as! String - api.deleteCalendar(calendarIdArg) { result in + api.deleteCalendar(calendarId: calendarIdArg) { result in switch result { case .success: reply(wrapResult(nil)) @@ -409,8 +428,10 @@ class CalendarApiSetup { if let api = api { deleteEventChannel.setMessageHandler { message, reply in let args = message as! [Any?] - let eventIdArg = args[0] as! String - api.deleteEvent(withId: eventIdArg) { result in + let calendarIdArg = args[0] as! String + let eventIdArg = args[1] as! String + let spanArg = args[2] as! EventSpan + api.deleteEvent(calendarId: calendarIdArg, eventId: eventIdArg, span: spanArg) { result in switch result { case .success: reply(wrapResult(nil)) @@ -428,7 +449,7 @@ class CalendarApiSetup { let args = message as! [Any?] let reminderArg = args[0] as! Int64 let eventIdArg = args[1] as! String - api.createReminder(reminderArg, forEventId: eventIdArg) { result in + api.createReminder(reminder: reminderArg, eventId: eventIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -446,7 +467,7 @@ class CalendarApiSetup { let args = message as! [Any?] let reminderArg = args[0] as! Int64 let eventIdArg = args[1] as! String - api.deleteReminder(reminderArg, withEventId: eventIdArg) { result in + api.deleteReminder(reminder: reminderArg, eventId: eventIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/ios/eventide/Sources/eventide/CalendarImplem.swift b/ios/eventide/Sources/eventide/CalendarImplem.swift index 1640bbe..fe92278 100644 --- a/ios/eventide/Sources/eventide/CalendarImplem.swift +++ b/ios/eventide/Sources/eventide/CalendarImplem.swift @@ -74,7 +74,7 @@ class CalendarImplem: CalendarApi { } } - func deleteCalendar(_ calendarId: String, completion: @escaping (Result) -> Void) { + func deleteCalendar(calendarId: String, completion: @escaping (Result) -> Void) { permissionHandler.checkCalendarAccessThenExecute { [self] in do { try easyEventStore.deleteCalendar(calendarId: calendarId) @@ -165,10 +165,10 @@ class CalendarImplem: CalendarApi { } } - func deleteEvent(withId eventId: String, completion: @escaping (Result) -> Void) { + func deleteEvent(eventId: String, span: EventSpan, completion: @escaping (Result) -> Void) { permissionHandler.checkCalendarAccessThenExecute { [self] in do { - try easyEventStore.deleteEvent(eventId: eventId) + try easyEventStore.deleteEvent(eventId: eventId, span: span) completion(.success(())) } catch { @@ -186,7 +186,7 @@ class CalendarImplem: CalendarApi { } } - func createReminder(_ reminder: Int64, forEventId eventId: String, completion: @escaping (Result) -> Void) { + func createReminder(reminder: Int64, eventId: String, completion: @escaping (Result) -> Void) { permissionHandler.checkCalendarAccessThenExecute { [self] in do { let modifiedEvent = try easyEventStore.createReminder(timeInterval: TimeInterval(-reminder), eventId: eventId) @@ -208,7 +208,7 @@ class CalendarImplem: CalendarApi { } - func deleteReminder(_ reminder: Int64, withEventId eventId: String, completion: @escaping (Result) -> Void) { + func deleteReminder(reminder: Int64, eventId: String, completion: @escaping (Result) -> Void) { permissionHandler.checkCalendarAccessThenExecute { [self] in do { let modifiedEvent = try easyEventStore.deleteReminder(timeInterval: TimeInterval(-reminder), eventId: eventId) diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift index 71053da..78805fd 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStore.swift @@ -149,7 +149,11 @@ final class EasyEventStore: EasyEventStoreProtocol { } } - func retrieveEvents(calendarId: String, startDate: Date, endDate: Date) throws -> [Event] { + func retrieveEvents( + calendarId: String, + startDate: Date, + endDate: Date + ) throws -> [Event] { guard let calendar = eventStore.calendar(withIdentifier: calendarId) else { throw PigeonError( code: "NOT_FOUND", @@ -167,7 +171,7 @@ final class EasyEventStore: EasyEventStoreProtocol { return eventStore.events(matching: predicate).map { $0.toEvent() } } - func deleteEvent(eventId: String) throws { + func deleteEvent(eventId: String, span: EventSpan) throws { guard let event = eventStore.event(withIdentifier: eventId) else { throw PigeonError( code: "NOT_FOUND", @@ -185,7 +189,7 @@ final class EasyEventStore: EasyEventStoreProtocol { } do { - try eventStore.remove(event, span: .thisEvent) + try eventStore.remove(event, span: EKSpan(from: span)) } catch { eventStore.reset() diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift index 64f141b..9925275 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EasyEventStoreProtocol.swift @@ -28,7 +28,7 @@ protocol EasyEventStoreProtocol { func retrieveEvents(calendarId: String, startDate: Date, endDate: Date) throws -> [Event] - func deleteEvent(eventId: String) throws -> Void + func deleteEvent(eventId: String, span: EventSpan) throws -> Void func createReminder(timeInterval: TimeInterval, eventId: String) throws -> Event diff --git a/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift index 099175f..caf3fbf 100644 --- a/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift +++ b/ios/eventide/Sources/eventide/EasyEventStore/EventKitExtensions.swift @@ -7,6 +7,15 @@ import EventKit +public extension EKSpan { + internal init(from span: EventSpan) { + switch span { + case .currentEvent: self.self = .thisEvent + case .futureEvents: self.self = .futureEvents + } + } +} + public extension EKRecurrenceRule { convenience init?(from rRule: String) { let workableRRule: String diff --git a/lib/eventide.dart b/lib/eventide.dart index 2f60ff3..fb09d13 100644 --- a/lib/eventide.dart +++ b/lib/eventide.dart @@ -2,6 +2,6 @@ library; export 'src/eventide.dart' show Eventide; export 'src/eventide_platform_interface.dart' - show ETCalendar, ETEvent, ETAccount, ETAttendee, ETAttendeeType, ETAttendanceStatus; + show ETCalendar, ETEvent, ETAccount, ETAttendee, ETAttendeeType, ETAttendanceStatus, ETEventSpan; export 'src/eventide_exception.dart' show ETException, ETPermissionException, ETNotEditableException, ETNotFoundException, ETGenericException; diff --git a/lib/src/calendar_api.g.dart b/lib/src/calendar_api.g.dart index 2f07ff2..71dac14 100644 --- a/lib/src/calendar_api.g.dart +++ b/lib/src/calendar_api.g.dart @@ -15,6 +15,12 @@ PlatformException _createConnectionError(String channelName) { ); } +enum EventSpan { + currentEvent, + futureEvents, + allEvents, +} + class Calendar { Calendar({ required this.id, @@ -69,6 +75,7 @@ class Event { this.description, this.url, this.rRule, + this.originalEventId, }); String id; @@ -93,6 +100,8 @@ class Event { String? rRule; + String? originalEventId; + Object encode() { return [ id, @@ -106,6 +115,7 @@ class Event { description, url, rRule, + originalEventId, ]; } @@ -123,6 +133,7 @@ class Event { description: result[8] as String?, url: result[9] as String?, rRule: result[10] as String?, + originalEventId: result[11] as String?, ); } } @@ -201,17 +212,20 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Calendar) { + } else if (value is EventSpan) { buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is Calendar) { + buffer.putUint8(130); writeValue(buffer, value.encode()); } else if (value is Event) { - buffer.putUint8(130); + buffer.putUint8(131); writeValue(buffer, value.encode()); } else if (value is Account) { - buffer.putUint8(131); + buffer.putUint8(132); writeValue(buffer, value.encode()); } else if (value is Attendee) { - buffer.putUint8(132); + buffer.putUint8(133); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -222,12 +236,15 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: - return Calendar.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : EventSpan.values[value]; case 130: - return Event.decode(readValue(buffer)!); + return Calendar.decode(readValue(buffer)!); case 131: - return Account.decode(readValue(buffer)!); + return Event.decode(readValue(buffer)!); case 132: + return Account.decode(readValue(buffer)!); + case 133: return Attendee.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -431,7 +448,11 @@ class CalendarApi { } } - Future deleteEvent({required String eventId}) async { + Future deleteEvent({ + required String calendarId, + required String eventId, + required EventSpan span, + }) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.eventide.CalendarApi.deleteEvent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -439,7 +460,7 @@ class CalendarApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([eventId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([calendarId, eventId, span]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/lib/src/eventide.dart b/lib/src/eventide.dart index 306c9d6..b48bf41 100644 --- a/lib/src/eventide.dart +++ b/lib/src/eventide.dart @@ -200,10 +200,12 @@ class Eventide extends EventidePlatform { /// Throws a [ETGenericException] if any other error occurs during event deletion. @override Future deleteEvent({ + required String calendarId, required String eventId, + ETEventSpan span = ETEventSpan.currentEvent, }) async { try { - await _calendarApi.deleteEvent(eventId: eventId); + await _calendarApi.deleteEvent(calendarId: calendarId, eventId: eventId, span: span.toEventSpan()); } on PlatformException catch (e) { throw e.toETException(); } diff --git a/lib/src/eventide_platform_interface.dart b/lib/src/eventide_platform_interface.dart index 85f41f4..0a2e9d8 100644 --- a/lib/src/eventide_platform_interface.dart +++ b/lib/src/eventide_platform_interface.dart @@ -57,7 +57,9 @@ abstract class EventidePlatform extends PlatformInterface { }); Future deleteEvent({ + required String calendarId, required String eventId, + ETEventSpan span = ETEventSpan.currentEvent, }); Future createReminder({ @@ -360,3 +362,9 @@ enum ETAttendanceStatus { required this.androidStatus, }); } + +enum ETEventSpan { + currentEvent, + futureEvents, + allEvents, +} diff --git a/lib/src/extensions/event_extensions.dart b/lib/src/extensions/event_extensions.dart index 6b08f95..72373cc 100644 --- a/lib/src/extensions/event_extensions.dart +++ b/lib/src/extensions/event_extensions.dart @@ -43,3 +43,19 @@ extension EventListToETEvent on List { return map((e) => e.toETEvent()).toList(); } } + +extension ETtoEventSpan on ETEventSpan { + EventSpan toEventSpan() => switch (this) { + ETEventSpan.currentEvent => EventSpan.currentEvent, + ETEventSpan.futureEvents => EventSpan.futureEvents, + ETEventSpan.allEvents => EventSpan.allEvents, + }; +} + +extension EventSpanToET on EventSpan { + ETEventSpan toETEventSpan() => switch (this) { + EventSpan.currentEvent => ETEventSpan.currentEvent, + EventSpan.futureEvents => ETEventSpan.futureEvents, + EventSpan.allEvents => ETEventSpan.allEvents, + }; +} diff --git a/pigeons/calendar_api.dart b/pigeons/calendar_api.dart index e6e9b10..bc3c921 100644 --- a/pigeons/calendar_api.dart +++ b/pigeons/calendar_api.dart @@ -29,7 +29,6 @@ abstract class CalendarApi { }); @async - @SwiftFunction('deleteCalendar(_:)') void deleteCalendar({ required String calendarId, }); @@ -54,20 +53,19 @@ abstract class CalendarApi { }); @async - @SwiftFunction('deleteEvent(withId:)') void deleteEvent({ + required String calendarId, required String eventId, + required EventSpan span, }); @async - @SwiftFunction('createReminder(_:forEventId:)') Event createReminder({ required int reminder, required String eventId, }); @async - @SwiftFunction('deleteReminder(_:withEventId:)') Event deleteReminder({ required int reminder, required String eventId, @@ -117,19 +115,21 @@ final class Event { final String? description; final String? url; final String? rRule; + final String? originalEventId; const Event({ required this.id, + required this.calendarId, required this.title, required this.isAllDay, required this.startDate, required this.endDate, - required this.calendarId, required this.reminders, required this.attendees, required this.description, required this.url, required this.rRule, + required this.originalEventId, }); } @@ -158,3 +158,9 @@ final class Attendee { required this.status, }); } + +enum EventSpan { + currentEvent, + futureEvents, + allEvents, +}