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.
โจ 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: Read recurring event instances; update/delete entire series
๐งฉ 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}');
}
๐งฑ Exception model
Each DeviceCalendarException uses an enum code to describe the error type:
enum DeviceCalendarError {
permissionDenied,
...
}
This enum provides stable, descriptive error codes for all exceptions thrown by the plugin.
Note on error codes:
DeviceCalendarErrorexists for developer ergonomics and clearerswitchhandling. 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
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,
);
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);
๐ค 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_androidand/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.