← Back to Blog

Flutter Calendar Localization: Date Pickers, Events, and Scheduling UI

fluttercalendardatepickereventslocalizationscheduling

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:

  1. Locale-aware date formats for display
  2. Week start configuration by region
  3. Localized month/day names
  4. Time zone handling for global events
  5. Relative date formatting for user-friendly display

With proper localization, your calendar app will feel native to users worldwide.

Related Resources