diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 9197588468ab0276b07a408a58e45d407bd65385..416eebdb143589ca6e9ec943196665700f27b778 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -250,81 +250,89 @@ class _TimePickerHeader extends StatelessWidget { ).timeOfDayFormat(alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context)); final _HourDialType hourDialType = _TimePickerModel.hourDialTypeOf(context); - switch (_TimePickerModel.orientationOf(context)) { - case Orientation.portrait: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: <Widget>[ - Padding( - padding: EdgeInsetsDirectional.only( - bottom: _TimePickerModel.useMaterial3Of(context) ? 20 : 24, - ), - child: Text( - helpText, - style: - _TimePickerModel.themeOf(context).helpTextStyle ?? - _TimePickerModel.defaultThemeOf(context).helpTextStyle, + final RenderObjectWidget orientationSpecificHeader = switch (_TimePickerModel.orientationOf( + context, + )) { + Orientation.portrait => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Padding( + padding: EdgeInsetsDirectional.only( + bottom: _TimePickerModel.useMaterial3Of(context) ? 20 : 24, + ), + child: Text( + helpText, + style: + _TimePickerModel.themeOf(context).helpTextStyle ?? + _TimePickerModel.defaultThemeOf(context).helpTextStyle, + ), + ), + Row( + textDirection: + timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm + ? TextDirection.rtl + : TextDirection.ltr, + spacing: 12, + children: <Widget>[ + Expanded( + child: Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: <Widget>[ + const Expanded(child: _HourControl()), + _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), + const Expanded(child: _MinuteControl()), + ], + ), ), + if (hourDialType == _HourDialType.twelveHour) const _DayPeriodControl(), + ], + ), + ], + ), + Orientation.landscape => SizedBox( + width: _kTimePickerHeaderLandscapeWidth, + child: Stack( + children: <Widget>[ + Text( + helpText, + style: + _TimePickerModel.themeOf(context).helpTextStyle ?? + _TimePickerModel.defaultThemeOf(context).helpTextStyle, ), - Row( - textDirection: + Column( + verticalDirection: timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm - ? TextDirection.rtl - : TextDirection.ltr, + ? VerticalDirection.up + : VerticalDirection.down, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: <Widget>[ - Expanded( - child: Row( - // Hour/minutes should not change positions in RTL locales. - textDirection: TextDirection.ltr, - children: <Widget>[ - const Expanded(child: _HourControl()), - _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), - const Expanded(child: _MinuteControl()), - ], - ), + Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: <Widget>[ + const Expanded(child: _HourControl()), + _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), + const Expanded(child: _MinuteControl()), + ], ), if (hourDialType == _HourDialType.twelveHour) const _DayPeriodControl(), ], ), ], - ); - case Orientation.landscape: - return SizedBox( - width: _kTimePickerHeaderLandscapeWidth, - child: Stack( - children: <Widget>[ - Text( - helpText, - style: - _TimePickerModel.themeOf(context).helpTextStyle ?? - _TimePickerModel.defaultThemeOf(context).helpTextStyle, - ), - Column( - verticalDirection: - timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm - ? VerticalDirection.up - : VerticalDirection.down, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: <Widget>[ - Row( - // Hour/minutes should not change positions in RTL locales. - textDirection: TextDirection.ltr, - children: <Widget>[ - const Expanded(child: _HourControl()), - _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), - const Expanded(child: _MinuteControl()), - ], - ), - if (hourDialType == _HourDialType.twelveHour) const _DayPeriodControl(), - ], - ), - ], - ), - ); - } + ), + ), + }; + + return Semantics( + label: MaterialLocalizations.of(context).formatTimeOfDay( + _TimePickerModel.selectedTimeOf(context), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ), + child: orientationSpecificHeader, + ); } } @@ -443,11 +451,7 @@ class _HourControl extends StatelessWidget { child: _HourMinuteControl( isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.hour, text: formattedHour, - onTap: - Feedback.wrapForTap( - () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.hour), - context, - )!, + onTap: () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.hour), onDoubleTap: _TimePickerModel.of(context, _TimePickerAspect.onHourDoubleTapped).onHourDoubleTapped, ), @@ -559,11 +563,7 @@ class _MinuteControl extends StatelessWidget { child: _HourMinuteControl( isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.minute, text: formattedMinute, - onTap: - Feedback.wrapForTap( - () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.minute), - context, - )!, + onTap: () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.minute), onDoubleTap: _TimePickerModel.of( context, @@ -597,19 +597,6 @@ class _DayPeriodControl extends StatelessWidget { if (selectedTime.period == DayPeriod.am) { return; } - switch (Theme.of(context).platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - _announceToAccessibility( - context, - MaterialLocalizations.of(context).anteMeridiemAbbreviation, - ); - case TargetPlatform.iOS: - case TargetPlatform.macOS: - break; - } _togglePeriod(context); } @@ -618,19 +605,6 @@ class _DayPeriodControl extends StatelessWidget { if (selectedTime.period == DayPeriod.pm) { return; } - switch (Theme.of(context).platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - _announceToAccessibility( - context, - MaterialLocalizations.of(context).postMeridiemAbbreviation, - ); - case TargetPlatform.iOS: - case TargetPlatform.macOS: - break; - } _togglePeriod(context); } @@ -758,7 +732,7 @@ class _AmPmButton extends StatelessWidget { return Material( color: resolvedBackgroundColor, child: InkWell( - onTap: Feedback.wrapForTap(onPressed, context), + onTap: onPressed, child: Semantics( checked: selected, inMutuallyExclusiveGroup: true, @@ -1328,18 +1302,9 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { _center = box.size.center(Offset.zero); _dialSize = box.size; _updateThetaForPan(roundMinutes: true); - final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true); + _notifyOnChangedIfNeeded(roundMinutes: true); if (widget.hourMinuteMode == _HourMinuteMode.hour) { - switch (widget.hourDialType) { - case _HourDialType.twentyFourHour: - case _HourDialType.twentyFourHourDoubleRing: - _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); - case _HourDialType.twelveHour: - _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod)); - } widget.onHourSelected?.call(); - } else { - _announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); } final TimeOfDay time = _getTimeForTheta( _theta.value, @@ -1354,7 +1319,6 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { } void _selectHour(int hour) { - _announceToAccessibility(context, localizations.formatDecimal(hour)); final TimeOfDay time; TimeOfDay getAmPmTime() { @@ -1387,7 +1351,6 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { } void _selectMinute(int minute) { - _announceToAccessibility(context, localizations.formatDecimal(minute)); final TimeOfDay time = TimeOfDay(hour: widget.selectedTime.hour, minute: minute); final double angle = _getThetaForTime(time); _thetaTween @@ -2775,7 +2738,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { ); final RestorableBoolN _autofocusHour = RestorableBoolN(null); final RestorableBoolN _autofocusMinute = RestorableBoolN(null); - final RestorableBool _announcedInitialTime = RestorableBool(false); late final RestorableEnumN<Orientation> _orientation = RestorableEnumN<Orientation>( widget.orientation, values: Orientation.values, @@ -2793,7 +2755,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { _lastModeAnnounced.dispose(); _autofocusHour.dispose(); _autofocusMinute.dispose(); - _announcedInitialTime.dispose(); super.dispose(); } @@ -2801,8 +2762,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { void didChangeDependencies() { super.didChangeDependencies(); localizations = MaterialLocalizations.of(context); - _announceInitialTimeOnce(); - _announceModeOnce(); } @override @@ -2829,7 +2788,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); registerForRestoration(_autofocusHour, 'autofocus_hour'); registerForRestoration(_autofocusMinute, 'autofocus_minute'); - registerForRestoration(_announcedInitialTime, 'announced_initial_time'); registerForRestoration(_selectedTime, 'selected_time'); registerForRestoration(_orientation, 'orientation'); } @@ -2855,7 +2813,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { _vibrate(); setState(() { _hourMinuteMode.value = mode; - _announceModeOnce(); }); } @@ -2877,37 +2834,6 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { }); } - void _announceModeOnce() { - if (_lastModeAnnounced.value == _hourMinuteMode.value) { - // Already announced it. - return; - } - - switch (_hourMinuteMode.value) { - case _HourMinuteMode.hour: - _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement); - case _HourMinuteMode.minute: - _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement); - } - _lastModeAnnounced.value = _hourMinuteMode.value; - } - - void _announceInitialTimeOnce() { - if (_announcedInitialTime.value) { - return; - } - - final MaterialLocalizations localizations = MaterialLocalizations.of(context); - _announceToAccessibility( - context, - localizations.formatTimeOfDay( - _selectedTime.value, - alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - ), - ); - _announcedInitialTime.value = true; - } - void _handleTimeChanged(TimeOfDay value) { _vibrate(); setState(() { @@ -2969,17 +2895,24 @@ class _TimePickerState extends State<_TimePicker> with RestorationMixin { }; final Widget dial = Padding( padding: dialPadding, - child: ExcludeSemantics( - child: SizedBox.fromSize( - size: defaultTheme.dialSize, - child: AspectRatio( - aspectRatio: 1, - child: _Dial( - hourMinuteMode: _hourMinuteMode.value, - hourDialType: hourMode, - selectedTime: _selectedTime.value, - onChanged: _handleTimeChanged, - onHourSelected: _handleHourSelected, + child: Semantics( + label: switch (_hourMinuteMode.value) { + _HourMinuteMode.hour => localizations.timePickerHourModeAnnouncement, + _HourMinuteMode.minute => localizations.timePickerMinuteModeAnnouncement, + }, + liveRegion: true, + child: ExcludeSemantics( + child: SizedBox.fromSize( + size: defaultTheme.dialSize, + child: AspectRatio( + aspectRatio: 1, + child: _Dial( + hourMinuteMode: _hourMinuteMode.value, + hourDialType: hourMode, + selectedTime: _selectedTime.value, + onChanged: _handleTimeChanged, + onHourSelected: _handleHourSelected, + ), ), ), ), @@ -3233,10 +3166,6 @@ Future<TimeOfDay?> showTimePicker({ ); } -void _announceToAccessibility(BuildContext context, String message) { - SemanticsService.announce(message, Directionality.of(context)); -} - // An abstract base class for the M2 and M3 defaults below, so that their return // types can be non-nullable. abstract class _TimePickerDefaults extends TimePickerThemeData { diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index 5dba36d33da3cc54452cc92188a991b80ad792fa..88474d1ba7797dd6310b2b3a0bfd4f7eda15ded4 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart @@ -2211,6 +2211,77 @@ void main() { expect(paragraph.text.style!.fontSize, 56.0); }); + testWidgets('provides semantics information for hour/minute mode announcement', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + await mediaQueryBoilerplate(tester, materialType: MaterialType.material3); + + final MaterialLocalizations localizations = MaterialLocalizations.of( + tester.element(find.byType(TimePickerDialog)), + ); + final Finder semanticsFinder = find.bySemanticsLabel( + localizations.timePickerHourModeAnnouncement, + ); + + final SemanticsNode semanticsNode = tester.getSemantics(semanticsFinder); + expect( + semanticsNode.label, + localizations.timePickerHourModeAnnouncement, + reason: 'Label should announce hour mode initially', + ); + expect( + semanticsNode.hasFlag(SemanticsFlag.isLiveRegion), + isTrue, + reason: 'Node should be a live region to announce changes', + ); + + // --- Switch to minute mode --- + final Finder minuteControlInkWell = find.descendant( + of: _minuteControl, + matching: find.byType(InkWell), + ); + expect(minuteControlInkWell, findsOneWidget, reason: 'Minute control should exist'); + await tester.tap(minuteControlInkWell); + await tester.pumpAndSettle(); + + // Get the updated node properties + expect( + semanticsNode.label, + localizations.timePickerMinuteModeAnnouncement, + reason: 'Label should announce minute mode after switching', + ); + + semantics.dispose(); + }); + + testWidgets('provides semantics information for the header (selected time)', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + const TimeOfDay initialTime = TimeOfDay(hour: 7, minute: 15); + + await mediaQueryBoilerplate( + tester, + initialTime: initialTime, + materialType: MaterialType.material3, + ); + + final MaterialLocalizations localizations = MaterialLocalizations.of( + tester.element(find.byType(TimePickerDialog)), + ); + final String expectedLabel12Hour = localizations.formatTimeOfDay(initialTime); + final String expectedHelpText = localizations.timePickerDialHelpText; + + expect( + semantics, + includesNodeWith(label: '$expectedLabel12Hour\n$expectedHelpText'), + reason: 'Header should have semantics label: $expectedLabel12Hour (12-hour)', + ); + + semantics.dispose(); + }); + // This is a regression test for https://github.com/flutter/flutter/issues/153549. testWidgets('Time picker hour minute does not resize on error', (WidgetTester tester) async { await startPicker(entryMode: TimePickerEntryMode.input, tester, (TimeOfDay? value) {});