Flutter Calendar Localization: Date Pickers, Events, and Scheduling Apps
Build calendar applications that work beautifully in any language. This guide covers localizing date pickers, event displays, recurring schedules, and time zone handling in Flutter.
Calendar Localization Challenges
Calendar apps need localization for:
- Date formats - Day/month/year order varies by region
- Week start - Sunday vs Monday vs Saturday
- Month/day names - Full translations
- Time formats - 12-hour vs 24-hour
- Holidays - Region-specific holidays
- Time zones - Display and scheduling
Localized Date Picker
Custom Date Picker with Full Localization
class LocalizedDatePicker extends StatefulWidget {
final DateTime initialDate;
final DateTime firstDate;
final DateTime lastDate;
final Function(DateTime) onDateSelected;
@override
State<LocalizedDatePicker> createState() => _LocalizedDatePickerState();
}
class _LocalizedDatePickerState extends State<LocalizedDatePicker> {
late DateTime _selectedDate;
late DateTime _displayedMonth;
@override
void initState() {
super.initState();
_selectedDate = widget.initialDate;
_displayedMonth = DateTime(_selectedDate.year, _selectedDate.month);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
final calendarHelper = CalendarLocalizationHelper(locale);
return Column(
children: [
// Month navigation
_buildMonthHeader(l10n, calendarHelper),
// Day of week headers
_buildWeekdayHeaders(calendarHelper),
// Calendar grid
_buildCalendarGrid(calendarHelper),
],
);
}
Widget _buildMonthHeader(
AppLocalizations l10n,
CalendarLocalizationHelper helper,
) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.chevron_left),
tooltip: l10n.previousMonth,
onPressed: _goToPreviousMonth,
),
GestureDetector(
onTap: () => _showMonthYearPicker(l10n),
child: Text(
helper.formatMonthYear(_displayedMonth),
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
icon: Icon(Icons.chevron_right),
tooltip: l10n.nextMonth,
onPressed: _goToNextMonth,
),
],
),
);
}
Widget _buildWeekdayHeaders(CalendarLocalizationHelper helper) {
final weekdays = helper.getWeekdayNames(narrow: true);
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: weekdays.map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[600],
),
),
),
);
}).toList(),
),
);
}
Widget _buildCalendarGrid(CalendarLocalizationHelper helper) {
final days = helper.getMonthDays(_displayedMonth);
return GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
),
itemCount: days.length,
itemBuilder: (context, index) {
final day = days[index];
if (day == null) return SizedBox();
return _buildDayCell(day, helper);
},
);
}
Widget _buildDayCell(DateTime day, CalendarLocalizationHelper helper) {
final isSelected = _isSameDay(day, _selectedDate);
final isToday = _isSameDay(day, DateTime.now());
final isCurrentMonth = day.month == _displayedMonth.month;
final isWeekend = helper.isWeekend(day);
return InkWell(
onTap: () {
setState(() => _selectedDate = day);
widget.onDateSelected(day);
},
customBorder: CircleBorder(),
child: Container(
margin: EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? Theme.of(context).primaryColor
: isToday
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
border: isToday && !isSelected
? Border.all(color: Theme.of(context).primaryColor)
: null,
),
child: Center(
child: Text(
'${day.day}',
style: TextStyle(
color: isSelected
? Colors.white
: !isCurrentMonth
? Colors.grey[400]
: isWeekend
? Colors.red[400]
: null,
fontWeight: isToday ? FontWeight.bold : null,
),
),
),
),
);
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
}
Calendar Localization Helper
class CalendarLocalizationHelper {
final String locale;
late final DateFormat _monthYearFormat;
late final DateFormat _fullDateFormat;
late final int _firstDayOfWeek;
CalendarLocalizationHelper(this.locale) {
_monthYearFormat = DateFormat.yMMMM(locale);
_fullDateFormat = DateFormat.yMMMMEEEEd(locale);
_firstDayOfWeek = _getFirstDayOfWeek(locale);
}
int _getFirstDayOfWeek(String locale) {
// Different regions start week on different days
// Sunday = 0, Monday = 1, Saturday = 6
final sundayCountries = ['US', 'CA', 'JP', 'TW', 'KR', 'IL', 'SA', 'AE'];
final saturdayCountries = ['AF', 'DZ', 'BH', 'EG', 'IQ', 'JO', 'KW', 'LY'];
final country = locale.contains('_')
? locale.split('_')[1]
: locale.toUpperCase();
if (saturdayCountries.contains(country)) return 6;
if (sundayCountries.contains(country)) return 0;
return 1; // Monday default (most of the world)
}
String formatMonthYear(DateTime date) {
return _monthYearFormat.format(date);
}
String formatFullDate(DateTime date) {
return _fullDateFormat.format(date);
}
List<String> getWeekdayNames({bool narrow = false}) {
final formatter = narrow
? DateFormat.E(locale)
: DateFormat.EEEE(locale);
// Generate weekday names starting from the locale's first day
final names = <String>[];
final baseDate = DateTime(2024, 1, 7); // A Sunday
for (var i = 0; i < 7; i++) {
final dayIndex = (_firstDayOfWeek + i) % 7;
final date = baseDate.add(Duration(days: dayIndex));
final name = formatter.format(date);
names.add(narrow ? name.substring(0, 1) : name);
}
return names;
}
List<String> getMonthNames({bool short = false}) {
final formatter = short
? DateFormat.MMM(locale)
: DateFormat.MMMM(locale);
return List.generate(12, (i) {
return formatter.format(DateTime(2024, i + 1));
});
}
List<DateTime?> getMonthDays(DateTime month) {
final firstDay = DateTime(month.year, month.month, 1);
final lastDay = DateTime(month.year, month.month + 1, 0);
// Calculate offset for first day based on locale's week start
final firstWeekday = firstDay.weekday % 7; // Convert to 0-6 (Sun-Sat)
final offset = (firstWeekday - _firstDayOfWeek + 7) % 7;
final days = <DateTime?>[];
// Add empty cells for offset
for (var i = 0; i < offset; i++) {
days.add(null);
}
// Add all days of the month
for (var i = 1; i <= lastDay.day; i++) {
days.add(DateTime(month.year, month.month, i));
}
// Pad to complete the last week
while (days.length % 7 != 0) {
days.add(null);
}
return days;
}
bool isWeekend(DateTime date) {
// Weekend days vary by locale
final fridaySaturdayWeekend = ['SA', 'AE', 'BH', 'EG', 'IQ', 'JO', 'KW'];
final country = locale.contains('_')
? locale.split('_')[1]
: locale.toUpperCase();
if (fridaySaturdayWeekend.contains(country)) {
return date.weekday == DateTime.friday ||
date.weekday == DateTime.saturday;
}
// Standard Saturday/Sunday weekend
return date.weekday == DateTime.saturday ||
date.weekday == DateTime.sunday;
}
}
Localized Event Display
Event List with Time Formatting
class LocalizedEventList extends StatelessWidget {
final List<CalendarEvent> events;
final DateTime selectedDate;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
final dayEvents = events.where((e) =>
e.startTime.year == selectedDate.year &&
e.startTime.month == selectedDate.month &&
e.startTime.day == selectedDate.day
).toList();
if (dayEvents.isEmpty) {
return _buildEmptyState(l10n, locale);
}
return ListView.builder(
itemCount: dayEvents.length,
itemBuilder: (context, index) {
return _buildEventTile(dayEvents[index], l10n, locale);
},
);
}
Widget _buildEmptyState(AppLocalizations l10n, String locale) {
final dateFormatter = DateFormat.yMMMMEEEEd(locale);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_available, size: 64, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
l10n.noEventsOn(dateFormatter.format(selectedDate)),
style: TextStyle(color: Colors.grey[600]),
),
SizedBox(height: 8),
TextButton.icon(
icon: Icon(Icons.add),
label: Text(l10n.createEvent),
onPressed: () {},
),
],
),
);
}
Widget _buildEventTile(
CalendarEvent event,
AppLocalizations l10n,
String locale,
) {
final timeFormatter = DateFormat.jm(locale);
final isAllDay = event.isAllDay;
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: event.color,
borderRadius: BorderRadius.circular(2),
),
),
title: Text(event.getTitle(locale)),
subtitle: Text(
isAllDay
? l10n.allDay
: '${timeFormatter.format(event.startTime)} - ${timeFormatter.format(event.endTime)}',
),
trailing: event.isRecurring
? Tooltip(
message: _getRecurrenceText(event, l10n),
child: Icon(Icons.repeat, size: 20),
)
: null,
onTap: () => _showEventDetails(event),
),
);
}
String _getRecurrenceText(CalendarEvent event, AppLocalizations l10n) {
switch (event.recurrence) {
case Recurrence.daily:
return l10n.repeatsDaily;
case Recurrence.weekly:
return l10n.repeatsWeekly;
case Recurrence.monthly:
return l10n.repeatsMonthly;
case Recurrence.yearly:
return l10n.repeatsYearly;
default:
return '';
}
}
}
class CalendarEvent {
final String id;
final Map<String, String> titles;
final Map<String, String> descriptions;
final DateTime startTime;
final DateTime endTime;
final bool isAllDay;
final Color color;
final Recurrence? recurrence;
final String? location;
bool get isRecurring => recurrence != null;
String getTitle(String locale) {
return titles[locale] ?? titles['en'] ?? titles.values.first;
}
String getDescription(String locale) {
return descriptions[locale] ?? descriptions['en'] ?? '';
}
}
enum Recurrence { daily, weekly, monthly, yearly }
Time Zone Handling
Localized Time Zone Display
class TimeZoneLocalizer {
final String locale;
final AppLocalizations l10n;
TimeZoneLocalizer({required this.locale, required this.l10n});
String formatEventTime(
DateTime eventTime,
String eventTimeZone,
String userTimeZone,
) {
final tz = getLocation(eventTimeZone);
final userTz = getLocation(userTimeZone);
final eventTzTime = TZDateTime.from(eventTime, tz);
final userTzTime = TZDateTime.from(eventTime, userTz);
final timeFormatter = DateFormat.jm(locale);
final formattedTime = timeFormatter.format(userTzTime);
// Show original time zone if different
if (eventTimeZone != userTimeZone) {
final offset = _formatOffset(eventTzTime.timeZoneOffset);
return '$formattedTime (${_getTimeZoneAbbr(eventTimeZone)} $offset)';
}
return formattedTime;
}
String _formatOffset(Duration offset) {
final hours = offset.inHours;
final minutes = offset.inMinutes.abs() % 60;
final sign = hours >= 0 ? '+' : '';
if (minutes == 0) {
return 'UTC$sign$hours';
}
return 'UTC$sign$hours:${minutes.toString().padLeft(2, '0')}';
}
String _getTimeZoneAbbr(String timeZone) {
// Common abbreviations
final abbreviations = {
'America/New_York': 'EST',
'America/Los_Angeles': 'PST',
'Europe/London': 'GMT',
'Europe/Paris': 'CET',
'Asia/Tokyo': 'JST',
'Asia/Shanghai': 'CST',
'Australia/Sydney': 'AEST',
};
return abbreviations[timeZone] ?? timeZone.split('/').last;
}
List<TimeZoneOption> getCommonTimeZones() {
return [
TimeZoneOption(
id: 'America/New_York',
name: l10n.timeZoneNewYork,
offset: 'UTC-5',
),
TimeZoneOption(
id: 'America/Los_Angeles',
name: l10n.timeZoneLosAngeles,
offset: 'UTC-8',
),
TimeZoneOption(
id: 'Europe/London',
name: l10n.timeZoneLondon,
offset: 'UTC+0',
),
TimeZoneOption(
id: 'Europe/Paris',
name: l10n.timeZoneParis,
offset: 'UTC+1',
),
TimeZoneOption(
id: 'Asia/Tokyo',
name: l10n.timeZoneTokyo,
offset: 'UTC+9',
),
TimeZoneOption(
id: 'Asia/Dubai',
name: l10n.timeZoneDubai,
offset: 'UTC+4',
),
];
}
}
class TimeZoneOption {
final String id;
final String name;
final String offset;
TimeZoneOption({
required this.id,
required this.name,
required this.offset,
});
}
Relative Date Formatting
Human-Readable Relative Dates
class RelativeDateFormatter {
final String locale;
final AppLocalizations l10n;
RelativeDateFormatter({required this.locale, required this.l10n});
String format(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dateOnly = DateTime(date.year, date.month, date.day);
final difference = dateOnly.difference(today).inDays;
if (difference == 0) return l10n.today;
if (difference == 1) return l10n.tomorrow;
if (difference == -1) return l10n.yesterday;
if (difference > 1 && difference < 7) {
// This week - show day name
return DateFormat.EEEE(locale).format(date);
}
if (difference >= 7 && difference < 14) {
return l10n.nextWeek;
}
if (difference >= -7 && difference < -1) {
return l10n.lastWeek;
}
// Show full date
return DateFormat.yMMMd(locale).format(date);
}
String formatRange(DateTime start, DateTime end) {
final startStr = format(start);
final endStr = format(end);
if (_isSameDay(start, end)) {
return startStr;
}
if (start.year == end.year && start.month == end.month) {
// Same month
return l10n.dateRange(
'${start.day}',
DateFormat.yMMMd(locale).format(end),
);
}
return l10n.dateRange(startStr, endStr);
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
}
ARB File Structure
{
"@@locale": "en",
"previousMonth": "Previous month",
"nextMonth": "Next month",
"today": "Today",
"tomorrow": "Tomorrow",
"yesterday": "Yesterday",
"nextWeek": "Next week",
"lastWeek": "Last week",
"dateRange": "{start} - {end}",
"@dateRange": {
"placeholders": {
"start": {"type": "String"},
"end": {"type": "String"}
}
},
"noEventsOn": "No events on {date}",
"@noEventsOn": {
"placeholders": {"date": {"type": "String"}}
},
"createEvent": "Create event",
"allDay": "All day",
"repeatsDaily": "Repeats daily",
"repeatsWeekly": "Repeats weekly",
"repeatsMonthly": "Repeats monthly",
"repeatsYearly": "Repeats yearly",
"eventTitle": "Event title",
"eventDescription": "Description",
"eventLocation": "Location",
"eventStartTime": "Start time",
"eventEndTime": "End time",
"eventTimeZone": "Time zone",
"eventReminder": "Reminder",
"reminderNone": "None",
"reminderAtTime": "At time of event",
"reminderMinutesBefore": "{count, plural, =1{1 minute before} other{{count} minutes before}}",
"@reminderMinutesBefore": {
"placeholders": {"count": {"type": "int"}}
},
"reminderHoursBefore": "{count, plural, =1{1 hour before} other{{count} hours before}}",
"@reminderHoursBefore": {
"placeholders": {"count": {"type": "int"}}
},
"reminderDaysBefore": "{count, plural, =1{1 day before} other{{count} days before}}",
"@reminderDaysBefore": {
"placeholders": {"count": {"type": "int"}}
},
"timeZoneNewYork": "New York",
"timeZoneLosAngeles": "Los Angeles",
"timeZoneLondon": "London",
"timeZoneParis": "Paris",
"timeZoneTokyo": "Tokyo",
"timeZoneDubai": "Dubai",
"weekStartsOn": "Week starts on",
"sunday": "Sunday",
"monday": "Monday",
"saturday": "Saturday",
"viewDay": "Day",
"viewWeek": "Week",
"viewMonth": "Month",
"viewYear": "Year"
}
Conclusion
Calendar localization in Flutter requires:
- Locale-aware date formats for display
- Week start configuration by region
- Localized month/day names
- Time zone handling for global events
- Relative date formatting for user-friendly display
With proper localization, your calendar app will feel native to users worldwide.