device_calendar_plus

A modern, maintained Flutter plugin for reading and writing device calendar events on Android and iOS. Modern replacement for the unmaintained device_calendar plugin โ€” rebuilt for 2025 Flutter standards, working towards feature parity with a cleaner API, and no timezone package dependency.

pub package pub points platforms MIT license

โœจ Overview

device_calendar_plus lets Flutter apps read and write native calendar data using:

  • Android Calendar Provider
  • iOS EventKit

It provides a clean Dart API, proper time-zone handling, and an actively maintained federated structure.

Created by Bullet โ€” a personal task + notes + calendar app using this plugin in production.

โœ… Supported versions

Platform Min OS / SDK Target / Compile
Android minSdk 24+ target/compile 35
iOS iOS 13+ Latest Xcode / iOS SDK

๐Ÿš€ Features

  • Permissions: Request and check calendar permissions
  • Calendars: Create, read, update, and delete calendars
  • Events: Create, read, update, and delete events
  • Query: Retrieve events by date range or specific event IDs
  • Native UI: Open native event modal for viewing/editing in both android and iOS
  • All-Day Events: Proper handling of floating calendar dates
  • Timezones: Correct timezone behavior for timed events
  • Recurring Events: Create, read, and delete recurring events (daily, weekly, monthly, yearly) with full RRULE support

๐Ÿงฉ Installation

Add the dependency to your project:

dependencies:
  device_calendar_plus: <latest version>

iOS

Add usage descriptions to your appโ€™s Info.plist:

<!-- iOS 10โ€“16 (legacy key, still valid) -->
<key>NSCalendarsUsageDescription</key>
<string>Access your calendar to view and manage events.</string>

<!-- iOS 17+ (choose as appropriate) -->
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Full access to view and edit your calendar events.</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>Add events without reading existing events.</string>

Android

Add calendar permissions to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />

ProGuard / R8: ProGuard rules are automatically applied by the plugin. No manual configuration needed.

โฐ DateTime and Timezone Behavior

All DateTimes returned by this plugin are in local time.

All-Day Events (Floating Dates)

All-day events are treated as floating calendar dates, not specific instants in time. This means:

  • An all-day event for "January 15, 2024" will always display as January 15, regardless of what timezone your device is in
  • The date components (year, month, day) are preserved across timezone changes
  • Do NOT convert all-day event DateTimes to UTC โ€” they represent calendar dates, not moments in time
  • Example: A birthday on "January 15" should always show as January 15, whether you're in New York or Tokyo

Non-All-Day Events (Instants in Time)

Regular timed events represent specific moments in time and can be converted to UTC as needed:

  • These events have specific start/end times in a timezone (e.g., "3:00 PM New York time")
  • They represent absolute instants that correspond to different local times across timezones
  • You can freely convert these DateTimes to UTC for storage, comparison, or API calls
  • Example: A meeting at "3:00 PM EST" is the same instant as "12:00 PM PST"

Summary

// All-day event - treat as a calendar date, NOT a UTC instant
final birthdayEvent = await plugin.getEvent(birthdayId);
if (birthdayEvent.isAllDay) {
  // โœ… Use the date components directly
  print('Birthday: ${birthdayEvent.startDate.year}-${birthdayEvent.startDate.month}-${birthdayEvent.startDate.day}');
  
  // โŒ Don't convert to UTC - it's a calendar date, not a moment in time
  // final utcDate = birthdayEvent.startDate.toUtc(); // DON'T DO THIS
}

// Regular timed event - this IS an instant in time
final meetingEvent = await plugin.getEvent(meetingId);
if (!meetingEvent.isAllDay) {
  // โœ… Convert to UTC for storage/comparison
  final utcTime = meetingEvent.startDate.toUtc();
  
  // โœ… Format in local time for display
  print('Meeting at: ${meetingEvent.startDate}');
}

๐Ÿงฑ Error Handling

The plugin throws two categories of errors:

Runtime errors โ€” DeviceCalendarException with a DeviceCalendarError enum code. These represent conditions your app should handle (permission denied, event not found, calendar read-only, etc.):

try {
  await plugin.createEvent(...);
} on DeviceCalendarException catch (e) {
  switch (e.errorCode) {
    case DeviceCalendarError.permissionDenied:
      // Ask user to grant permission
    case DeviceCalendarError.notFound:
      // Calendar or event doesn't exist
    default:
      // Handle other cases
  }
}

Programmer errors โ€” standard Dart errors (ArgumentError, etc.) for invalid arguments. These indicate bugs in your code, not runtime conditions to handle:

// These throw ArgumentError โ€” fix your code, don't catch them:
plugin.createEvent(calendarId: '', ...);  // empty ID
plugin.createEvent(..., endDate: beforeStart);  // end before start
plugin.updateEvent(eventId: 'x');  // no fields to update

Note on error codes: DeviceCalendarError exists for developer ergonomics and clearer switch handling. We may introduce new enum values in future minor versions as new error cases appear. We do not consider this a breaking change.

๐Ÿ› ๏ธ Usage Examples

Request Permissions

import 'package:device_calendar_plus/device_calendar_plus.dart';

// Get the singleton instance
final plugin = DeviceCalendar.instance;

// Request calendar permissions
final status = await plugin.requestPermissions();
if (status != CalendarPermissionStatus.granted) {
  // Handle permission denied
  return;
}

Check Permissions

Use hasPermissions() to check the current permission status without prompting the user:

final plugin = DeviceCalendar.instance;

// Check current permission status (doesn't prompt)
final status = await plugin.hasPermissions();

if (status == CalendarPermissionStatus.granted) {
  // Permissions already granted
  final calendars = await plugin.listCalendars();
} else if (status == CalendarPermissionStatus.notDetermined) {
  // User hasn't been asked yet - now we can prompt
  final newStatus = await plugin.requestPermissions();
} else {
  // Denied or restricted - show appropriate UI
  print('Permissions: $status');
}

List Calendars

final plugin = DeviceCalendar.instance;

// List all calendars
final calendars = await plugin.listCalendars();
for (final calendar in calendars) {
  print('${calendar.name} (${calendar.readOnly ? "read-only" : "writable"})');
  if (calendar.isPrimary) {
    print('  โญ Primary calendar');
  }
  if (calendar.colorHex != null) {
    print('  Color: ${calendar.colorHex}');
  }
}

// Find a writable calendar
final writableCalendar = calendars.firstWhere(
  (cal) => !cal.readOnly,
  orElse: () => calendars.first,
);

Retrieve Events

final plugin = DeviceCalendar.instance;

// Get events for the next 30 days
final now = DateTime.now();
final startDate = now;
final endDate = now.add(const Duration(days: 30));

// Get events from all calendars
final allEvents = await plugin.listEvents(
  startDate,
  endDate,
);
print('Found ${allEvents.length} events');

// Get events from specific calendars only
final calendarIds = ['calendar-id-1', 'calendar-id-2'];
final filteredEvents = await plugin.listEvents(
  startDate,
  endDate,
  calendarIds: calendarIds,
);

Get Single Event

final plugin = DeviceCalendar.instance;

// Get a specific event by instanceId
final event = await plugin.getEvent(event.instanceId);
if (event != null) {
  print('Event: ${event.title}');
}

// For recurring events, get a specific occurrence
final instance = await plugin.getEvent(event.instanceId);

// For recurring events, get the master event definition
final masterEvent = await plugin.getEvent(event.eventId);

Show Event in Modal

final plugin = DeviceCalendar.instance;

// Show a specific event in a modal dialog
await plugin.showEventModal(event.instanceId);

// For recurring events, show a specific occurrence
await plugin.showEventModal(event.instanceId);

// For recurring events, show the master event
await plugin.showEventModal(event.eventId);

Create Event via Native Editor

Opens the platform's native calendar editor in create mode. Useful when you want the user to review/edit before saving, or as the iOS workaround for adding attendees (which can't be done programmatically).

final plugin = DeviceCalendar.instance;

// Open blank editor
await plugin.showCreateEventModal();

// Open with pre-filled data
await plugin.showCreateEventModal(
  title: 'Team Meeting',
  startDate: DateTime.now().add(Duration(hours: 1)),
  endDate: DateTime.now().add(Duration(hours: 2)),
  location: 'Conference Room A',
  description: 'Weekly sync',
  recurrenceRule: WeeklyRecurrence(
    daysOfWeek: [DayOfWeek.tuesday],
  ),
);

All parameters are optional. The Future completes when the modal is dismissed (whether the user saved or cancelled).

Platform APIs: iOS uses EKEventEditViewController, Android uses Intent.ACTION_INSERT.

Create Event

final plugin = DeviceCalendar.instance;

// Create a basic event
final eventId = await plugin.createEvent(
  calendarId: 'your-calendar-id',
  title: 'Team Meeting',
  startDate: DateTime(2024, 3, 20, 14, 0),
  endDate: DateTime(2024, 3, 20, 15, 0),
);

// Create an all-day event
final allDayEventId = await plugin.createEvent(
  calendarId: 'your-calendar-id',
  title: 'Conference',
  startDate: DateTime(2024, 3, 20),
  endDate: DateTime(2024, 3, 21),
  isAllDay: true,
);

// Create event with all optional parameters
final detailedEventId = await plugin.createEvent(
  calendarId: 'your-calendar-id',
  title: 'Project Kickoff',
  startDate: DateTime(2024, 3, 20, 10, 0),
  endDate: DateTime(2024, 3, 20, 12, 0),
  description: 'Quarterly project kickoff meeting',
  location: 'Conference Room A',
  timeZone: 'America/New_York',
  availability: EventAvailability.busy,
);

Recurring Events

Create recurring events by passing a RecurrenceRule to createEvent. The rule types are sealed classes, so the compiler ensures you handle all cases.

// Every day for 30 days
await plugin.createEvent(
  calendarId: calendarId,
  title: 'Daily Standup',
  startDate: DateTime(2024, 3, 20, 9, 0),
  endDate: DateTime(2024, 3, 20, 9, 15),
  recurrenceRule: DailyRecurrence(end: CountEnd(30)),
);

// Every 2 weeks on Monday and Friday
await plugin.createEvent(
  calendarId: calendarId,
  title: 'Sprint Review',
  startDate: DateTime(2024, 3, 20, 14, 0),
  endDate: DateTime(2024, 3, 20, 15, 0),
  recurrenceRule: WeeklyRecurrence(
    interval: 2,
    daysOfWeek: [DayOfWeek.monday, DayOfWeek.friday],
  ),
);

Monthly and yearly have sealed subtypes โ€” the default constructor is by day of month, use .byWeekday for weekday patterns:

// Monthly on the 1st and 15th
MonthlyRecurrence(daysOfMonth: [1, 15])

// Monthly on the 2nd Tuesday
MonthlyRecurrence.byWeekday(
  daysOfWeek: [RecurrenceDay(DayOfWeek.tuesday, position: 2)],
)

// Monthly on the last Friday
MonthlyRecurrence.byWeekday(
  daysOfWeek: [RecurrenceDay(DayOfWeek.friday, position: -1)],
)

// Yearly on Christmas
YearlyRecurrence(months: [12], daysOfMonth: [25])

// Yearly on the 4th Thursday of November (Thanksgiving)
YearlyRecurrence.byWeekday(
  months: [11],
  daysOfWeek: [RecurrenceDay(DayOfWeek.thursday, position: 4)],
)

// Last weekday of every month (uses BYSETPOS)
MonthlyRecurrence.byWeekday(
  daysOfWeek: [
    RecurrenceDay(DayOfWeek.monday),
    RecurrenceDay(DayOfWeek.tuesday),
    RecurrenceDay(DayOfWeek.wednesday),
    RecurrenceDay(DayOfWeek.thursday),
    RecurrenceDay(DayOfWeek.friday),
  ],
  setPositions: [-1],
)

End conditions are either a count or a date โ€” or omit for forever:

DailyRecurrence(end: CountEnd(10))           // after 10 occurrences
DailyRecurrence(end: UntilEnd(DateTime.utc(2025, 12, 31)))  // until a date
DailyRecurrence()                            // forever

When reading events back, event.recurrenceRule gives you the typed model. For RRULE properties the typed model doesn't cover, use the rruleString escape hatch โ€” it preserves the original platform string:

final event = await plugin.getEvent(eventId);
final rule = event?.recurrenceRule;

// Typed access
if (rule is MonthlyByWeekday) {
  print(rule.daysOfWeek);
  print(rule.setPositions);
}

// Raw RRULE string โ€” preserves platform-specific properties
// like BYHOUR or BYSETPOS combinations the typed model doesn't cover
print(rule?.rruleString); // e.g. "FREQ=MONTHLY;BYDAY=2TU;COUNT=12"

Update Event

final plugin = DeviceCalendar.instance;

// Update event title
await plugin.updateEvent(
  instanceId: event.instanceId,
  title: 'Updated Meeting Title',
);

// Update multiple fields
await plugin.updateEvent(
  instanceId: event.instanceId,
  title: 'Team Sync',
  startDate: DateTime(2024, 3, 21, 15, 0),
  endDate: DateTime(2024, 3, 21, 16, 0),
  location: 'Conference Room B',
  description: 'Updated description',
);

// Change a timed event to all-day
await plugin.updateEvent(
  instanceId: event.instanceId,
  isAllDay: true,
);

// Change an all-day event to timed
await plugin.updateEvent(
  instanceId: event.instanceId,
  isAllDay: false,
  startDate: DateTime(2024, 3, 21, 10, 0),
  endDate: DateTime(2024, 3, 21, 11, 0),
);

// Update timezone (reinterprets local time)
// Note: "3 PM EST" becomes "3 PM PST" (different instant in time)
await plugin.updateEvent(
  instanceId: event.instanceId,
  timeZone: 'America/Los_Angeles',
);

Note on Recurring Events: For recurring events, updateEvent will always update the ENTIRE series (all past and future occurrences). Single-instance updates are not supported to maintain consistent behavior across platforms.

Delete Event

final plugin = DeviceCalendar.instance;

// Delete a single event
await plugin.deleteEvent(event.instanceId);

// For recurring events, this deletes the ENTIRE series (all occurrences)
await plugin.deleteEvent(event.instanceId);

๐Ÿ“‹ Roadmap

  • x Permissions โ€” request, check, and open settings
  • x Calendars โ€” create, read, update, delete
  • x Events โ€” create, read, update, delete
  • x All-day events โ€” proper floating date handling across timezones
  • x Native UI โ€” show event modal on both platforms
  • x Recurring events โ€” create and read with sealed RecurrenceRule model (daily, weekly, monthly, yearly)
  • Update recurrence rules โ€” change/add/remove recurrence rule via updateEvent
  • Attendees โ€” read on both platforms; write on Android (iOS EventKit is read-only for participants)
  • Reminders / alarms โ€” read/write on both platforms
  • Platform-specific extras โ€” event URL, organizer, and other platform-native fields exposed where supported

๐Ÿค Contributing

Contributions, PRs and issue reports welcome. Open an issue first for larger features or breaking changes.

  • Code style: dart format .
  • Run tests: flutter test
  • Federated layout: platform code lives in /packages/device_calendar_plus_android and /packages/device_calendar_plus_ios; shared contracts in /packages/device_calendar_plus_platform_interface.

๐Ÿงช Testing Status

This plugin includes both unit tests and integration tests to ensure reliability.

๐Ÿ“„ License

MIT ยฉ 2025 Bullet See LICENSE for details.


Maintained by Bullet โ€” a cross-platform task + notes + calendar app built with Flutter.