Skip to content

[a11y] Circles on 24-hour time picker are too close together #141501

Open

Description

@ngocanhtve

I reported an issue with the Cashew app - an app written in Flutter here.

He said the problem was with Flutter so I moved here to find better support.

  1. When the Android system displays in Vietnamese, French or Spanish - and 12-hour format is selected - the Cashew's (Flutter) time picker will always remain in 24-hour format, not switch to 12-hour format as desired. Does Flutter not support 12 hour format with those 3 languages?

  2. Cashew's (Flutter) 24-hour time picker has two time circles picker. These two time circles picker are too close together, touching each other. I think the result is a lack of aesthetics and the ability to choose numbers incorrectly. Big fingers are more likely to pick the wrong number.

Caption: 10:00 touched 22:00 because they were too close to each other.

  • Platform: Crdroid Android 13.
  • Device: Sony Xperia 1.

=> I hope Flutter can support the 12-hour format for Vietnamese, French, and Spanish. As well as further spacing of the two numerical circles time picker to ensure ease of use and aesthetic symmetry!

Activity

  1. dam-ease commented on Jan 15, 2024

    @dam-ease

    Thanks for filing this @ngocanhtve.
    Can you please provide a minimal complete reproducible code sample that doesn’t include 3rd party plugins or complex production code?
    I tried reproducing this with a simple time picker sample code below but can't seem to reproduce what exact issue you are encountering. Can you modify this to reproduce your issue?

    These two time circles picker are too close together, touching each other. I think the result is a lack of aesthetics and the ability to choose numbers incorrectly. Big fingers are more likely to pick the wrong number.

    I can reproduce this with the sample below, it only occurs with Material3 and this isn't specific to any localization. Can you help with more information on the first?

    Code Sample

    import 'package:flutter/material.dart';
    import 'package:flutter_localizations/flutter_localizations.dart';
    
    /// Flutter code sample for [showTimePicker].
    
    void main() {
      runApp(const ShowTimePickerApp());
    }
    
    class ShowTimePickerApp extends StatefulWidget {
      const ShowTimePickerApp({super.key});
    
      @override
      State<ShowTimePickerApp> createState() => _ShowTimePickerAppState();
    }
    
    class _ShowTimePickerAppState extends State<ShowTimePickerApp> {
      ThemeMode themeMode = ThemeMode.dark;
      bool useMaterial3 = true;
    
      void setThemeMode(ThemeMode mode) {
        setState(() {
          themeMode = mode;
        });
      }
    
      void setUseMaterial3(bool? value) {
        setState(() {
          useMaterial3 = value!;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          localizationsDelegates: [GlobalMaterialLocalizations.delegate],
          supportedLocales: [const Locale('en'), const Locale('fr')],
          theme: ThemeData.light(useMaterial3: useMaterial3),
          darkTheme: ThemeData.dark(useMaterial3: useMaterial3),
          themeMode: themeMode,
          home: TimePickerOptions(
            themeMode: themeMode,
            useMaterial3: useMaterial3,
            setThemeMode: setThemeMode,
            setUseMaterial3: setUseMaterial3,
          ),
        );
      }
    }
    
    class TimePickerOptions extends StatefulWidget {
      const TimePickerOptions({
        super.key,
        required this.themeMode,
        required this.useMaterial3,
        required this.setThemeMode,
        required this.setUseMaterial3,
      });
    
      final ThemeMode themeMode;
      final bool useMaterial3;
      final ValueChanged<ThemeMode> setThemeMode;
      final ValueChanged<bool?> setUseMaterial3;
    
      @override
      State<TimePickerOptions> createState() => _TimePickerOptionsState();
    }
    
    class _TimePickerOptionsState extends State<TimePickerOptions> {
      TimeOfDay? selectedTime;
      TimePickerEntryMode entryMode = TimePickerEntryMode.dial;
      Orientation? orientation;
      TextDirection textDirection = TextDirection.ltr;
      MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.padded;
      bool use24HourTime = false;
    
      void _entryModeChanged(TimePickerEntryMode? value) {
        if (value != entryMode) {
          setState(() {
            entryMode = value!;
          });
        }
      }
    
      void _orientationChanged(Orientation? value) {
        if (value != orientation) {
          setState(() {
            orientation = value;
          });
        }
      }
    
      void _textDirectionChanged(TextDirection? value) {
        if (value != textDirection) {
          setState(() {
            textDirection = value!;
          });
        }
      }
    
      void _tapTargetSizeChanged(MaterialTapTargetSize? value) {
        if (value != tapTargetSize) {
          setState(() {
            tapTargetSize = value!;
          });
        }
      }
    
      void _use24HourTimeChanged(bool? value) {
        if (value != use24HourTime) {
          setState(() {
            use24HourTime = value!;
          });
        }
      }
    
      void _themeModeChanged(ThemeMode? value) {
        widget.setThemeMode(value!);
      }
    
      @override
      Widget build(BuildContext context) {
        return Material(
          child: Column(
            children: <Widget>[
              Expanded(
                child: GridView(
                  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                    maxCrossAxisExtent: 350,
                    mainAxisSpacing: 4,
                    // ignore: deprecated_member_use, https://github.com/flutter/flutter/issues/128825
                    mainAxisExtent:
                        200 * MediaQuery.textScalerOf(context).textScaleFactor,
                    crossAxisSpacing: 4,
                  ),
                  children: <Widget>[
                    EnumCard<TimePickerEntryMode>(
                      choices: TimePickerEntryMode.values,
                      value: entryMode,
                      onChanged: _entryModeChanged,
                    ),
                    EnumCard<ThemeMode>(
                      choices: ThemeMode.values,
                      value: widget.themeMode,
                      onChanged: _themeModeChanged,
                    ),
                    EnumCard<TextDirection>(
                      choices: TextDirection.values,
                      value: textDirection,
                      onChanged: _textDirectionChanged,
                    ),
                    EnumCard<MaterialTapTargetSize>(
                      choices: MaterialTapTargetSize.values,
                      value: tapTargetSize,
                      onChanged: _tapTargetSizeChanged,
                    ),
                    ChoiceCard<Orientation?>(
                      choices: const <Orientation?>[...Orientation.values, null],
                      value: orientation,
                      title: '$Orientation',
                      choiceLabels: <Orientation?, String>{
                        for (final Orientation choice in Orientation.values)
                          choice: choice.name,
                        null: 'from MediaQuery',
                      },
                      onChanged: _orientationChanged,
                    ),
                    ChoiceCard<bool>(
                      choices: const <bool>[false, true],
                      value: use24HourTime,
                      onChanged: _use24HourTimeChanged,
                      title: 'Time Mode',
                      choiceLabels: const <bool, String>{
                        false: '12-hour am/pm time',
                        true: '24-hour time',
                      },
                    ),
                    ChoiceCard<bool>(
                      choices: const <bool>[false, true],
                      value: widget.useMaterial3,
                      onChanged: widget.setUseMaterial3,
                      title: 'Material Version',
                      choiceLabels: const <bool, String>{
                        false: 'Material 2',
                        true: 'Material 3',
                      },
                    ),
                  ],
                ),
              ),
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    Padding(
                      padding: const EdgeInsets.all(12.0),
                      child: ElevatedButton(
                        child: const Text('Open time picker'),
                        onPressed: () async {
                          final TimeOfDay? time = await showTimePicker(
                            context: context,
                            initialTime: selectedTime ?? TimeOfDay.now(),
                            initialEntryMode: entryMode,
                            orientation: orientation,
                            builder: (BuildContext context, Widget? child) {
                              // We just wrap these environmental changes around the
                              // child in this builder so that we can apply the
                              // options selected above. In regular usage, this is
                              // rarely necessary, because the default values are
                              // usually used as-is.
                              return Theme(
                                data: Theme.of(context).copyWith(
                                  materialTapTargetSize: tapTargetSize,
                                ),
                                child: Directionality(
                                  textDirection: textDirection,
                                  child: MediaQuery(
                                    data: MediaQuery.of(context).copyWith(
                                      alwaysUse24HourFormat: use24HourTime,
                                    ),
                                    child: child!,
                                  ),
                                ),
                              );
                            },
                          );
                          setState(() {
                            selectedTime = time;
                          });
                        },
                      ),
                    ),
                    if (selectedTime != null)
                      Text('Selected time: ${selectedTime!.format(context)}'),
                  ],
                ),
              ),
            ],
          ),
        );
      }
    }
    
    // This is a simple card that presents a set of radio buttons (inside of a
    // RadioSelection, defined below) for the user to select from.
    class ChoiceCard<T extends Object?> extends StatelessWidget {
      const ChoiceCard({
        super.key,
        required this.value,
        required this.choices,
        required this.onChanged,
        required this.choiceLabels,
        required this.title,
      });
    
      final T value;
      final Iterable<T> choices;
      final Map<T, String> choiceLabels;
      final String title;
      final ValueChanged<T?> onChanged;
    
      @override
      Widget build(BuildContext context) {
        return Card(
          // If the card gets too small, let it scroll both directions.
          child: SingleChildScrollView(
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text(title),
                    ),
                    for (final T choice in choices)
                      RadioSelection<T>(
                        value: choice,
                        groupValue: value,
                        onChanged: onChanged,
                        child: Text(choiceLabels[choice]!),
                      ),
                  ],
                ),
              ),
            ),
          ),
        );
      }
    }
    
    // This aggregates a ChoiceCard so that it presents a set of radio buttons for
    // the allowed enum values for the user to select from.
    class EnumCard<T extends Enum> extends StatelessWidget {
      const EnumCard({
        super.key,
        required this.value,
        required this.choices,
        required this.onChanged,
      });
    
      final T value;
      final Iterable<T> choices;
      final ValueChanged<T?> onChanged;
    
      @override
      Widget build(BuildContext context) {
        return ChoiceCard<T>(
            value: value,
            choices: choices,
            onChanged: onChanged,
            choiceLabels: <T, String>{
              for (final T choice in choices) choice: choice.name,
            },
            title: value.runtimeType.toString());
      }
    }
    
    // A button that has a radio button on one side and a label child. Tapping on
    // the label or the radio button selects the item.
    class RadioSelection<T extends Object?> extends StatefulWidget {
      const RadioSelection({
        super.key,
        required this.value,
        required this.groupValue,
        required this.onChanged,
        required this.child,
      });
    
      final T value;
      final T? groupValue;
      final ValueChanged<T?> onChanged;
      final Widget child;
    
      @override
      State<RadioSelection<T>> createState() => _RadioSelectionState<T>();
    }
    
    class _RadioSelectionState<T extends Object?> extends State<RadioSelection<T>> {
      @override
      Widget build(BuildContext context) {
        return Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Padding(
              padding: const EdgeInsetsDirectional.only(end: 8),
              child: Radio<T>(
                groupValue: widget.groupValue,
                value: widget.value,
                onChanged: widget.onChanged,
              ),
            ),
            GestureDetector(
                onTap: () => widget.onChanged(widget.value), child: widget.child),
          ],
        );
      }
    }
    

    flutter doctor -v

    [!] Flutter (Channel stable, 3.16.7, on macOS 14.2.1 23C71 darwin-arm64, locale
        en-NG)
        • Flutter version 3.16.7 on channel stable at
          /Users/damilolaalimi/sdks/flutter
        ! Warning: `dart` on your path resolves to
          /opt/homebrew/Cellar/dart/3.1.5/libexec/bin/dart, which is not inside your
          current Flutter SDK checkout at /Users/damilolaalimi/sdks/flutter.
          Consider adding /Users/damilolaalimi/sdks/flutter/bin to the front of your
          path.
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision ef1af02aea (3 days ago), 2024-01-11 15:19:26 -0600
        • Engine revision 4a585b7929
        • Dart version 3.2.4
        • DevTools version 2.28.5
        • If those were intentional, you can disregard the above warnings; however
          it is recommended to use "git" directly to perform update checks and
          upgrades.
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
        • Android SDK at /Users/damilolaalimi/Library/Android/sdk
        • Platform android-34, build-tools 34.0.0
        • ANDROID_HOME = /Users/damilolaalimi/Library/Android/sdk
        • Java binary at: /Applications/Android
          Studio.app/Contents/jbr/Contents/Home/bin/java
        • Java version OpenJDK Runtime Environment (build
          17.0.6+0-17.0.6b802.4-9586694)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 15.1)
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 15C65
        • CocoaPods version 1.14.3
    
    [✓] Chrome - develop for the web
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Android Studio (version 2022.2)
        • Android Studio at /Applications/Android Studio.app/Contents
        • Flutter plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/9212-flutter
        • Dart plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/6351-dart
        • Java version OpenJDK Runtime Environment (build
          17.0.6+0-17.0.6b802.4-9586694)
    
    [!] Android Studio (version unknown)
        • Android Studio at /Users/damilolaalimi/Downloads/Android Studio
          Preview.app/Contents
        • Flutter plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/9212-flutter
        • Dart plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/6351-dart
        ✗ Unable to determine Android Studio version.
        • Java version OpenJDK Runtime Environment (build
          17.0.7+0-17.0.7b1000.6-10550314)
    
    [✓] VS Code (version 1.85.1)
        • VS Code at /Applications/Visual Studio Code.app/Contents
        • Flutter extension version 3.78.0
    
    [✓] VS Code (version 1.83.1)
        • VS Code at /Users/damilolaalimi/Downloads/Visual Studio Code.app/Contents
        • Flutter extension version 3.78.0
    
    [✓] Connected device (4 available)
        • Damilola’s iPhone (mobile) • 00008110-001964480AE1801E            • ios
          • iOS 17.1.1 21B91
        • iPhone 15 (mobile)         • DBCC5388-DA57-470E-8F0E-477EA0AA3C3A • ios
          • com.apple.CoreSimulator.SimRuntime.iOS-17-2 (simulator)
        • macOS (desktop)            • macos                                •
          darwin-arm64   • macOS 14.2.1 23C71 darwin-arm64
        • Chrome (web)               • chrome                               •
          web-javascript • Google Chrome 120.0.6099.216
    
    [✓] Network resources
        • All expected network resources are available.
    
    ! Doctor found issues in 2 categories.
    

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Jan 15, 2024
  3. ngocanhtve commented on Jan 15, 2024

    @ngocanhtve
    Author

    First of all, thank you for your attention to my issue report.

    Sorry I'm not the one who wrote that app. So I can only link the source code of Cashew app and link to the time picker file, maybe you will discover something useful on it.

    Update: On the user side. When the time picker is in 24-hour format, there will be 2 circles, the outer circle from 1, 2, ... 11, 00. The small inner circle from 13, 14, ... 23, 12. The issue is that these two circles are too close to each other.

    For example, at the 10 o'clock position (AM) corresponding to the 22 o'clock position (PM), the numbers 10 and 22 are very close to each other. When choosing one of the two numbers, the color selection mark will touch the other number.

    Additionally, I see that a few other apps written in Flutter also have time pickers with two circles very close together like that. So please make those two circles farther apart, the outer circle should be extended a little bit more, because I see there is still a lot of space around.

    Thanks!

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Jan 15, 2024
  5. dam-ease commented on Jan 16, 2024

    @dam-ease

    Hello @ngocanhtve.
    Thanks for your response!
    I got/understood the issue with close circles and also shared that I can reproduce this with a minimal reproducible code I shared in my comment #141501 (comment).

    When the Android system displays in Vietnamese, French or Spanish - and 12-hour format is selected - the Cashew's (Flutter) time picker will always remain in 24-hour format, not switch to 12-hour format as desired. Does Flutter not support 12 hour format with those 3 languages?

    The information being asked for is on this but I can reproduce this as well now after changing my device Language to Vietnamese using the minimal reproducible code I shared in my comment #141501 (comment) as well.

    Simulator.Screen.Recording.-.iPhone.15.-.2024-01-16.at.08.50.23.mp4

    I'll be labelling this for more insights.
    Thank you!

  6. added
    p: material_uimaterial_ui package in flutter/packages
    a: internationalizationSupporting other languages or locales. (aka i18n)
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-designOwned by Design Languages team
    and removed
    in triagePresently being triaged by the triage team
    on Jan 16, 2024
  7. ngocanhtve commented on Jan 16, 2024

    @ngocanhtve
    Author

    Thank you for your thorough reply. I hope the solutions Flutter offers will help apps update time pickers better. But honestly, I don't understand anything about the lines of code you provided, sorry about that.

    Thanks again!

  8. added
    frameworkflutter/packages/flutter repository. See also f: labels.
    P2Important issues not at the top of the work list
    triaged-designTriaged by Design Languages team
    on Jan 17, 2024
  9. jamesncl commented on May 29, 2024

    @jamesncl

    I have also encountered the problem of certain locales not showing the 12-hour AM/PM style time picker when the device settings are set to 12-hour. This appears to affect any locale who's default TimeOfDayFormat is either TimeOfDayFormat.HH_colon_mm or TimeOfDayFormat.HH_dot_mm. The defaults appear to be defined in generated_material_localizations.dart - doing a quick search reveals 38 locales which this issue will affect (including es, fr, vi, en-GB).

    I raised a duplicate issue #149179 before I saw this one - however, because this issue is raising two separate concerns:

    1. An accessibility issue regarding the size of the circles in the time picker UI, and
    2. 12-hour time picker UI not being shown for certain locales

    Can I suggest that this issue is renamed and repurposed to just track the accessibility issue, any #149179 becomes the main tracker for the 12-hour issue?

  10. huycozy commented on May 30, 2024

    @huycozy
    Member

    @ngocanhtve We should break into separate issues for better tracking, so I renamed this issue title to focus on the accessibility issue (size of the circles in the time picker UI). There is a separate issue for 12-hour format is not showing at #149179. So please follow up on that issue for updates. Thanks!

    cc @HansMuller for updates.

  11. changed the title [-][BUG] Issue related to local 24-hour time picker.[/-] [+][a11y] Circles on 24-hour time picker are too close together[/+] on May 30, 2024
  12. ngocanhtve commented on May 30, 2024

    @ngocanhtve
    Author
  13. added
    packageflutter/packages repository. See also p: labels.
    and removed
    frameworkflutter/packages/flutter repository. See also f: labels.
    on Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)a: internationalizationSupporting other languages or locales. (aka i18n)f: date/time pickerDate or time picker widgetsfound in release: 3.16Found to occur in 3.16found in release: 3.18Found to occur in 3.18has reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: material_uimaterial_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions