← Back to Blog

Flutter CupertinoTimerPicker Localization: iOS Timer Pickers for Multilingual Apps

fluttercupertinotimerpickerioslocalizationrtl

Flutter CupertinoTimerPicker Localization: iOS Timer Pickers for Multilingual Apps

CupertinoTimerPicker is a Flutter widget that renders an iOS-style spinning wheel picker for selecting time durations with hours, minutes, and seconds columns. In multilingual applications, CupertinoTimerPicker is essential for displaying translated time unit labels (hours, minutes, seconds) in the active language, formatting duration values according to locale conventions, supporting RTL alignment within picker columns for Arabic and Hebrew, and providing accessible duration announcements in the user's language.

Understanding CupertinoTimerPicker in Localization Context

CupertinoTimerPicker renders spinning wheels for selecting a duration rather than a specific point in time. For multilingual apps, this enables:

  • Translated time unit labels (hr, min, sec) matching iOS conventions per locale
  • Locale-aware number formatting in picker columns
  • RTL-compatible label alignment within columns
  • Accessible duration value announcements in the active language

Why CupertinoTimerPicker Matters for Multilingual Apps

CupertinoTimerPicker provides:

  • iOS consistency: Timer selection matching native iOS patterns in every language
  • Duration formatting: Properly formatted hour, minute, and second labels per locale
  • Cooking and fitness apps: Common use case where duration selection must be localized
  • Platform feel: iOS users expect Cupertino-style pickers regardless of language

Basic CupertinoTimerPicker Implementation

import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedCupertinoTimerPickerExample extends StatefulWidget {
  const LocalizedCupertinoTimerPickerExample({super.key});

  @override
  State<LocalizedCupertinoTimerPickerExample> createState() =>
      _LocalizedCupertinoTimerPickerExampleState();
}

class _LocalizedCupertinoTimerPickerExampleState
    extends State<LocalizedCupertinoTimerPickerExample> {
  Duration _selectedDuration = const Duration(minutes: 10);

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text(l10n.setTimerTitle),
      ),
      child: SafeArea(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                l10n.timerDurationLabel(
                  _selectedDuration.inMinutes,
                  _selectedDuration.inSeconds % 60,
                ),
                style: const TextStyle(fontSize: 18),
              ),
            ),
            Expanded(
              child: CupertinoTimerPicker(
                mode: CupertinoTimerPickerMode.ms,
                initialTimerDuration: _selectedDuration,
                onTimerDurationChanged: (Duration duration) {
                  setState(() {
                    _selectedDuration = duration;
                  });
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Advanced CupertinoTimerPicker Patterns for Localization

Cooking Timer with Preset Durations

CupertinoTimerPicker for a cooking app with localized preset buttons and duration display.

class CookingTimerExample extends StatefulWidget {
  const CookingTimerExample({super.key});

  @override
  State<CookingTimerExample> createState() => _CookingTimerExampleState();
}

class _CookingTimerExampleState extends State<CookingTimerExample> {
  Duration _cookingDuration = const Duration(minutes: 15);

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text(l10n.cookingTimerTitle),
      ),
      child: SafeArea(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  _presetButton(l10n.quickPreset, const Duration(minutes: 5)),
                  _presetButton(l10n.mediumPreset, const Duration(minutes: 15)),
                  _presetButton(l10n.longPreset, const Duration(minutes: 30)),
                  _presetButton(l10n.extendedPreset, const Duration(hours: 1)),
                ],
              ),
            ),
            Expanded(
              child: CupertinoTimerPicker(
                mode: CupertinoTimerPickerMode.hms,
                initialTimerDuration: _cookingDuration,
                onTimerDurationChanged: (Duration duration) {
                  setState(() {
                    _cookingDuration = duration;
                  });
                },
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16),
              child: SizedBox(
                width: double.infinity,
                child: CupertinoButton.filled(
                  onPressed: () {
                    // Start cooking timer
                  },
                  child: Text(l10n.startTimerButton),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _presetButton(String label, Duration duration) {
    return CupertinoButton(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      onPressed: () {
        setState(() {
          _cookingDuration = duration;
        });
      },
      child: Text(label, style: const TextStyle(fontSize: 14)),
    );
  }
}

Workout Interval Timer

CupertinoTimerPicker for setting exercise and rest intervals with localized labels.

class WorkoutIntervalTimerExample extends StatefulWidget {
  const WorkoutIntervalTimerExample({super.key});

  @override
  State<WorkoutIntervalTimerExample> createState() =>
      _WorkoutIntervalTimerExampleState();
}

class _WorkoutIntervalTimerExampleState
    extends State<WorkoutIntervalTimerExample> {
  Duration _exerciseDuration = const Duration(minutes: 1);
  Duration _restDuration = const Duration(seconds: 30);
  bool _editingExercise = true;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text(l10n.intervalTimerTitle),
      ),
      child: SafeArea(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(16),
              child: CupertinoSlidingSegmentedControl<bool>(
                groupValue: _editingExercise,
                children: {
                  true: Text(l10n.exerciseLabel),
                  false: Text(l10n.restLabel),
                },
                onValueChanged: (bool? value) {
                  setState(() {
                    _editingExercise = value ?? true;
                  });
                },
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: CupertinoColors.systemGrey6.resolveFrom(context),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: [
                    Column(
                      children: [
                        Text(
                          l10n.exerciseLabel,
                          style: const TextStyle(fontSize: 12),
                        ),
                        Text(
                          _formatDuration(_exerciseDuration),
                          style: const TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    ),
                    Column(
                      children: [
                        Text(
                          l10n.restLabel,
                          style: const TextStyle(fontSize: 12),
                        ),
                        Text(
                          _formatDuration(_restDuration),
                          style: const TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),
            Expanded(
              child: CupertinoTimerPicker(
                mode: CupertinoTimerPickerMode.ms,
                initialTimerDuration:
                    _editingExercise ? _exerciseDuration : _restDuration,
                onTimerDurationChanged: (Duration duration) {
                  setState(() {
                    if (_editingExercise) {
                      _exerciseDuration = duration;
                    } else {
                      _restDuration = duration;
                    }
                  });
                },
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16),
              child: SizedBox(
                width: double.infinity,
                child: CupertinoButton.filled(
                  onPressed: () {
                    // Start workout
                  },
                  child: Text(l10n.startWorkoutButton),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  String _formatDuration(Duration duration) {
    final minutes = duration.inMinutes.toString().padLeft(2, '0');
    final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
    return '$minutes:$seconds';
  }
}

Modal Timer Picker

CupertinoTimerPicker presented in a modal popup with localized action buttons.

class ModalTimerPickerExample extends StatefulWidget {
  const ModalTimerPickerExample({super.key});

  @override
  State<ModalTimerPickerExample> createState() =>
      _ModalTimerPickerExampleState();
}

class _ModalTimerPickerExampleState extends State<ModalTimerPickerExample> {
  Duration? _selectedDuration;

  void _showTimerPicker(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    Duration tempDuration = _selectedDuration ?? const Duration(minutes: 5);

    showCupertinoModalPopup(
      context: context,
      builder: (BuildContext popupContext) {
        return Container(
          height: 300,
          color: CupertinoColors.systemBackground.resolveFrom(context),
          child: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  CupertinoButton(
                    child: Text(l10n.cancelButton),
                    onPressed: () => Navigator.pop(popupContext),
                  ),
                  Text(
                    l10n.setDurationTitle,
                    style: const TextStyle(fontWeight: FontWeight.w600),
                  ),
                  CupertinoButton(
                    child: Text(l10n.doneButton),
                    onPressed: () {
                      setState(() {
                        _selectedDuration = tempDuration;
                      });
                      Navigator.pop(popupContext);
                    },
                  ),
                ],
              ),
              Expanded(
                child: CupertinoTimerPicker(
                  mode: CupertinoTimerPickerMode.hm,
                  initialTimerDuration: tempDuration,
                  onTimerDurationChanged: (Duration duration) {
                    tempDuration = duration;
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text(l10n.timerTitle),
      ),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              _selectedDuration != null
                  ? l10n.durationSelectedLabel(
                      _selectedDuration!.inHours,
                      _selectedDuration!.inMinutes % 60,
                    )
                  : l10n.noDurationSelected,
              style: const TextStyle(fontSize: 18),
            ),
            const SizedBox(height: 16),
            CupertinoButton.filled(
              onPressed: () => _showTimerPicker(context),
              child: Text(l10n.setDurationButton),
            ),
          ],
        ),
      ),
    );
  }
}

RTL Support and Bidirectional Layouts

CupertinoTimerPicker handles RTL text direction for the time unit labels within each spinning wheel column. The surrounding UI elements should use directional properties for proper RTL alignment.

class BidirectionalTimerPickerExample extends StatelessWidget {
  const BidirectionalTimerPickerExample({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text(l10n.setTimerTitle),
      ),
      child: SafeArea(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsetsDirectional.all(16),
              child: Align(
                alignment: AlignmentDirectional.centerStart,
                child: Text(
                  l10n.timerInstructions,
                  style: const TextStyle(fontSize: 16),
                ),
              ),
            ),
            Expanded(
              child: CupertinoTimerPicker(
                mode: CupertinoTimerPickerMode.hms,
                initialTimerDuration: const Duration(minutes: 10),
                onTimerDurationChanged: (Duration duration) {},
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Testing CupertinoTimerPicker Localization

import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() {
  Widget buildTestWidget({Locale locale = const Locale('en')}) {
    return CupertinoApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LocalizedCupertinoTimerPickerExample(),
    );
  }

  testWidgets('CupertinoTimerPicker renders correctly', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(CupertinoTimerPicker), findsOneWidget);
  });

  testWidgets('CupertinoTimerPicker works in RTL', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });

  testWidgets('CupertinoTimerPicker renders in French', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('fr')));
    await tester.pumpAndSettle();
    expect(find.byType(CupertinoTimerPicker), findsOneWidget);
  });
}

Best Practices

  1. Choose the right mode — Use CupertinoTimerPickerMode.hms for precise durations, hm for hour-minute selections, and ms for short durations under an hour.

  2. Set sensible initial values — Start with a reasonable default duration and translate the context explaining what the duration is for.

  3. Present in modal sheets — Use showCupertinoModalPopup with localized Cancel and Done buttons for an iOS-consistent experience.

  4. Format duration displays — When showing the selected duration outside the picker, format it using locale-aware patterns (e.g., "2 hours 30 minutes" vs "2h 30m").

  5. Combine with segmented controls — For apps with multiple timer types (exercise/rest, work/break), use CupertinoSlidingSegmentedControl with translated labels to switch between timers.

  6. Test with verbose locales — German and Finnish translations of time units can be longer, so verify the picker layout does not clip text.

Conclusion

CupertinoTimerPicker provides iOS-style duration selection for Flutter apps with built-in locale support for time unit labels. For multilingual apps, it handles translated hour, minute, and second labels, supports RTL alignment, and pairs well with modal sheets for a native iOS experience. By choosing the right picker mode, formatting durations with locale-aware patterns, and testing across multiple locales, you can build timer selection flows that work correctly in every supported language.

Further Reading