Flutter DatePicker Localization: Formats, Calendars, and Locale-Specific Displays
Date pickers are fundamental UI components that require careful localization in Flutter apps. Different cultures use different date formats, calendar systems, and even different first days of the week. This guide covers everything you need to know about localizing date pickers effectively for global audiences.
Understanding DatePicker Localization Needs
Date pickers require localization for:
- Date formats: MM/DD/YYYY vs DD/MM/YYYY vs YYYY-MM-DD
- First day of week: Sunday vs Monday vs Saturday
- Month and day names: January vs Janvier vs Januar
- Calendar systems: Gregorian, Hijri, Hebrew, Buddhist
- Button labels: OK, Cancel, Select Date
- Accessibility: Screen reader date announcements
- Error messages: Invalid date, date out of range
Basic DatePicker with Flutter Localizations
Start with proper locale setup for the material date picker:
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en', 'US'),
Locale('en', 'GB'),
Locale('de', 'DE'),
Locale('fr', 'FR'),
Locale('ja', 'JP'),
Locale('ar', 'SA'),
],
home: const DatePickerDemo(),
);
}
}
Showing Localized Date Picker
Display a date picker that respects the current locale:
class LocalizedDatePicker extends StatefulWidget {
const LocalizedDatePicker({super.key});
@override
State<LocalizedDatePicker> createState() => _LocalizedDatePickerState();
}
class _LocalizedDatePickerState extends State<LocalizedDatePicker> {
DateTime? _selectedDate;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.datePickerLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
InkWell(
onTap: () => _showDatePicker(context),
child: InputDecorator(
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_today),
hintText: l10n.datePickerHint,
),
child: Text(
_selectedDate != null
? _formatDate(_selectedDate!, locale)
: l10n.datePickerPlaceholder,
),
),
),
],
);
}
Future<void> _showDatePicker(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final date = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
locale: locale,
helpText: l10n.datePickerHelpText,
cancelText: l10n.datePickerCancel,
confirmText: l10n.datePickerConfirm,
fieldLabelText: l10n.datePickerFieldLabel,
fieldHintText: l10n.datePickerFieldHint,
errorFormatText: l10n.datePickerErrorFormat,
errorInvalidText: l10n.datePickerErrorInvalid,
);
if (date != null) {
setState(() => _selectedDate = date);
}
}
String _formatDate(DateTime date, Locale locale) {
return DateFormat.yMMMd(locale.toString()).format(date);
}
}
ARB file entries:
{
"datePickerLabel": "Select a date",
"@datePickerLabel": {
"description": "Label above the date picker field"
},
"datePickerHint": "Choose date",
"@datePickerHint": {
"description": "Hint text in the date field"
},
"datePickerPlaceholder": "No date selected",
"@datePickerPlaceholder": {
"description": "Placeholder when no date is selected"
},
"datePickerHelpText": "Select date",
"@datePickerHelpText": {
"description": "Help text shown at top of date picker"
},
"datePickerCancel": "Cancel",
"@datePickerCancel": {
"description": "Cancel button in date picker"
},
"datePickerConfirm": "OK",
"@datePickerConfirm": {
"description": "Confirm button in date picker"
},
"datePickerFieldLabel": "Enter date",
"@datePickerFieldLabel": {
"description": "Label for manual date entry field"
},
"datePickerFieldHint": "mm/dd/yyyy",
"@datePickerFieldHint": {
"description": "Hint showing expected date format"
},
"datePickerErrorFormat": "Invalid format",
"@datePickerErrorFormat": {
"description": "Error when date format is wrong"
},
"datePickerErrorInvalid": "Invalid date",
"@datePickerErrorInvalid": {
"description": "Error when date is invalid"
}
}
Locale-Specific Date Formatting
Display dates in the format expected by each locale:
class LocalizedDateDisplay extends StatelessWidget {
final DateTime date;
final DateDisplayFormat format;
const LocalizedDateDisplay({
super.key,
required this.date,
this.format = DateDisplayFormat.medium,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
return Text(_formatDate(locale));
}
String _formatDate(Locale locale) {
final localeString = locale.toString();
switch (format) {
case DateDisplayFormat.short:
// 1/15/26 or 15/1/26
return DateFormat.yMd(localeString).format(date);
case DateDisplayFormat.medium:
// Jan 15, 2026 or 15 Jan 2026
return DateFormat.yMMMd(localeString).format(date);
case DateDisplayFormat.long:
// January 15, 2026 or 15 January 2026
return DateFormat.yMMMMd(localeString).format(date);
case DateDisplayFormat.full:
// Wednesday, January 15, 2026
return DateFormat.yMMMMEEEEd(localeString).format(date);
case DateDisplayFormat.relative:
return _getRelativeDate(localeString);
}
}
String _getRelativeDate(String locale) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today'; // Should be localized
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays == -1) {
return 'Tomorrow';
} else if (difference.inDays > 0 && difference.inDays < 7) {
return DateFormat.EEEE(locale).format(date);
} else {
return DateFormat.yMMMd(locale).format(date);
}
}
}
enum DateDisplayFormat { short, medium, long, full, relative }
Date Range Picker with Localization
Handle date range selection with proper localized labels:
class LocalizedDateRangePicker extends StatefulWidget {
const LocalizedDateRangePicker({super.key});
@override
State<LocalizedDateRangePicker> createState() =>
_LocalizedDateRangePickerState();
}
class _LocalizedDateRangePickerState extends State<LocalizedDateRangePicker> {
DateTimeRange? _selectedRange;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.dateRangeLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
InkWell(
onTap: () => _showDateRangePicker(context),
child: InputDecorator(
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.date_range),
),
child: Text(
_selectedRange != null
? _formatDateRange(_selectedRange!, locale)
: l10n.dateRangePlaceholder,
),
),
),
],
);
}
Future<void> _showDateRangePicker(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDateRange: _selectedRange,
locale: locale,
helpText: l10n.dateRangeHelpText,
cancelText: l10n.dateRangeCancel,
confirmText: l10n.dateRangeConfirm,
saveText: l10n.dateRangeSave,
fieldStartLabelText: l10n.dateRangeStartLabel,
fieldEndLabelText: l10n.dateRangeEndLabel,
fieldStartHintText: l10n.dateRangeStartHint,
fieldEndHintText: l10n.dateRangeEndHint,
errorFormatText: l10n.dateRangeErrorFormat,
errorInvalidText: l10n.dateRangeErrorInvalid,
errorInvalidRangeText: l10n.dateRangeErrorInvalidRange,
);
if (range != null) {
setState(() => _selectedRange = range);
}
}
String _formatDateRange(DateTimeRange range, Locale locale) {
final format = DateFormat.yMMMd(locale.toString());
return '${format.format(range.start)} - ${format.format(range.end)}';
}
}
ARB entries:
{
"dateRangeLabel": "Select date range",
"dateRangePlaceholder": "No range selected",
"dateRangeHelpText": "Select start and end dates",
"dateRangeCancel": "Cancel",
"dateRangeConfirm": "OK",
"dateRangeSave": "Save",
"dateRangeStartLabel": "Start date",
"dateRangeEndLabel": "End date",
"dateRangeStartHint": "Start",
"dateRangeEndHint": "End",
"dateRangeErrorFormat": "Invalid date format",
"dateRangeErrorInvalid": "Invalid date",
"dateRangeErrorInvalidRange": "End date must be after start date"
}
Custom Date Picker with Locale-Aware Formatting
Build a custom date picker with full localization control:
class CustomLocalizedDatePicker extends StatefulWidget {
final DateTime? initialDate;
final DateTime firstDate;
final DateTime lastDate;
final ValueChanged<DateTime> onDateSelected;
const CustomLocalizedDatePicker({
super.key,
this.initialDate,
required this.firstDate,
required this.lastDate,
required this.onDateSelected,
});
@override
State<CustomLocalizedDatePicker> createState() =>
_CustomLocalizedDatePickerState();
}
class _CustomLocalizedDatePickerState extends State<CustomLocalizedDatePicker> {
late DateTime _displayedMonth;
DateTime? _selectedDate;
@override
void initState() {
super.initState();
_displayedMonth = widget.initialDate ?? DateTime.now();
_selectedDate = widget.initialDate;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
children: [
// Month navigation header
_buildHeader(l10n, locale, isRTL),
const SizedBox(height: 16),
// Weekday headers
_buildWeekdayHeaders(locale),
const SizedBox(height: 8),
// Calendar grid
_buildCalendarGrid(locale),
],
);
}
Widget _buildHeader(AppLocalizations l10n, Locale locale, bool isRTL) {
final monthFormat = DateFormat.yMMMM(locale.toString());
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(isRTL ? Icons.chevron_right : Icons.chevron_left),
tooltip: l10n.datePickerPreviousMonth,
onPressed: _goToPreviousMonth,
),
Text(
monthFormat.format(_displayedMonth),
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: Icon(isRTL ? Icons.chevron_left : Icons.chevron_right),
tooltip: l10n.datePickerNextMonth,
onPressed: _goToNextMonth,
),
],
);
}
Widget _buildWeekdayHeaders(Locale locale) {
final firstDayOfWeek = _getFirstDayOfWeek(locale);
final weekdays = _getLocalizedWeekdays(locale, firstDayOfWeek);
return Row(
children: weekdays.map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
}).toList(),
);
}
List<String> _getLocalizedWeekdays(Locale locale, int firstDayOfWeek) {
final format = DateFormat.E(locale.toString());
final weekdays = <String>[];
// Generate weekday names starting from the first day of week
for (var i = 0; i < 7; i++) {
final day = (firstDayOfWeek + i) % 7;
// Create a date that falls on the correct weekday
final date = DateTime(2024, 1, 7 + day); // Jan 7, 2024 is a Sunday
weekdays.add(format.format(date));
}
return weekdays;
}
int _getFirstDayOfWeek(Locale locale) {
// First day of week varies by locale
// Sunday = 0, Monday = 1, Saturday = 6
final localeString = locale.toString();
// Countries that start week on Sunday
if (['en_US', 'ja_JP', 'ko_KR', 'zh_CN', 'zh_TW']
.contains(localeString)) {
return DateTime.sunday;
}
// Countries that start week on Saturday
if (['ar_SA', 'ar_AE', 'ar_EG', 'he_IL'].contains(localeString)) {
return DateTime.saturday;
}
// Most countries start on Monday
return DateTime.monday;
}
Widget _buildCalendarGrid(Locale locale) {
final firstDayOfWeek = _getFirstDayOfWeek(locale);
final daysInMonth = DateTime(
_displayedMonth.year,
_displayedMonth.month + 1,
0,
).day;
final firstDayOfMonth = DateTime(
_displayedMonth.year,
_displayedMonth.month,
1,
);
// Calculate offset for the first day
var startOffset = firstDayOfMonth.weekday - firstDayOfWeek;
if (startOffset < 0) startOffset += 7;
final totalCells = startOffset + daysInMonth;
final rows = (totalCells / 7).ceil();
return Column(
children: List.generate(rows, (rowIndex) {
return Row(
children: List.generate(7, (colIndex) {
final cellIndex = rowIndex * 7 + colIndex;
final dayNumber = cellIndex - startOffset + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const Expanded(child: SizedBox(height: 40));
}
final date = DateTime(
_displayedMonth.year,
_displayedMonth.month,
dayNumber,
);
return Expanded(
child: _buildDayCell(date, locale),
);
}),
);
}),
);
}
Widget _buildDayCell(DateTime date, Locale locale) {
final isSelected = _selectedDate != null &&
date.year == _selectedDate!.year &&
date.month == _selectedDate!.month &&
date.day == _selectedDate!.day;
final isToday = date.year == DateTime.now().year &&
date.month == DateTime.now().month &&
date.day == DateTime.now().day;
final isEnabled =
!date.isBefore(widget.firstDate) && !date.isAfter(widget.lastDate);
final numberFormat = NumberFormat('#', locale.toString());
return GestureDetector(
onTap: isEnabled
? () {
setState(() => _selectedDate = date);
widget.onDateSelected(date);
}
: null,
child: Container(
height: 40,
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: isToday
? Theme.of(context).colorScheme.primaryContainer
: null,
shape: BoxShape.circle,
),
child: Center(
child: Text(
numberFormat.format(date.day),
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: isEnabled
? null
: Theme.of(context).disabledColor,
fontWeight: isToday ? FontWeight.bold : null,
),
),
),
),
);
}
void _goToPreviousMonth() {
setState(() {
_displayedMonth = DateTime(
_displayedMonth.year,
_displayedMonth.month - 1,
);
});
}
void _goToNextMonth() {
setState(() {
_displayedMonth = DateTime(
_displayedMonth.year,
_displayedMonth.month + 1,
);
});
}
}
Date Input with Format Validation
Handle manual date entry with locale-aware validation:
class LocalizedDateInput extends StatefulWidget {
final ValueChanged<DateTime?> onDateChanged;
final DateTime? initialDate;
const LocalizedDateInput({
super.key,
required this.onDateChanged,
this.initialDate,
});
@override
State<LocalizedDateInput> createState() => _LocalizedDateInputState();
}
class _LocalizedDateInputState extends State<LocalizedDateInput> {
late TextEditingController _controller;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController();
if (widget.initialDate != null) {
_updateDisplayedDate(widget.initialDate!);
}
}
void _updateDisplayedDate(DateTime date) {
final locale = Localizations.localeOf(context);
_controller.text = DateFormat.yMd(locale.toString()).format(date);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return TextField(
controller: _controller,
decoration: InputDecoration(
labelText: l10n.dateInputLabel,
hintText: _getFormatHint(locale),
errorText: _errorText,
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
tooltip: l10n.dateInputPickerTooltip,
onPressed: () => _openDatePicker(context),
),
),
keyboardType: TextInputType.datetime,
onChanged: (value) => _validateAndParse(value, locale),
);
}
String _getFormatHint(Locale locale) {
// Return format hint based on locale
final localeString = locale.toString();
if (localeString.startsWith('en_US')) {
return 'MM/DD/YYYY';
} else if (localeString.startsWith('de') ||
localeString.startsWith('fr') ||
localeString.startsWith('es')) {
return 'DD/MM/YYYY';
} else if (localeString.startsWith('ja') ||
localeString.startsWith('zh') ||
localeString.startsWith('ko')) {
return 'YYYY/MM/DD';
}
return 'DD/MM/YYYY';
}
void _validateAndParse(String value, Locale locale) {
final l10n = AppLocalizations.of(context)!;
if (value.isEmpty) {
setState(() => _errorText = null);
widget.onDateChanged(null);
return;
}
try {
final date = DateFormat.yMd(locale.toString()).parseStrict(value);
setState(() => _errorText = null);
widget.onDateChanged(date);
} catch (e) {
setState(() => _errorText = l10n.dateInputErrorInvalidFormat);
widget.onDateChanged(null);
}
}
Future<void> _openDatePicker(BuildContext context) async {
final date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
);
if (date != null) {
_updateDisplayedDate(date);
widget.onDateChanged(date);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Relative Date Display
Show dates in relative terms with proper localization:
class LocalizedRelativeDate extends StatelessWidget {
final DateTime date;
const LocalizedRelativeDate({
super.key,
required this.date,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Text(_getRelativeDate(l10n, locale));
}
String _getRelativeDate(AppLocalizations l10n, Locale locale) {
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.relativeDateToday;
} else if (difference == 1) {
return l10n.relativeDateTomorrow;
} else if (difference == -1) {
return l10n.relativeDateYesterday;
} else if (difference > 1 && difference <= 7) {
return l10n.relativeDateInDays(difference);
} else if (difference < -1 && difference >= -7) {
return l10n.relativeDateDaysAgo(difference.abs());
} else if (difference > 7 && difference <= 30) {
final weeks = (difference / 7).round();
return l10n.relativeDateInWeeks(weeks);
} else if (difference < -7 && difference >= -30) {
final weeks = (difference.abs() / 7).round();
return l10n.relativeDateWeeksAgo(weeks);
} else {
return DateFormat.yMMMd(locale.toString()).format(date);
}
}
}
ARB entries:
{
"relativeDateToday": "Today",
"relativeDateTomorrow": "Tomorrow",
"relativeDateYesterday": "Yesterday",
"relativeDateInDays": "{count, plural, =1{In 1 day} other{In {count} days}}",
"@relativeDateInDays": {
"placeholders": {
"count": {"type": "int"}
}
},
"relativeDateDaysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
"@relativeDateDaysAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"relativeDateInWeeks": "{count, plural, =1{In 1 week} other{In {count} weeks}}",
"@relativeDateInWeeks": {
"placeholders": {
"count": {"type": "int"}
}
},
"relativeDateWeeksAgo": "{count, plural, =1{1 week ago} other{{count} weeks ago}}",
"@relativeDateWeeksAgo": {
"placeholders": {
"count": {"type": "int"}
}
}
}
Testing Date Picker Localization
Write comprehensive tests for date picker behavior:
void main() {
group('LocalizedDatePicker Tests', () {
testWidgets('displays date in US format', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en', 'US'),
home: Scaffold(
body: LocalizedDateDisplay(
date: DateTime(2026, 1, 15),
format: DateDisplayFormat.short,
),
),
),
);
expect(find.text('1/15/2026'), findsOneWidget);
});
testWidgets('displays date in German format', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('de', 'DE'),
home: Scaffold(
body: LocalizedDateDisplay(
date: DateTime(2026, 1, 15),
format: DateDisplayFormat.short,
),
),
),
);
expect(find.text('15.1.2026'), findsOneWidget);
});
testWidgets('shows localized month names', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('fr', 'FR'),
home: Scaffold(
body: LocalizedDateDisplay(
date: DateTime(2026, 1, 15),
format: DateDisplayFormat.long,
),
),
),
);
expect(find.textContaining('janvier'), findsOneWidget);
});
});
}
Best Practices Summary
- Always include GlobalMaterialLocalizations: Required for date picker localization
- Use DateFormat with locale: Format dates according to user's locale
- Handle first day of week: Different cultures start weeks differently
- Validate input formats: Accept locale-appropriate date formats
- Provide clear error messages: Help users enter correct format
- Use relative dates: "Today", "Yesterday" are more user-friendly
- Test multiple locales: Verify US, European, and Asian formats
Conclusion
Localizing date pickers in Flutter requires attention to date formats, first day of week, month names, and input validation. By implementing locale-aware formatting, proper validation messages, and comprehensive testing across multiple locales, you ensure users worldwide can easily select and understand dates in your app.