Building Calendar and Scheduling UI in Flutter
Building calendar and scheduling UI in Flutter: package options, week and day views, time zones done right, recurring events, drag to reschedule and performance.

A calendar looks like a solved problem until you build one. The month grid takes an afternoon. Then come the questions: what happens to a 9:00 meeting when the user flies to another country? How should a weekly event that skips holidays be shown? And why does the day view stutter when one person has forty shifts in a week?
Schedule Pro, a scheduling app I worked on, needed a responsive interface that worked on everything from phones to tablets. Scheduling UI touches a lot of tricky parts of Flutter at once. Here are the questions I now ask at the start of any calendar feature, with the answers I’ve settled on.
Should I use a package or build my own?
Usually both. Use a package for the parts that are pure UI and well understood, and write your own code for the parts that encode your business rules.
The common options:
- table_calendar is a popular open-source package for month, two-week and week grids, with selection, ranges and event markers. It’s a great fit for the “pick a day, see its items below” pattern.
- Syncfusion’s calendar is a full-featured scheduler with day, week, timeline and month views, recurrence and drag-and-drop built in. It’s a commercial product with its own license terms. There’s a free community license for qualifying individuals and small companies, but read the terms before building a client’s product on it.
- Custom views built with
CustomScrollView,StackandCustomPaint. You’ll need these when your day view has to show something no package expects, like shift coverage or resource lanes.
Licensing applies to every package, not just commercial ones. Check the license of anything you add, and write it down for whoever maintains the app after you.
A basic table_calendar setup looks like this:
TableCalendar<Event>(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _format,
selectedDayPredicate: (day) => isSameDay(day, _selectedDay),
eventLoader: (day) => _eventsByDay[_dayKey(day)] ?? const [],
onDaySelected: (selected, focused) {
setState(() {
_selectedDay = selected;
_focusedDay = focused;
});
},
onFormatChanged: (format) => setState(() => _format = format),
onPageChanged: (focused) => _focusedDay = focused,
)
Notice that focusedDay is state you own. If you forget to store it from onPageChanged, the calendar jumps back to the old month the next time the widget rebuilds.
How do I build week and day views?
A day view is a vertical timeline: hour rows on the left, events placed in absolute positions on the right. The key is one function that converts time into pixels, used everywhere:
const hourHeight = 64.0;
double offsetFor(DateTime time) =>
(time.hour + time.minute / 60) * hourHeight;
double heightFor(DateTime start, DateTime end) =>
end.difference(start).inMinutes / 60 * hourHeight;
Put the hour grid in a CustomPaint (it’s just lines and labels), and position the events in a Stack on top using those two functions. Overlapping events need a layout pass. Group events that overlap in time, give each group a number of columns, and divide the width between them. It’s a classic algorithm and worth unit testing on its own.
A week view is seven day columns that share one time axis and scroll vertically together. On a phone, seven columns are narrow, so I usually show three days or a single day with swipe navigation. On a tablet, the full week fits comfortably. This is where a phone-to-tablet requirement turns into code: let LayoutBuilder choose the number of visible days from the available width, instead of designing one layout and stretching it. I cover that approach in responsive Flutter layouts for phone and tablet.
Open the day view scrolled to “now” (or the first event) rather than midnight, and draw a “current time” line. Small touch, big difference.
How should I handle time zones?
This is where calendar apps get bug reports. The rule that prevents most of them:
Store instants in UTC. Show them in the relevant local zone.
The API sends and receives UTC (ISO 8601 with a Z), the database stores UTC, and the code converts only at the edge, when displaying a time or reading one from a picker. Dart’s DateTime only knows about UTC and the device’s local zone. For anything else, such as showing a shift in the workplace’s zone while the manager travels, use the timezone package:
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
void initTimeZones() => tzdata.initializeTimeZones();
tz.TZDateTime inZone(DateTime utc, String zoneId) =>
tz.TZDateTime.from(utc, tz.getLocation(zoneId));
// inZone(shift.startUtc, 'America/Chicago')
Be clear about whose time a value is. For a team schedule, the shift happens in the workplace’s zone. Store that zone’s IANA ID (like America/Chicago) next to the event, and when it’s not obvious, show the zone in the UI. Getting the device’s own IANA zone name needs a small plugin, because Dart’s DateTime.timeZoneName gives an abbreviation, not an ID.
And test daylight saving transitions. A shift that crosses the night the clocks change is not eight hours long, whatever the template says.
What about recurring events?
Don’t invent your own recurrence format. Calendars already have a standard: the RRULE from the iCalendar spec (RFC 5545). A rule like FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=... describes “every Monday, Wednesday and Friday until a date”, and every calendar system understands it.
The concepts you need to model:
- The rule: frequency, interval, which days, and an end (
COUNTorUNTIL). - The start: the first occurrence. Its local time and zone anchor every later occurrence, which is why recurring events must be expanded in their own time zone, not in UTC. Otherwise a weekly 9:00 meeting moves to 8:00 or 10:00 after a DST change.
- Exceptions: dates that are skipped (
EXDATE), and single occurrences that were edited (“move just this one to Thursday”). - Edit scope: “this event”, “this and following” or “all events”. “This and following” usually means ending the old rule and starting a new one.
I prefer expanding occurrences on the server for the visible date range, so every client and every report agree. If you need to expand them on the device, there’s an rrule package on pub.dev. Test it against the edge cases you care about before relying on it.
How do I make drag to reschedule feel right?
Use LongPressDraggable on the event tile, so a drag doesn’t fight with vertical scrolling, and DragTargets for the day columns. While dragging, convert the pointer’s y-position back into a time and snap it to a sensible increment:
DateTime snapToSlot(DateTime day, double dy, {int slotMinutes = 15}) {
final rawMinutes = (dy / hourHeight * 60).round();
final snapped = (rawMinutes / slotMinutes).round() * slotMinutes;
final clamped = snapped.clamp(0, 24 * 60 - slotMinutes);
return DateTime(day.year, day.month, day.day).add(Duration(minutes: clamped));
}
Then the rules that make it trustworthy:
- Show a preview of where the event will land, with its new time, while dragging.
- Validate the drop: overlaps, availability and business rules. If the drop is invalid, animate the event back and say why.
- Apply the change optimistically, but roll back if the server rejects it, and offer undo. One accidental drag shouldn’t silently move someone’s shift.
- Auto-scroll when the dragged event nears the top or bottom edge.
Are date pickers and grids accessible?
Often not, out of the box. A few things to check:
- Calendar cells need semantic labels like “Tuesday, March 4, 3 events”, not just “4”.
- Don’t show event status with colored dots alone. Add a count or an icon too.
showDatePickeroffers a text-input mode. Typing a date is much easier than tapping through months for screen reader users, and for anyone entering a birth date.- Drag to reschedule must have an alternative, such as an “Edit time” action. Not everyone can do a long-press drag.
- Check that time labels still fit at large text sizes.
More in accessibility in Flutter apps.
How do I keep it fast with lots of events?
- Index events by day. Build a
Mapfrom a normalized day key to its events once, when data arrives.eventLoaderand day columns then look up a day instead of scanning a list on every build. - Load the visible range only, plus a buffer of a week or so on each side. Fetch more as the user pages.
- Paint the static parts. The hour grid and separators belong in one
CustomPaint, not hundreds ofContainers. - Rebuild narrowly. Dragging one event shouldn’t rebuild the whole week. Keep drag state close to the dragged widget.
Takeaways
- Use packages for standard grids, and write your own code for the views that encode your business rules. Check every license, especially commercial ones.
- Base all positioning on one time-to-pixel function, and let width decide how many days are visible.
- Store UTC, display in the relevant zone with the timezone package, and test DST transitions.
- Model recurrence with RRULE, exceptions and edit scopes. Expand occurrences in the event’s own zone, ideally on the server.
- Snap drags to slots, validate drops, offer undo and provide an accessible alternative.
If you’re planning a scheduling feature and want a second pair of eyes on the data model before the UI gets built, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).