📅

device_calendarのカスタム条件で繰り返す予定の互換性

2023/01/28に公開

Flutterで "端末のカレンダーの予定をCRUDできる" packageとして device_calendar があります。
これを触っていて、繰り返し予定(ある予定を 日次/週次/月次/年次/カスタム条件 で繰り返す)の
"カスタム条件で繰り返す" 予定について、

  • システムカレンダーアプリで編集した予定と完全な互換性があるのか?
  • iOS/Android で設定できるカスタム条件に違いがあるが、その辺りがどう表現されるか

が気になったので調査してみます。

flutter 3.3.8
device_calendar 4.3.0
iOS 16.2
Android 13 

で確認しました。

使い方のおさらい

端末のカレンダーの予定を読むには

final _plugin = DeviceCalendarPlugin();
// カレンダーを取得
final calendarsResult = await _plugin.retrieveCalendars();
if (calendarsResult.data == null) return;

// 各カレンダーから予定を取得
final now = DateTime.now();
final calendarIdToEvents = <String, List<Event>>{};
for (final calendar in calendarsResult.data!) {
  final eventResult = await _calendarPlugin.retrieveEvents(
    calendar.id,
    // 今から1日以内の予定を取得
    RetrieveEventsParams(
      startDate: now,
      endDate: now.add(const Duration(days: 1)),
    ),
  );
  if (eventResult.isSuccess) {
    calendarIdToEvents[calendar.id!] = eventResult.data!; // dataはList<Event>
  }
}

という感じで行うことができ、pluginからはEvent というオブジェクトで返ってきます。
(※カレンダー及び予定の取得前にアクセス権限を取得する必要がありますが省略)

逆に編集した予定を端末のカレンダーに保存(書き込む)には

final now = DateTime.now();
final event = Event(
  calendar.id,
  eventId: eventId, // 既存のEventを更新する場合に入れる
  title: 'Flutterアプリ開発するよ',
  allDay: false,
  start: now,
  end: now.add(const Duration(hours: 3)),
  recurrenceRule: null, // この後詳しく見ていく繰り返しルールを指定
  description: 'メモメモ',
);
final result = await _plugin.createOrUpdateEvent(event);
if (result?.isSuccess ?? false) {
  print('書き込み成功');
} 

という感じで編集したEventオブジェクトをpluginのcreateOrUpdateEventに渡します。

ちなみに、裏側の実装としては
iOSでは EventKit を、Androidでは Calendar provider を操作しているようです。

繰り返し条件の表現

EventオブジェクトのrecurrenceRuleというメンバに入る RecurrenceRuleオブジェクトで表現されます。
RecurrenceRuleは以下のようなメンバを持ちます。

メンバ名 意味 値の候補
recurrenceFrequency RecurrenceFrequency? 繰り返す頻度 Daily,Weekly,Monthly,Yearly
interval int? 予定同士の間隔
(インターバル)
totalOccurrences int? 合計繰り返し回数
(この回数繰り返したら終了する)
endDate DateTime? 終了日
(※totalOccurrencesとendDateはどちらか一方を指定する)
daysOfWeek List<DayOfWeek>? 曜日 Monday,Tuesday,..,Sunday
dayOfMonth int? (月に対して)何日か 1-31
monthOfYear MonthOfYear? January,Feburary,..,December
weekOfMonth WeekNumber? 何週目か First,Second,Third,Fourth,Last

例えば、システムのカレンダーアプリで単純に「毎日」の繰り返しで予定を作った場合は以下のようになります。

スクショ
or
メンバ
iOS Android
スクショ ios-event
recurrenceFrequency RecurrenceFrequency.Daily RecurrenceFrequency.Daily
interval 1 1
daysOfWeek [] []

カスタム条件で繰り返すイベントの表現

ここからが本題です。
カスタム条件を色々設定した場合の表現を見ていきます。

Dailyの繰り返し

1/16 19-20時の予定を 頻度: Daily, 間隔: 3日, 終了日 2/16 で繰り返し

スクショ
or
メンバ
iOS Android
スクショ

※iOSと違い、終了日も同一画面で設定可
recurrenceFrequency RecurrenceFrequency.Daily RecurrenceFrequency.Daily
interval 3 3
endDate 2/16 20:00:00 2/16 23:59:59
daysOfWeek [] []

気づいたことなど

  • 繰り返しの終了日(endDate)はイベント終了時(iOS) or 終了日の23:59:59(Android) という違いがあるが繰り返しの観点からはどちらでも良いように思われる
    • 起点日より後の、繰り返す日のイベントはどう見えるか?...(A)
  • daysOfWeekはnullでなく空リストになる
  • Androidは「N回目の予定が完了した時点で終了」という設定ができる。これをFlutter側で設定してiOSのシステムカレンダーアプリで開くとどう見えるか?...(B)

追加調査

起点日より後の、繰り返す日のイベントはどう見えるか?...(A)

3日後の1/19のイベントを取得してみると、
Eventのidは起点日(1/16)と同じ、start-endは日付が1/19に変更、recurrenceRuleは1/16と同じ
Eventデータが返ってきました。

Androidは「N回目の予定が完了した時点で終了」という設定ができる。これをFlutter側で設定してiOSのシステムカレンダーアプリで開くとどう見えるか?...(B)

Flutter側でtotalOccurrencesを指定してcreateOrUpdateEventしたところ、
iOSのシステムカレンダーアプリでも指定回数で終了するイベントになりました!
(iOSもAndroidもiCalendar形式のファイルをカレンダーにインポートできますし、それに準拠しているであろうイベントは問題なく扱えるんでしょうね。)
であれば、せっかくなのでFlutterアプリ上ではtotalOccurrencesの編集もサポートする方がユーザさんにとっても良さそうに思います。

Weeklyの繰り返し

1/16 19-20時の予定を 頻度: Weekly, interval: 2, 曜日: (月、水、金), 終了日 2/16 で繰り返し

スクショ
or
メンバ
iOS Android
スクショ
recurrenceFrequency RecurrenceFrequency.Weekly RecurrenceFrequency.Weekly
interval 2 2
endDate 2/16 20:00:00 2/16 23:59:59
daysOfWeek [DayOfWeek.Monday,
DayOfWeek.Wednesday,
DayOfWeek.Friday]
[DayOfWeek.Monday,
DayOfWeek.Wednesday,
DayOfWeek.Friday]
weekOfMonth WeekNumber.First null

気づいたことなど

  • iOSのweekOfMonthがなぜか .Firstになっている(1/16は1月第3週なのに...)
    • Flutter側でweekOfMonthだけをnullにして作ったイベントをiOSのシステムアプリで開くとどう見えるか?...(C)

追加調査

Flutter側でweekOfMonthだけをnullにして作ったイベントをiOSのシステムアプリで開くとどう見えるか?...(C)

こちらを試したところシステムのカレンダーアプリで作ったイベントと同様の見え方になりました。
→ が、それを改めてFlutter側で読むと WeekNumber.Firstが入ってきたw
ソースを見ると ここ のiOSのEKEventの繰り返しルールをparseする部分で「最初に見つかった曜日をweekOfMonthに入れる」ようになっているため、上記例だと 月曜(intだと1) → WeekNumber.First が入るという不具合に見えます。
(developブランチでは既に修正されているので、将来のリリースで直りそう)

Monthlyの繰り返し①

1/16 19-20時の予定を 頻度: Monthly, interval: 2, 指定日: 16日, 終了日 5/16 で繰り返し

スクショ
or
メンバ
iOS Android
スクショ
recurrenceFrequency RecurrenceFrequency.Monthly RecurrenceFrequency.Monthly
interval 2 2
endDate 5/16 20:00:00 5/16 23:59:59
daysOfWeek [] []
dayOfMonth 16 16

ちなみに、Monthlyの設定では「毎月○日」以外にも「第3月曜日」のような設定もできます。
その場合は以下のようにdaysOfWeekとweekOfMonthで表現されます。

スクショ
or
メンバ
iOS Android
スクショ
daysOfWeek [DayOfWeek.Monday] [DayOfWeek.Monday]
weekOfMonth WeekNumber.Third WeekNumber.Third

気づいたことなど

  • Androidは(イベント日に基づいて)「16日 or 第3月曜日」というように2択になっているのに対し、iOSでは「20日」や「第4日曜日」など自由度高く設定できるようになっている
    • Androidのシステムカレンダーアプリでは設定できない「17日」とか「第4日曜日」をFlutter側で設定したら、Androidのシステムアプリでどう見えるか?...(D)

追加調査

Androidのシステムカレンダーアプリでは設定できない「17日」とか「第4日曜日」をFlutter側で設定したら、Androidのシステムアプリでどう見えるか?...(D)

こちらは設定通りの繰り返しとして見えることが確認できました。
(17日で設定した場合、1/17, 3/17で繰り返す。
第4日曜日で設定した場合、1/29(日), 3/26(日)で繰り返す)

Monthlyの繰り返し②

前節のiOSで「16日」など設定できる部分は、実は複数選択可能です...
ところがdayOfMonthはint?なので複数入らないように見えますね。
複数設定した場合を見ていきます。

1/16 19-20時の予定を 頻度: Monthly, interval: 2, 指定日: (16日, 26日), 終了日 5/16 で繰り返し

スクショ
or
メンバ
iOS Android
スクショ ※システムのカレンダーアプリでは複数設定不可
recurrenceFrequency RecurrenceFrequency.Monthly --
interval 2 --
endDate 5/16 20:00:00 --
daysOfWeek [] --
dayOfMonth 16 --

dayOfMonthが16(=指定日の先頭)しか返ってきません・・
ただし、retrieveEventで26日のイベントを取得すると、Event自体は返ってきます
(ただしRecurrenceRuleは上記と同じdayOfMonthが16のまま)
これはdevice_calendarが対応できていないパターンと言えそうです。

ちなみに、先頭しか返ってこないのは ここdaysOfTheMonth?.first で先頭だけ取り出しているからのように見えます。
が、developブランチでは iCalendar の繰り返しルールに準拠した rrule を内部的に使うようにする PR が取り込まれているため、これも将来的には直るものと思われます。

Yearlyの繰り返し①

1/16 19-20時の予定を 頻度: Yearly, interval: 2, 指定月: 1月, 終了日 2027/1/16 で繰り返し

スクショ
or
メンバ
iOS Android
スクショ

recurrenceFrequency RecurrenceFrequency.Yearly RecurrenceFrequency.Yearly
interval 2 2
endDate 2027/1/16 20:00:00 2027/1/16 23:59:59
daysOfWeek [] []
monthOfYear MonthOfYear.January null

気づいたことなど

  • Androidでは月が選択できず、monthOfYearもnullになる
    • Flutter側でmonthOfYearを指定して作ったイベントはAndroidのシステムアプリでどう見えるか?...(E)
  • iOSでMonthOfYearが入るなら、なんとなくdayOfMonthに16が入る(=MonthOfYear.Januaryと合わせて1/16と表現されそう)と思ったが入らなかった
    • Flutter側でmonthOfYearをnullにしたイベントはiOSのシステムアプリでどう見えるか?...(F)

追加調査

Flutter側でmonthOfYearを指定して作ったイベントはAndroidのシステムアプリでどういう見え方になるか?...(E)

こちらを試したところ、正常な繰り返しとして設定できました。
(=2025/1/16, 2027/1/16の2回繰り返される。
またこれを再度Flutter側で読み出すとmonthOfyearにJanuaryは入っている。

Flutter側でmonthOfYearをnullにしたイベントはiOSのシステムアプリでどう見えるか?...(F)

こちらも正常な繰り返しとして設定できました。
(=2025/1/16, 2027/1/16の2回繰り返される。
ちなみに、これを再度Flutter側で読み出すとmonthOfYearにJanuaryが入っていた

Yearlyの繰り返し②

前節のiOSのスクショを見て頂くとわかりますが、さらに「曜日」も設定できるようなのでこれを見てみます。
条件は 1/16 19-20時の予定を 頻度: Yearly, interval: 2, 指定月: 1月, 指定曜日: "最後の日曜日", 終了日 2027/2/1 としました。

スクショ
or
メンバ
iOS Android
スクショ ※ システムのカレンダーアプリでは設定不可
recurrenceFrequency RecurrenceFrequency.Yearly --
interval 2 --
endDate 2027/2/1 20:00:00 --
daysOfWeek [DayOfWeek.Sunday] --
weekOfMonth WeekNumber.last --
monthOfYear MonthOfYear.January --

上記のようにdaysOfWeekとweekOfMonthに追加で値が入りました。
(もう予想はつくでしょうが)このイベントをFlutter側で作ってAndroidのシステムカレンダーアプリで開いても正しく設定されていました。
(=2025/1/26(日), 2027/1/31(日) に繰り返される)

まとめ

  • 「Monthlyで繰り返しの指定日を複数設定できない」件を除き、iOS/Androidのどちらかのシステムカレンダーで設定可能な繰り返しは、device_calendar(4.3.0)側でも設定可能です。
    • Monthlyの複数指定日で繰り返すイベントも、Flutter側で繰り返し日のイベントとして読み出すことは可能です。
    • ちなみに、developブランチでは iCalendar の繰り返しルールに準拠した rrule を内部的に使うようにする PR が取り込まれているため、将来的には直る(複数指定できるようになる)ものと思われます。
    • 上記の1件以外のパターンは device_calendar側で設定できるため、Flutterアプリで対応すると「OSのシステムカレンダーアプリより高機能!」という差別化ができて良いかもしれませんね 🚀
  • 上記を含め、OS別のシステムカレンダーアプリで設定できるカスタムの繰り返し条件
    (及びdevice_calendarでの対応状況)を表にすると以下のようになります。
繰り返し条件 iOS Android device_calendar
(4.3.0)
(全般)特定日に繰り返し終了
(全般)特定回数で繰り返し終了 x
(Weekly)複数曜日で繰り返す
(Monthly)指定日を複数指定して繰り返す x x
(Monthly)起点のイベントと違う指定日(毎月N日)で繰り返す x
(Monthly)起点のイベントと違う第N-△曜日で繰り返す x
(Yearly)起点のイベントと違う月(毎年N月)で繰り返す x
(Yearly)起点のイベントと違う第N-△曜日で繰り返す x

Discussion