Flutter TimePicker Localization: 12/24 Hour Formats, AM/PM Labels, and Accessibility
Time pickers are essential UI components that require careful localization in Flutter apps. Different cultures use different time formats - 12-hour with AM/PM in the US, 24-hour format in most of Europe, and various conventions for displaying hours and minutes. This guide covers everything you need to know about localizing time pickers effectively.
Understanding TimePicker Localization Needs
Time pickers require localization for:
- Time format: 12-hour (1:30 PM) vs 24-hour (13:30)
- AM/PM labels: AM/PM, a.m./p.m., or nothing for 24h
- Hour display: Leading zeros (01:30 vs 1:30)
- Separator: Colon vs period (13:30 vs 13.30)
- Button labels: OK, Cancel, Select Time
- Accessibility: Screen reader time announcements
- Input modes: Dial vs input field preferences
Basic TimePicker with Flutter Localizations
Start with proper locale setup for the material time 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'), // 12-hour format
Locale('en', 'GB'), // 24-hour format
Locale('de', 'DE'), // 24-hour format
Locale('fr', 'FR'), // 24-hour format
Locale('ja', 'JP'), // 24-hour format
Locale('ar', 'SA'), // RTL, 12-hour format
],
home: const TimePickerDemo(),
);
}
}
Showing Localized Time Picker
Display a time picker that respects the current locale:
class LocalizedTimePicker extends StatefulWidget {
const LocalizedTimePicker({super.key});
@override
State<LocalizedTimePicker> createState() => _LocalizedTimePickerState();
}
class _LocalizedTimePickerState extends State<LocalizedTimePicker> {
TimeOfDay? _selectedTime;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.timePickerLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
InkWell(
onTap: () => _showTimePicker(context),
child: InputDecorator(
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.access_time),
hintText: l10n.timePickerHint,
),
child: Text(
_selectedTime != null
? _formatTime(_selectedTime!, context)
: l10n.timePickerPlaceholder,
),
),
),
],
);
}
Future<void> _showTimePicker(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final time = await showTimePicker(
context: context,
initialTime: _selectedTime ?? TimeOfDay.now(),
helpText: l10n.timePickerHelpText,
cancelText: l10n.timePickerCancel,
confirmText: l10n.timePickerConfirm,
hourLabelText: l10n.timePickerHourLabel,
minuteLabelText: l10n.timePickerMinuteLabel,
errorInvalidText: l10n.timePickerErrorInvalid,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
alwaysUse24HourFormat: _use24HourFormat(context),
),
child: child!,
);
},
);
if (time != null) {
setState(() => _selectedTime = time);
}
}
bool _use24HourFormat(BuildContext context) {
// Check system setting first
return MediaQuery.of(context).alwaysUse24HourFormat;
}
String _formatTime(TimeOfDay time, BuildContext context) {
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
final locale = Localizations.localeOf(context);
if (use24Hour) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final period = time.period == DayPeriod.am ? 'AM' : 'PM';
return '$hour:${time.minute.toString().padLeft(2, '0')} $period';
}
}
}
ARB file entries:
{
"timePickerLabel": "Select a time",
"@timePickerLabel": {
"description": "Label above the time picker field"
},
"timePickerHint": "Choose time",
"@timePickerHint": {
"description": "Hint text in the time field"
},
"timePickerPlaceholder": "No time selected",
"@timePickerPlaceholder": {
"description": "Placeholder when no time is selected"
},
"timePickerHelpText": "Select time",
"@timePickerHelpText": {
"description": "Help text shown at top of time picker"
},
"timePickerCancel": "Cancel",
"@timePickerCancel": {
"description": "Cancel button in time picker"
},
"timePickerConfirm": "OK",
"@timePickerConfirm": {
"description": "Confirm button in time picker"
},
"timePickerHourLabel": "Hour",
"@timePickerHourLabel": {
"description": "Label for hour input"
},
"timePickerMinuteLabel": "Minute",
"@timePickerMinuteLabel": {
"description": "Label for minute input"
},
"timePickerErrorInvalid": "Enter a valid time",
"@timePickerErrorInvalid": {
"description": "Error when time is invalid"
}
}
Locale-Specific Time Formatting
Display times in the format expected by each locale:
class LocalizedTimeDisplay extends StatelessWidget {
final TimeOfDay time;
final bool showSeconds;
const LocalizedTimeDisplay({
super.key,
required this.time,
this.showSeconds = false,
});
@override
Widget build(BuildContext context) {
return Text(_formatTime(context));
}
String _formatTime(BuildContext context) {
final locale = Localizations.localeOf(context);
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
final l10n = AppLocalizations.of(context)!;
if (use24Hour) {
return _format24Hour();
} else {
return _format12Hour(l10n);
}
}
String _format24Hour() {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _format12Hour(AppLocalizations l10n) {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final minute = time.minute.toString().padLeft(2, '0');
final period = time.period == DayPeriod.am
? l10n.timeAm
: l10n.timePm;
return '$hour:$minute $period';
}
}
ARB entries:
{
"timeAm": "AM",
"@timeAm": {
"description": "Morning time period indicator"
},
"timePm": "PM",
"@timePm": {
"description": "Afternoon/evening time period indicator"
}
}
Custom Time Picker with Locale Support
Build a custom time picker with full localization control:
class CustomLocalizedTimePicker extends StatefulWidget {
final TimeOfDay? initialTime;
final ValueChanged<TimeOfDay> onTimeSelected;
const CustomLocalizedTimePicker({
super.key,
this.initialTime,
required this.onTimeSelected,
});
@override
State<CustomLocalizedTimePicker> createState() =>
_CustomLocalizedTimePickerState();
}
class _CustomLocalizedTimePickerState extends State<CustomLocalizedTimePicker> {
late int _selectedHour;
late int _selectedMinute;
late DayPeriod _selectedPeriod;
@override
void initState() {
super.initState();
final time = widget.initialTime ?? TimeOfDay.now();
_selectedHour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
_selectedMinute = time.minute;
_selectedPeriod = time.period;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.timePickerTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Hour picker
_buildNumberPicker(
value: use24Hour ? _get24Hour() : _selectedHour,
minValue: use24Hour ? 0 : 1,
maxValue: use24Hour ? 23 : 12,
label: l10n.timePickerHourLabel,
onChanged: (value) {
setState(() {
if (use24Hour) {
_selectedHour = value > 12 ? value - 12 : (value == 0 ? 12 : value);
_selectedPeriod = value >= 12 ? DayPeriod.pm : DayPeriod.am;
} else {
_selectedHour = value;
}
});
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
':',
style: Theme.of(context).textTheme.headlineLarge,
),
),
// Minute picker
_buildNumberPicker(
value: _selectedMinute,
minValue: 0,
maxValue: 59,
label: l10n.timePickerMinuteLabel,
onChanged: (value) {
setState(() => _selectedMinute = value);
},
),
if (!use24Hour) ...[
const SizedBox(width: 16),
// AM/PM picker
_buildPeriodPicker(l10n),
],
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.timePickerCancel),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
final time = TimeOfDay(
hour: _get24Hour(),
minute: _selectedMinute,
);
widget.onTimeSelected(time);
Navigator.of(context).pop();
},
child: Text(l10n.timePickerConfirm),
),
],
),
],
);
}
int _get24Hour() {
if (_selectedPeriod == DayPeriod.am) {
return _selectedHour == 12 ? 0 : _selectedHour;
} else {
return _selectedHour == 12 ? 12 : _selectedHour + 12;
}
}
Widget _buildNumberPicker({
required int value,
required int minValue,
required int maxValue,
required String label,
required ValueChanged<int> onChanged,
}) {
return Column(
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 8),
Container(
width: 80,
height: 120,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: ListWheelScrollView.useDelegate(
itemExtent: 40,
perspective: 0.01,
diameterRatio: 1.5,
physics: const FixedExtentScrollPhysics(),
controller: FixedExtentScrollController(
initialItem: value - minValue,
),
onSelectedItemChanged: (index) {
onChanged(minValue + index);
},
childDelegate: ListWheelChildBuilderDelegate(
childCount: maxValue - minValue + 1,
builder: (context, index) {
final itemValue = minValue + index;
return Center(
child: Text(
itemValue.toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleLarge,
),
);
},
),
),
),
],
);
}
Widget _buildPeriodPicker(AppLocalizations l10n) {
return Column(
children: [
const SizedBox(height: 24), // Align with number pickers
ToggleButtons(
direction: Axis.vertical,
isSelected: [
_selectedPeriod == DayPeriod.am,
_selectedPeriod == DayPeriod.pm,
],
onPressed: (index) {
setState(() {
_selectedPeriod = index == 0 ? DayPeriod.am : DayPeriod.pm;
});
},
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(l10n.timeAm),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(l10n.timePm),
),
],
),
],
);
}
}
Time Range Selection with Localization
Handle time range selection with proper localized labels:
class LocalizedTimeRange extends StatefulWidget {
const LocalizedTimeRange({super.key});
@override
State<LocalizedTimeRange> createState() => _LocalizedTimeRangeState();
}
class _LocalizedTimeRangeState extends State<LocalizedTimeRange> {
TimeOfDay? _startTime;
TimeOfDay? _endTime;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.timeRangeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildTimeField(
label: l10n.timeRangeStart,
time: _startTime,
onTap: () => _selectTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(l10n.timeRangeTo),
),
Expanded(
child: _buildTimeField(
label: l10n.timeRangeEnd,
time: _endTime,
onTap: () => _selectTime(isStart: false),
),
),
],
),
if (_startTime != null && _endTime != null) ...[
const SizedBox(height: 16),
Text(
_calculateDuration(l10n),
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
);
}
Widget _buildTimeField({
required String label,
required TimeOfDay? time,
required VoidCallback onTap,
}) {
final l10n = AppLocalizations.of(context)!;
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.access_time),
),
child: Text(
time != null
? _formatTime(time, context)
: l10n.timePickerPlaceholder,
),
),
);
}
Future<void> _selectTime({required bool isStart}) async {
final l10n = AppLocalizations.of(context)!;
final time = await showTimePicker(
context: context,
initialTime: (isStart ? _startTime : _endTime) ?? TimeOfDay.now(),
helpText: isStart ? l10n.timeRangeSelectStart : l10n.timeRangeSelectEnd,
);
if (time != null) {
setState(() {
if (isStart) {
_startTime = time;
} else {
_endTime = time;
}
});
}
}
String _calculateDuration(AppLocalizations l10n) {
if (_startTime == null || _endTime == null) return '';
final startMinutes = _startTime!.hour * 60 + _startTime!.minute;
var endMinutes = _endTime!.hour * 60 + _endTime!.minute;
// Handle overnight ranges
if (endMinutes < startMinutes) {
endMinutes += 24 * 60;
}
final durationMinutes = endMinutes - startMinutes;
final hours = durationMinutes ~/ 60;
final minutes = durationMinutes % 60;
if (hours > 0 && minutes > 0) {
return l10n.timeRangeDurationHoursMinutes(hours, minutes);
} else if (hours > 0) {
return l10n.timeRangeDurationHours(hours);
} else {
return l10n.timeRangeDurationMinutes(minutes);
}
}
String _formatTime(TimeOfDay time, BuildContext context) {
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
final l10n = AppLocalizations.of(context)!;
if (use24Hour) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final period = time.period == DayPeriod.am ? l10n.timeAm : l10n.timePm;
return '$hour:${time.minute.toString().padLeft(2, '0')} $period';
}
}
}
ARB entries:
{
"timeRangeLabel": "Time range",
"timeRangeStart": "Start time",
"timeRangeEnd": "End time",
"timeRangeTo": "to",
"timeRangeSelectStart": "Select start time",
"timeRangeSelectEnd": "Select end time",
"timeRangeDurationHoursMinutes": "{hours, plural, =1{1 hour} other{{hours} hours}} and {minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"@timeRangeDurationHoursMinutes": {
"placeholders": {
"hours": {"type": "int"},
"minutes": {"type": "int"}
}
},
"timeRangeDurationHours": "{hours, plural, =1{1 hour} other{{hours} hours}}",
"@timeRangeDurationHours": {
"placeholders": {
"hours": {"type": "int"}
}
},
"timeRangeDurationMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"@timeRangeDurationMinutes": {
"placeholders": {
"minutes": {"type": "int"}
}
}
}
Time Input Mode Selection
Let users choose their preferred input mode:
class TimeInputModeSelector extends StatefulWidget {
const TimeInputModeSelector({super.key});
@override
State<TimeInputModeSelector> createState() => _TimeInputModeSelectorState();
}
class _TimeInputModeSelectorState extends State<TimeInputModeSelector> {
TimePickerEntryMode _entryMode = TimePickerEntryMode.dial;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.timeInputModeLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
SegmentedButton<TimePickerEntryMode>(
segments: [
ButtonSegment(
value: TimePickerEntryMode.dial,
icon: const Icon(Icons.schedule),
label: Text(l10n.timeInputModeDial),
),
ButtonSegment(
value: TimePickerEntryMode.input,
icon: const Icon(Icons.keyboard),
label: Text(l10n.timeInputModeKeyboard),
),
],
selected: {_entryMode},
onSelectionChanged: (modes) {
setState(() => _entryMode = modes.first);
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _showTimePicker(context),
child: Text(l10n.timeInputModeSelectTime),
),
],
);
}
Future<void> _showTimePicker(BuildContext context) async {
await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
initialEntryMode: _entryMode,
);
}
}
Accessibility for Time Pickers
Ensure time pickers are accessible:
class AccessibleTimePicker extends StatelessWidget {
final TimeOfDay? selectedTime;
final ValueChanged<TimeOfDay> onTimeChanged;
const AccessibleTimePicker({
super.key,
this.selectedTime,
required this.onTimeChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.timePickerAccessibilityLabel,
hint: l10n.timePickerAccessibilityHint,
value: selectedTime != null
? _getAccessibleTimeDescription(selectedTime!, l10n, context)
: l10n.timePickerNoSelection,
button: true,
child: InkWell(
onTap: () => _showTimePicker(context),
child: _buildTimeDisplay(context, l10n),
),
);
}
String _getAccessibleTimeDescription(
TimeOfDay time,
AppLocalizations l10n,
BuildContext context,
) {
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
if (use24Hour) {
return l10n.timeAccessibleFormat24(time.hour, time.minute);
} else {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final period = time.period == DayPeriod.am
? l10n.timeAccessibleAm
: l10n.timeAccessiblePm;
return l10n.timeAccessibleFormat12(hour, time.minute, period);
}
}
Widget _buildTimeDisplay(BuildContext context, AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.access_time),
const SizedBox(width: 12),
Expanded(
child: Text(
selectedTime != null
? _formatTime(selectedTime!, context, l10n)
: l10n.timePickerPlaceholder,
),
),
],
),
);
}
String _formatTime(TimeOfDay time, BuildContext context, AppLocalizations l10n) {
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
if (use24Hour) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final period = time.period == DayPeriod.am ? l10n.timeAm : l10n.timePm;
return '$hour:${time.minute.toString().padLeft(2, '0')} $period';
}
}
Future<void> _showTimePicker(BuildContext context) async {
final time = await showTimePicker(
context: context,
initialTime: selectedTime ?? TimeOfDay.now(),
);
if (time != null) {
onTimeChanged(time);
}
}
}
ARB entries:
{
"timePickerAccessibilityLabel": "Time selection",
"timePickerAccessibilityHint": "Double tap to select a time",
"timePickerNoSelection": "No time selected",
"timeAccessibleFormat24": "{hour} hours and {minute} minutes",
"@timeAccessibleFormat24": {
"placeholders": {
"hour": {"type": "int"},
"minute": {"type": "int"}
}
},
"timeAccessibleFormat12": "{hour}:{minute} {period}",
"@timeAccessibleFormat12": {
"placeholders": {
"hour": {"type": "int"},
"minute": {"type": "int"},
"period": {"type": "String"}
}
},
"timeAccessibleAm": "in the morning",
"timeAccessiblePm": "in the afternoon"
}
Testing Time Picker Localization
Write comprehensive tests for time picker behavior:
void main() {
group('LocalizedTimePicker Tests', () {
testWidgets('displays time in 12-hour format for US locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en', 'US'),
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: false),
child: Scaffold(
body: LocalizedTimeDisplay(
time: const TimeOfDay(hour: 14, minute: 30),
),
),
),
),
);
expect(find.text('2:30 PM'), findsOneWidget);
});
testWidgets('displays time in 24-hour format for German locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('de', 'DE'),
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: true),
child: Scaffold(
body: LocalizedTimeDisplay(
time: const TimeOfDay(hour: 14, minute: 30),
),
),
),
),
);
expect(find.text('14:30'), findsOneWidget);
});
testWidgets('shows localized AM/PM labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en', 'US'),
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: false),
child: Scaffold(
body: LocalizedTimeDisplay(
time: const TimeOfDay(hour: 9, minute: 0),
),
),
),
),
);
expect(find.textContaining('AM'), findsOneWidget);
});
});
}
Best Practices Summary
- Respect system settings: Use
MediaQuery.alwaysUse24HourFormat - Provide localized labels: AM/PM, hour, minute labels
- Handle RTL layouts: Properly mirror time picker UI
- Include accessibility: Screen reader announcements
- Validate input: Accept locale-appropriate time formats
- Show clear errors: Help users enter valid times
- Test multiple locales: Verify 12h and 24h formats
Conclusion
Localizing time pickers in Flutter requires attention to 12/24 hour formats, AM/PM labels, and input validation. By implementing locale-aware formatting, proper accessibility labels, and comprehensive testing across multiple locales, you ensure users worldwide can easily select and understand times in your app.