ops_calendar 0.1.4
ops_calendar: ^0.1.4 copied to clipboard
Flutter month calendar widget with multi-day event ribbons, swipeable months, and tap callbacks. State-management agnostic, works with BLoC, Riverpod, Provider.
ops_calendar #
A lightweight, performant Flutter month calendar with multi-day event ribbons.
- State-management agnostic — works with BLoC, Riverpod, Provider, GetX, or plain
setState. - Multi-day ribbons — events spanning multiple days render as continuous bars across week rows, with greedy lane packing for overlapping events.
- Programmatic control —
OpsCalendarControllerfor next/previous/jump-to-month and listening to the visible month from outside the widget. - Performant —
RepaintBoundaryper page, pre-computed ribbon layout,PageView.builderrecycling, pure-Dart algorithms. - JSON-ready models —
CalendarEventandCalendarConfiground-trip through JSON viafreezed+json_serializable. - Tested — unit tests for layout algorithms, widget tests for rendering, callbacks, and the controller.
Table of contents #
- Installation
- Quick start
- Programmatic control
- Custom header with chevrons
- Tapping events & dates: popups, sheets, dialogs
- Data structure & mapping
- State management
- Theming
- Configuration
- Events & JSON
- API reference
- Architecture
- Performance
- Development
- License
Installation #
dependencies:
ops_calendar: ^0.1.0
Then:
flutter pub get
Requires Flutter >= 3.27.0 (uses the modern Color API).
Quick start #
import 'package:flutter/material.dart';
import 'package:ops_calendar/ops_calendar.dart';
class MyCalendar extends StatelessWidget {
const MyCalendar({super.key});
@override
Widget build(BuildContext context) {
return OpsMonthCalendar(
initialMonth: DateTime(2026, 4),
events: [
CalendarEvent(
id: '1',
title: 'Conference',
start: DateTime(2026, 4, 15),
end: DateTime(2026, 4, 17),
color: const Color(0xFFF59E0B),
),
CalendarEvent(
id: '2',
title: 'Standup',
start: DateTime(2026, 4, 16),
end: DateTime(2026, 4, 16),
color: const Color(0xFF3B82F6),
),
],
onEventTap: (e) => debugPrint('Tapped event ${e.title}'),
onDateTap: (d) => debugPrint('Tapped date $d'),
onMonthChanged: (m) => debugPrint('Now showing $m'),
);
}
}
That's it. Swipe left/right between months. Multi-day events render as ribbons. Single-day events as chips.
Programmatic control #
Pass an OpsCalendarController to drive the calendar from outside — for chevron buttons, "Today" buttons, BLoC listeners, deep links, anything.
class MyScreen extends StatefulWidget {
const MyScreen({super.key});
@override
State<MyScreen> createState() => _MyScreenState();
}
class _MyScreenState extends State<MyScreen> {
late final OpsCalendarController _controller;
@override
void initState() {
super.initState();
_controller = OpsCalendarController();
}
@override
void dispose() {
_controller.dispose(); // important — same lifecycle as TextEditingController
super.dispose();
}
@override
Widget build(BuildContext context) {
return OpsMonthCalendar(
controller: _controller,
initialMonth: DateTime.now(),
);
}
}
Available methods #
| Method | What it does |
|---|---|
controller.nextMonth() |
Animate to next month |
controller.previousMonth() |
Animate to previous month |
controller.goToMonth(DateTime target) |
Animate to a specific month (any year) |
controller.jumpToMonth(DateTime target) |
Same, but instant — no animation |
controller.goToToday() |
Animate to the month containing today |
Each animated method takes optional duration and curve parameters:
controller.goToMonth(
DateTime(2030, 1),
duration: const Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn,
);
Reading the visible month #
OpsCalendarController is a ChangeNotifier. Listen to it to drive a custom header label:
AnimatedBuilder(
animation: _controller,
builder: (_, __) {
final m = _controller.visibleMonth ?? DateTime.now();
return Text(_formatMonth(m));
},
);
Or with addListener:
@override
void initState() {
super.initState();
_controller = OpsCalendarController()
..addListener(() => debugPrint('Visible: ${_controller.visibleMonth}'));
}
visibleMonth is null until the widget builds — handle the null case with a fallback.
Custom header with chevrons #
Full working pattern:
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key});
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
late final OpsCalendarController _calendar;
@override
void initState() {
super.initState();
_calendar = OpsCalendarController();
}
@override
void dispose() {
_calendar.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _calendar.previousMonth,
),
title: AnimatedBuilder(
animation: _calendar,
builder: (_, __) {
final m = _calendar.visibleMonth ?? DateTime.now();
return Text(_formatMonth(m));
},
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.today),
tooltip: 'Today',
onPressed: _calendar.goToToday,
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _calendar.nextMonth,
),
],
),
body: OpsMonthCalendar(
controller: _calendar,
events: const [/* ... */],
),
);
}
String _formatMonth(DateTime m) {
const months = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
return '${months[m.month - 1]} ${m.year}';
}
}
Tapping a chevron animates the calendar; the header rebuilds because AnimatedBuilder listens to the controller. User swipes also flow through the controller, so the header stays in sync regardless of the input source.
Tapping events & dates: popups, sheets, dialogs #
The package deliberately ships no built-in popup — you decide what happens when an event ribbon or empty cell is tapped. The two callbacks give you everything:
OpsMonthCalendar(
onEventTap: (CalendarEvent e) { /* event was tapped */ },
onDateTap: (DateTime d) { /* empty cell was tapped */ },
);
Below are the most common patterns. Pick one (or mix them) based on your UX.
Pattern 1 — Bottom sheet (recommended on mobile) #
Shows from the bottom edge, dismissible by drag. The most "Material" pattern for event detail.
OpsMonthCalendar(
onEventTap: (event) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (ctx) => Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(event.title, style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 8),
Text('${event.start.toLocal()} → ${event.end.toLocal()}'),
if (event.subtitle != null) ...[
const SizedBox(height: 8),
Text(event.subtitle!),
],
const SizedBox(height: 16),
Row(
children: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () { /* edit / delete / ... */ },
child: const Text('Edit'),
),
],
),
],
),
),
);
},
);
Pattern 2 — Modal dialog #
Centered, blocking, good for confirmations or short detail.
onEventTap: (event) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(event.title),
content: Text('${event.start} → ${event.end}'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK')),
],
),
);
},
Pattern 3 — Full-screen detail route #
Best when the detail view is non-trivial (form, comments, attachments).
onEventTap: (event) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => EventDetailScreen(event: event)),
);
},
Pattern 4 — Anchored menu (Cupertino-ish) #
Position a popup menu near the event. Simplest version uses showMenu:
onEventTap: (event) async {
final box = context.findRenderObject() as RenderBox?;
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?;
if (box == null || overlay == null) return;
final pos = RelativeRect.fromRect(
Rect.fromPoints(
box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
),
Offset.zero & overlay.size,
);
await showMenu<String>(
context: context,
position: pos,
items: [
PopupMenuItem(value: 'edit', child: Text('Edit ${event.title}')),
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
);
},
Note: the package does not currently pass the global tap position to the callback —
showMenuanchors to the calendar widget itself rather than the exact tap point. If you need pixel-accurate anchoring, file an issue and I'll addonEventTapWithPositionin 0.2.
Pattern 5 — Snack bar (lightweight feedback) #
Best for "you tapped X" without disrupting the user.
onDateTap: (date) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('Selected $date')));
},
Pattern 6 — Stateful: dispatch to BLoC / Riverpod, render the popup from state #
This is what the example/ app does. The callback dispatches an event; a BlocConsumer.listener (or Riverpod listener) calls showModalBottomSheet. The popup is then driven entirely by your state machine, which is the right shape for production apps with deep linking, undo, analytics, etc.
BlocConsumer<EventBloc, EventState>(
listenWhen: (a, b) => a.openedEvent != b.openedEvent,
listener: (context, state) {
final e = state.openedEvent;
if (e == null) return;
showModalBottomSheet<void>(
context: context,
builder: (_) => EventDetailSheet(event: e),
);
},
builder: (context, state) => OpsMonthCalendar(
events: state.events,
onEventTap: (e) => context.read<EventBloc>().add(EventOpened(e)),
),
);
Tapping empty cells #
Same callbacks, different intent. onDateTap typically opens a "create event" flow:
onDateTap: (date) async {
final created = await Navigator.of(context).push<Appointment>(
MaterialPageRoute(builder: (_) => CreateEventScreen(initialDate: date)),
);
if (created != null) /* refresh events */;
},
Data structure & mapping #
The widget consumes a single shape: List<CalendarEvent>. Everything else — your API model, recurrence expansion, categorization — lives in your code and converts to CalendarEvent at the render boundary.
Native JSON shape (works with CalendarEvent.fromJson as-is) #
This is exactly what toJson() emits and what fromJson() accepts with no mapping code:
{
"id": "evt-001",
"title": "Conference",
"start": "2026-04-15T00:00:00.000",
"end": "2026-04-17T00:00:00.000",
"color": 4283215696,
"subtitle": "Annual product conference",
"metadata": { "location": "San Francisco", "attendees": 120 }
}
| Key | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | stable identifier |
title |
string | yes | rendered on the ribbon |
start |
ISO 8601 string | yes | anything DateTime.parse accepts |
end |
ISO 8601 string | yes | inclusive, must be ≥ start |
color |
integer (ARGB) | no | defaults to blue 0xFF3B82F6 (= 4283215696) |
subtitle |
string | null | no | |
metadata |
object | null | no | any JSON-encodable bag |
If your backend emits this exact shape, you're done in three lines:
final raw = jsonDecode(response.body) as List;
final events = raw
.map((j) => CalendarEvent.fromJson(j as Map<String, dynamic>))
.toList();
OpsMonthCalendar(events: events);
Realistic API response (full example) #
Most real APIs use snake_case, hex colors, envelope objects, and timezone-aware timestamps. Here's a typical GET /events body:
{
"data": [
{
"id": "appt_8f2c91",
"subject": "Sprint planning",
"starts_at": "2026-04-27T09:00:00Z",
"ends_at": "2026-04-27T10:00:00Z",
"category": "meeting",
"priority": "normal",
"owner": { "id": "u_42", "name": "Alice" },
"location": "Room 4"
},
{
"id": "appt_a1b2c3",
"subject": "Design review",
"starts_at": "2026-04-29T13:00:00Z",
"ends_at": "2026-05-01T18:00:00Z",
"category": "meeting",
"priority": "high",
"owner": { "id": "u_42", "name": "Alice" }
},
{
"id": "appt_d4e5f6",
"subject": "Vacation",
"starts_at": "2026-05-10T00:00:00Z",
"ends_at": "2026-05-15T23:59:00Z",
"category": "personal",
"priority": "low",
"owner": { "id": "u_99", "name": "Bob" }
}
],
"meta": { "page": 1, "total": 3 }
}
Mapping to CalendarEvent:
final categoryColors = <String, Color>{
'meeting': Color(0xFF3B82F6),
'travel': Color(0xFFF59E0B),
'personal': Color(0xFF10B981),
'oncall': Color(0xFFEF4444),
};
List<CalendarEvent> mapApiResponse(Map<String, dynamic> body) {
final list = (body['data'] as List).cast<Map<String, dynamic>>();
return list.map((j) {
return CalendarEvent(
id: j['id'] as String,
title: j['subject'] as String,
start: DateTime.parse(j['starts_at'] as String).toLocal(),
end: DateTime.parse(j['ends_at'] as String).toLocal(),
color: categoryColors[j['category'] as String] ?? const Color(0xFF6B7280),
subtitle: j['location'] as String?,
metadata: {
'ownerId': (j['owner'] as Map)['id'],
'ownerName': (j['owner'] as Map)['name'],
'priority': j['priority'],
'raw': j, // keep the source for the tap handler
},
);
}).toList();
}
// Usage
final events = mapApiResponse(jsonDecode(response.body));
OpsMonthCalendar(
events: events,
onEventTap: (e) {
final raw = e.metadata!['raw'] as Map<String, dynamic>;
showAppointmentDetail(raw); // open the original API record
},
);
Hex color strings #
If your backend returns "color": "#3B82F6" instead of an ARGB int:
Color hexToColor(String hex) {
final h = hex.replaceAll('#', '');
final padded = h.length == 6 ? 'FF$h' : h;
return Color(int.parse(padded, radix: 16));
}
// In your map function:
color: hexToColor(j['color'] as String? ?? '#3B82F6'),
Single-day vs multi-day vs all-day #
| Type | JSON shape | Result on the calendar |
|---|---|---|
| Single day, timed | start: "2026-04-27T09:00:00Z", end: "2026-04-27T10:00:00Z" |
One ribbon on Apr 27 |
| Multi-day, timed | start: "2026-04-27T09:00:00Z", end: "2026-04-29T17:00:00Z" |
Continuous ribbon Apr 27–29 |
| Single day, all-day | start: "2026-04-27T00:00:00Z", end: "2026-04-27T23:59:59Z" |
One ribbon on Apr 27 |
| Multi-day, all-day | start: "2026-05-10", end: "2026-05-15" |
Continuous ribbon May 10–15 |
The month view renders by calendar date only — time-of-day is preserved on the model but doesn't affect layout. Any start/end combination where end >= start works.
Pagination — fetch only the visible month #
For large datasets, don't load everything at once. Use onMonthChanged to fetch the visible window:
OpsMonthCalendar(
onMonthChanged: (DateTime month) async {
final from = month.toIso8601String();
final to = DateTime(month.year, month.month + 1).toIso8601String();
final res = await http.get(Uri.parse(
'https://api.example.com/events?from=$from&to=$to',
));
final fetched = mapApiResponse(jsonDecode(res.body));
// hand off to your state — calendar rebuilds with the new events
context.read<EventBloc>().add(EventsLoaded(fetched));
},
);
CalendarEvent schema (Dart) #
CalendarEvent(
id: String, // required, stable
title: String, // required, ribbon label
start: DateTime, // required, inclusive
end: DateTime, // required, inclusive, must be >= start
color: Color, // optional, default #3B82F6 blue
subtitle: String?, // optional
metadata: Map<String, dynamic>?, // optional, your bag
);
Semantics
| Field | Notes |
|---|---|
id |
Used as a stability key for the ribbon widget. Use the same id you'd use to reference the event in your backend (UUID, primary key, etc.). |
title |
Rendered in the ribbon. Truncated with ellipsis if it doesn't fit. |
start / end |
Inclusive date range. Single-day events have end on the same calendar day as start. The time component is preserved on the model but the month grid renders by date only. |
color |
Ribbon background. Pick from your design system (priority colors, category colors, owner avatar color, etc.). |
subtitle |
Reserved for richer ribbon content in future versions; currently unused by the renderer but available on tap. |
metadata |
Free-form bag for anything you'll need in onEventTap. Round-trips through JSON only if all values are JSON-encodable. |
Keeping your domain model intact #
If you already have a Appointment / Event / etc. class, don't replace it — convert at the render boundary and recover the original via metadata:
CalendarEvent toCalendarEvent(Appointment a) => CalendarEvent(
id: a.id,
title: a.subject,
start: a.startsAt,
end: a.endsAt,
color: a.priorityColor,
metadata: {'appointment': a}, // keep the domain object alive
);
OpsMonthCalendar(
events: appointments.map(toCalendarEvent).toList(),
onEventTap: (e) {
final a = e.metadata!['appointment'] as Appointment;
openAppointmentDetail(a); // recover and use as the original type
},
);
Mapping from Firestore #
final stream = FirebaseFirestore.instance
.collection('appointments')
.where('starts_at', isGreaterThanOrEqualTo: monthStart)
.where('starts_at', isLessThan: monthEnd)
.snapshots();
StreamBuilder<QuerySnapshot>(
stream: stream,
builder: (context, snap) {
final events = (snap.data?.docs ?? []).map((doc) {
final d = doc.data() as Map<String, dynamic>;
return CalendarEvent(
id: doc.id,
title: d['title'] as String,
start: (d['starts_at'] as Timestamp).toDate(),
end: (d['ends_at'] as Timestamp).toDate(),
color: Color(d['color_argb'] as int),
metadata: {'docRef': doc.reference},
);
}).toList();
return OpsMonthCalendar(events: events);
},
);
Color by category #
final categoryColors = <String, Color>{
'meeting': Color(0xFF3B82F6),
'travel': Color(0xFFF59E0B),
'personal': Color(0xFF10B981),
'oncall': Color(0xFFEF4444),
};
CalendarEvent toCalendarEvent(MyEvent e) => CalendarEvent(
id: e.id,
title: e.title,
start: e.start,
end: e.end,
color: categoryColors[e.category] ?? const Color(0xFF6B7280),
metadata: {'category': e.category, 'src': e},
);
Resources (people, rooms, equipment) #
The widget doesn't render a resource axis (that's a different view type — out of scope for v1). The common workaround: prefix the title and color by resource:
CalendarEvent(
id: 'r1-e42',
title: 'Alice · Standup', // resource in title
start: ...,
end: ...,
color: aliceColor, // resource via color
metadata: {'resourceId': 'alice', 'eventId': '42'},
);
For multi-resource overlap, list them comma-separated, or generate one CalendarEvent per (event × resource) tuple if you want each resource to render separately.
Recurrence (RRULE) #
Not built into the package — recurrence rules belong in your domain layer. Expand the rule into individual CalendarEvent instances on your side before passing to the calendar:
List<CalendarEvent> expandRecurring(MyRecurringEvent r, DateTimeRange window) {
final dates = expandRruleInWindow(r.rrule, r.start, window); // your impl
return dates.map((d) => CalendarEvent(
id: '${r.id}-${d.toIso8601String()}',
title: r.title,
start: d,
end: d.add(r.duration),
color: r.color,
metadata: {'sourceId': r.id, 'instanceDate': d},
)).toList();
}
For RRULE parsing in Dart, see the rrule package on pub.dev.
Working set: how many events can I pass? #
The page-level pre-filter clips the global event list to events overlapping the visible 6-week grid before per-week layout. In practice:
- < 1,000 events globally — pass the full list, filtering is cheap.
- 1,000 – 10,000 events — fine, but pre-filter to a wider window (e.g. ±1 year of the visible month) to keep
events:argument size sensible. - > 10,000 events — fetch only the visible month from your data source on
onMonthChanged. There's no benefit to keeping years of events in widget memory.
JSON round-trip #
Both CalendarEvent and CalendarConfig are freezed + json_serializable:
final json = event.toJson(); // Map<String, dynamic>
final back = CalendarEvent.fromJson(json);
assert(back == event);
Color is encoded as a 32-bit ARGB integer via the bundled ColorConverter. DateTime uses ISO 8601 strings (Dart's default). metadata survives the round-trip if every value in it is itself JSON-encodable.
State management #
The widget itself is state-management-agnostic — it accepts data and exposes callbacks. Pick whatever fits your project.
With BLoC #
BlocBuilder<EventBloc, EventState>(
builder: (context, state) => OpsMonthCalendar(
initialMonth: state.visibleMonth,
events: state.events,
selectedDate: state.selectedDate,
onMonthChanged: (m) =>
context.read<EventBloc>().add(MonthChanged(m)),
onDateTap: (d) =>
context.read<EventBloc>().add(DateSelected(d)),
onEventTap: (e) =>
context.read<EventBloc>().add(EventOpened(e)),
),
);
A complete BLoC example with chevron header + selection + snack-bar feedback lives in example/.
With Riverpod #
class CalendarPage extends ConsumerWidget {
const CalendarPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(calendarProvider);
return OpsMonthCalendar(
events: state.events,
selectedDate: state.selectedDate,
onDateTap: (d) => ref.read(calendarProvider.notifier).select(d),
onEventTap: (e) => ref.read(calendarProvider.notifier).open(e),
onMonthChanged: (m) =>
ref.read(calendarProvider.notifier).setVisibleMonth(m),
);
}
}
With Provider / setState #
class _CalendarPageState extends State<CalendarPage> {
DateTime? _selected;
final _events = <CalendarEvent>[/* ... */];
@override
Widget build(BuildContext context) {
return OpsMonthCalendar(
events: _events,
selectedDate: _selected,
onDateTap: (d) => setState(() => _selected = d),
);
}
}
Theming #
Two ways to style the calendar.
Per-instance #
OpsMonthCalendar(
theme: const OpsCalendarTheme(
todayHighlightColor: Color(0xFFFEF3C7),
selectedDayColor: Color(0xFF111827),
ribbonHeight: 22,
ribbonRadius: 6,
),
);
Globally via ThemeData #
OpsCalendarTheme is a ThemeExtension, so register it once:
MaterialApp(
theme: ThemeData(
useMaterial3: true,
extensions: const [
OpsCalendarTheme(
cellBorderColor: Color(0xFFE5E7EB),
ribbonHeight: 20,
),
],
),
// every OpsMonthCalendar without an explicit `theme:` picks this up
);
Available theme fields #
| Field | Default |
|---|---|
cellBorderColor |
#E5E7EB |
cellBackgroundColor |
#FFFFFF |
outsideMonthCellColor |
#F9FAFB |
todayHighlightColor |
#DBEAFE |
selectedDayColor |
#3B82F6 |
weekdayLabelStyle |
small grey 600 |
dayNumberStyle |
small dark 500 |
outsideMonthDayNumberStyle |
small grey 400 |
ribbonTextStyle |
white 600, 11px |
moreIndicatorStyle |
grey 500, 11px |
ribbonHeight |
18 |
ribbonGap |
2 |
ribbonHorizontalPadding |
4 |
ribbonRadius |
4 |
cellPadding |
EdgeInsets.all(4) |
Configuration #
OpsMonthCalendar(
config: const CalendarConfig(
firstDayOfWeek: DateTime.sunday, // 1=Mon ... 7=Sun
maxVisibleLanes: 4, // default: 3
showWeekdayLabels: true,
locale: 'en_US', // optional override
),
);
firstDayOfWeek: ISO 8601 —DateTime.monday(1) throughDateTime.sunday(7).maxVisibleLanes: max ribbons stacked per cell. Anything beyond becomes+N more.showWeekdayLabels: hide the Mon/Tue/… header row.locale: weekday labels follow the ambientMaterialLocalizationsby default. This field is reserved for explicit overrides in future releases.
Events & JSON #
CalendarEvent is a freezed data class with full JSON support:
final event = CalendarEvent(
id: 'e1',
title: 'Sprint planning',
start: DateTime(2026, 4, 27),
end: DateTime(2026, 4, 27),
color: const Color(0xFF3B82F6),
subtitle: '9:00 - 10:00',
metadata: {'attendees': 4},
);
final json = event.toJson();
// {
// "id": "e1",
// "title": "Sprint planning",
// "start": "2026-04-27T00:00:00.000",
// "end": "2026-04-27T00:00:00.000",
// "color": -16225983, // ARGB int
// "subtitle": "9:00 - 10:00",
// "metadata": {"attendees": 4}
// }
final round = CalendarEvent.fromJson(json);
CalendarConfig round-trips the same way.
Event semantics #
startandendare inclusive. A single-day event hasendon the same calendar day asstart. The time component is preserved on the model but the month grid renders by date only.- Multi-day events spanning week boundaries render as separate ribbons per week, with squared-off corners on the side that continues into the adjacent week.
Mapping from your domain model #
If your backend returns a different shape (an Appointment, a Google Calendar event, etc.), keep it that way — convert to CalendarEvent only at the render boundary:
extension AppointmentToCalendarEvent on Appointment {
CalendarEvent toCalendarEvent() => CalendarEvent(
id: id,
title: subject,
start: startsAt,
end: endsAt,
color: priorityColor,
metadata: {'appointmentRef': this},
);
}
OpsMonthCalendar(
events: appointments.map((a) => a.toCalendarEvent()).toList(),
onEventTap: (e) {
final a = e.metadata!['appointmentRef'] as Appointment;
// open detail screen, etc.
},
);
API reference #
OpsMonthCalendar #
| Parameter | Type | Default |
|---|---|---|
initialMonth |
DateTime? |
current month |
events |
List<CalendarEvent> |
[] |
config |
CalendarConfig |
CalendarConfig() |
theme |
OpsCalendarTheme? |
falls back to ThemeData extension, then defaults |
selectedDate |
DateTime? |
none |
controller |
OpsCalendarController? |
none |
onMonthChanged |
ValueChanged<DateTime>? |
— |
onEventTap |
void Function(CalendarEvent)? |
— |
onDateTap |
void Function(DateTime)? |
— |
onDateLongPress |
void Function(DateTime)? |
— |
OpsCalendarController #
| Member | Description |
|---|---|
visibleMonth |
currently centered month, or null |
isAttached |
whether bound to a live widget |
nextMonth({duration, curve}) |
animate forward one month |
previousMonth({duration, curve}) |
animate back one month |
goToMonth(target, {duration, curve}) |
animate to a specific month |
jumpToMonth(target) |
jump instantly |
goToToday({duration, curve}) |
animate to current month |
addListener(VoidCallback) |
inherited from ChangeNotifier |
CalendarEvent #
| Field | Type | Default |
|---|---|---|
id |
String |
required |
title |
String |
required |
start |
DateTime |
required |
end |
DateTime |
required, must be ≥ start |
color |
Color |
#3B82F6 |
subtitle |
String? |
none |
metadata |
Map<String, dynamic>? |
none |
Architecture #
lib/
├── ops_calendar.dart # Public barrel (5 exports)
└── src/
├── controllers/
│ └── ops_calendar_controller.dart # Programmatic control
├── models/ # Freezed + json_serializable
│ ├── calendar_event.dart
│ ├── calendar_config.dart
│ ├── ops_calendar_theme.dart # ThemeExtension
│ ├── ribbon_segment.dart # internal layout result
│ └── color_converter.dart # Color <-> int JSON
├── core/ # Pure Dart, no Flutter
│ ├── calendar_date_utils.dart
│ └── ribbon_layout.dart # Lane-packing algorithm
└── widgets/
├── ops_month_calendar.dart # Public widget
├── month_page.dart # 6×7 grid per page
├── week_row.dart # cells + ribbon overlay
├── day_cell.dart # background + day number
├── ribbon.dart # single ribbon
└── weekday_header.dart # Mon Tue ...
core/ has zero Flutter imports — date math and the lane-packing algorithm are unit-tested in pure Dart.
Performance #
- Each
MonthPageis wrapped in aRepaintBoundaryso adjacent pages composite as cached layers during a swipe. - Ribbon layout is computed once per page build in
MonthPageand passed toWeekRow— not recomputed per row rebuild. - Events are pre-filtered to those overlapping the displayed 6-week grid before per-week packing.
DateTime.now()is hoisted fromWeekRowtoMonthPage(one call per page rebuild instead of six).PageView.builderrecycles month pages — only ~3 pages live in memory at a time.
If you observe jank, run with flutter run --release first — debug builds are 5–10× slower than release.
Development #
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter test
flutter analyze
The example app:
cd example
flutter create . # one-time: generate platform scaffolding
flutter pub get
flutter run --release
License #
MIT — see LICENSE.