| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:math' as math; |
| import 'dart:ui'; |
| |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'button_style.dart'; |
| import 'color_scheme.dart'; |
| import 'colors.dart'; |
| import 'curves.dart'; |
| import 'debug.dart'; |
| import 'dialog.dart'; |
| import 'feedback.dart'; |
| import 'icon_button.dart'; |
| import 'icons.dart'; |
| import 'ink_well.dart'; |
| import 'input_border.dart'; |
| import 'input_decorator.dart'; |
| import 'material.dart'; |
| import 'material_localizations.dart'; |
| import 'material_state.dart'; |
| import 'text_button.dart'; |
| import 'text_form_field.dart'; |
| import 'text_theme.dart'; |
| import 'theme.dart'; |
| import 'theme_data.dart'; |
| import 'time.dart'; |
| import 'time_picker_theme.dart'; |
| |
| // Examples can assume: |
| // late BuildContext context; |
| |
| const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200); |
| const Duration _kDialAnimateDuration = Duration(milliseconds: 200); |
| const double _kTwoPi = 2 * math.pi; |
| const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); |
| |
| const double _kTimePickerHeaderLandscapeWidth = 216; |
| const double _kTimePickerInnerDialOffset = 28; |
| const double _kTimePickerDialMinRadius = 50; |
| const double _kTimePickerDialPadding = 28; |
| |
| /// Interactive input mode of the time picker dialog. |
| /// |
| /// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and the user |
| /// taps or drags the time they wish to select. In TimePickerEntryMode.input] |
| /// mode, [TextField]s are displayed and the user types in the time they wish to |
| /// select. |
| /// |
| /// See also: |
| /// |
| /// * [showTimePicker], a function that shows a [TimePickerDialog] and returns |
| /// the selected time as a [Future]. |
| enum TimePickerEntryMode { |
| /// User picks time from a clock dial. |
| /// |
| /// Can switch to [input] by activating a mode button in the dialog. |
| dial, |
| |
| /// User can input the time by typing it into text fields. |
| /// |
| /// Can switch to [dial] by activating a mode button in the dialog. |
| input, |
| |
| /// User can only pick time from a clock dial. |
| /// |
| /// There is no user interface to switch to another mode. |
| dialOnly, |
| |
| /// User can only input the time by typing it into text fields. |
| /// |
| /// There is no user interface to switch to another mode. |
| inputOnly |
| } |
| |
| // Whether the dial-mode time picker is currently selecting the hour or the |
| // minute. |
| enum _HourMinuteMode { hour, minute } |
| |
| // Aspects of _TimePickerModel that can be depended upon. |
| enum _TimePickerAspect { |
| use24HourFormat, |
| useMaterial3, |
| entryMode, |
| hourMinuteMode, |
| onHourMinuteModeChanged, |
| onHourDoubleTapped, |
| onMinuteDoubleTapped, |
| hourDialType, |
| selectedTime, |
| onSelectedTimeChanged, |
| orientation, |
| theme, |
| defaultTheme, |
| } |
| |
| class _TimePickerModel extends InheritedModel<_TimePickerAspect> { |
| const _TimePickerModel({ |
| required this.entryMode, |
| required this.hourMinuteMode, |
| required this.onHourMinuteModeChanged, |
| required this.onHourDoubleTapped, |
| required this.onMinuteDoubleTapped, |
| required this.selectedTime, |
| required this.onSelectedTimeChanged, |
| required this.use24HourFormat, |
| required this.useMaterial3, |
| required this.hourDialType, |
| required this.orientation, |
| required this.theme, |
| required this.defaultTheme, |
| required super.child, |
| }); |
| |
| final TimePickerEntryMode entryMode; |
| final _HourMinuteMode hourMinuteMode; |
| final ValueChanged<_HourMinuteMode> onHourMinuteModeChanged; |
| final GestureTapCallback onHourDoubleTapped; |
| final GestureTapCallback onMinuteDoubleTapped; |
| final TimeOfDay selectedTime; |
| final ValueChanged<TimeOfDay> onSelectedTimeChanged; |
| final bool use24HourFormat; |
| final bool useMaterial3; |
| final _HourDialType hourDialType; |
| final Orientation orientation; |
| final TimePickerThemeData theme; |
| final _TimePickerDefaults defaultTheme; |
| |
| static _TimePickerModel of(BuildContext context, [_TimePickerAspect? aspect]) => InheritedModel.inheritFrom<_TimePickerModel>(context, aspect: aspect)!; |
| static TimePickerEntryMode entryModeOf(BuildContext context) => of(context, _TimePickerAspect.entryMode).entryMode; |
| static _HourMinuteMode hourMinuteModeOf(BuildContext context) => of(context, _TimePickerAspect.hourMinuteMode).hourMinuteMode; |
| static TimeOfDay selectedTimeOf(BuildContext context) => of(context, _TimePickerAspect.selectedTime).selectedTime; |
| static bool use24HourFormatOf(BuildContext context) => of(context, _TimePickerAspect.use24HourFormat).use24HourFormat; |
| static bool useMaterial3Of(BuildContext context) => of(context, _TimePickerAspect.useMaterial3).useMaterial3; |
| static _HourDialType hourDialTypeOf(BuildContext context) => of(context, _TimePickerAspect.hourDialType).hourDialType; |
| static Orientation orientationOf(BuildContext context) => of(context, _TimePickerAspect.orientation).orientation; |
| static TimePickerThemeData themeOf(BuildContext context) => of(context, _TimePickerAspect.theme).theme; |
| static _TimePickerDefaults defaultThemeOf(BuildContext context) => of(context, _TimePickerAspect.defaultTheme).defaultTheme; |
| |
| static void setSelectedTime(BuildContext context, TimeOfDay value) => of(context, _TimePickerAspect.onSelectedTimeChanged).onSelectedTimeChanged(value); |
| static void setHourMinuteMode(BuildContext context, _HourMinuteMode value) => of(context, _TimePickerAspect.onHourMinuteModeChanged).onHourMinuteModeChanged(value); |
| |
| @override |
| bool updateShouldNotifyDependent(_TimePickerModel oldWidget, Set<_TimePickerAspect> dependencies) { |
| if (use24HourFormat != oldWidget.use24HourFormat && dependencies.contains(_TimePickerAspect.use24HourFormat)) { |
| return true; |
| } |
| if (useMaterial3 != oldWidget.useMaterial3 && dependencies.contains(_TimePickerAspect.useMaterial3)) { |
| return true; |
| } |
| if (entryMode != oldWidget.entryMode && dependencies.contains(_TimePickerAspect.entryMode)) { |
| return true; |
| } |
| if (hourMinuteMode != oldWidget.hourMinuteMode && dependencies.contains(_TimePickerAspect.hourMinuteMode)) { |
| return true; |
| } |
| if (onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged && dependencies.contains(_TimePickerAspect.onHourMinuteModeChanged)) { |
| return true; |
| } |
| if (onHourMinuteModeChanged != oldWidget.onHourDoubleTapped && dependencies.contains(_TimePickerAspect.onHourDoubleTapped)) { |
| return true; |
| } |
| if (onHourMinuteModeChanged != oldWidget.onMinuteDoubleTapped && dependencies.contains(_TimePickerAspect.onMinuteDoubleTapped)) { |
| return true; |
| } |
| if (hourDialType != oldWidget.hourDialType && dependencies.contains(_TimePickerAspect.hourDialType)) { |
| return true; |
| } |
| if (selectedTime != oldWidget.selectedTime && dependencies.contains(_TimePickerAspect.selectedTime)) { |
| return true; |
| } |
| if (onSelectedTimeChanged != oldWidget.onSelectedTimeChanged && dependencies.contains(_TimePickerAspect.onSelectedTimeChanged)) { |
| return true; |
| } |
| if (orientation != oldWidget.orientation && dependencies.contains(_TimePickerAspect.orientation)) { |
| return true; |
| } |
| if (theme != oldWidget.theme && dependencies.contains(_TimePickerAspect.theme)) { |
| return true; |
| } |
| if (defaultTheme != oldWidget.defaultTheme && dependencies.contains(_TimePickerAspect.defaultTheme)) { |
| return true; |
| } |
| return false; |
| } |
| |
| @override |
| bool updateShouldNotify(_TimePickerModel oldWidget) { |
| return use24HourFormat != oldWidget.use24HourFormat |
| || useMaterial3 != oldWidget.useMaterial3 |
| || entryMode != oldWidget.entryMode |
| || hourMinuteMode != oldWidget.hourMinuteMode |
| || onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged |
| || onHourDoubleTapped != oldWidget.onHourDoubleTapped |
| || onMinuteDoubleTapped != oldWidget.onMinuteDoubleTapped |
| || hourDialType != oldWidget.hourDialType |
| || selectedTime != oldWidget.selectedTime |
| || onSelectedTimeChanged != oldWidget.onSelectedTimeChanged |
| || orientation != oldWidget.orientation |
| || theme != oldWidget.theme |
| || defaultTheme != oldWidget.defaultTheme; |
| } |
| } |
| |
| class _TimePickerHeader extends StatelessWidget { |
| const _TimePickerHeader({ required this.helpText }); |
| |
| final String helpText; |
| |
| @override |
| Widget build(BuildContext context) { |
| final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).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, |
| ), |
| ), |
| Row( |
| children: <Widget>[ |
| if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) |
| const _DayPeriodControl(), |
| Expanded( |
| child: Row( |
| // Hour/minutes should not change positions in RTL locales. |
| textDirection: TextDirection.ltr, |
| children: <Widget>[ |
| const Expanded(child: _HourControl()), |
| _StringFragment(timeOfDayFormat: timeOfDayFormat), |
| const Expanded(child: _MinuteControl()), |
| ], |
| ), |
| ), |
| if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) |
| ...<Widget>[ |
| const SizedBox(width: 12), |
| 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( |
| mainAxisAlignment: MainAxisAlignment.center, |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) |
| const _DayPeriodControl(), |
| Padding( |
| padding: EdgeInsets.only(bottom: hourDialType == _HourDialType.twelveHour ? 12 : 0), |
| child: Row( |
| // Hour/minutes should not change positions in RTL locales. |
| textDirection: TextDirection.ltr, |
| children: <Widget>[ |
| const Expanded(child: _HourControl()), |
| _StringFragment(timeOfDayFormat: timeOfDayFormat), |
| const Expanded(child: _MinuteControl()), |
| ], |
| ), |
| ), |
| if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) |
| const _DayPeriodControl(), |
| ], |
| ), |
| ], |
| ), |
| ); |
| } |
| } |
| } |
| |
| class _HourMinuteControl extends StatelessWidget { |
| const _HourMinuteControl({ |
| required this.text, |
| required this.onTap, |
| required this.onDoubleTap, |
| required this.isSelected, |
| }); |
| |
| final String text; |
| final GestureTapCallback onTap; |
| final GestureTapCallback onDoubleTap; |
| final bool isSelected; |
| |
| @override |
| Widget build(BuildContext context) { |
| final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); |
| final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); |
| final Color backgroundColor = timePickerTheme.hourMinuteColor ?? defaultTheme.hourMinuteColor; |
| final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? defaultTheme.hourMinuteShape; |
| |
| final Set<MaterialState> states = <MaterialState>{ |
| if (isSelected) MaterialState.selected, |
| }; |
| final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>( |
| _TimePickerModel.themeOf(context).hourMinuteTextColor ?? _TimePickerModel.defaultThemeOf(context).hourMinuteTextColor, |
| states, |
| ); |
| final TextStyle effectiveStyle = MaterialStateProperty.resolveAs<TextStyle>( |
| timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle, |
| states, |
| ).copyWith(color: effectiveTextColor); |
| |
| final double height; |
| switch (_TimePickerModel.entryModeOf(context)) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| height = defaultTheme.hourMinuteSize.height; |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| height = defaultTheme.hourMinuteInputSize.height; |
| } |
| |
| return SizedBox( |
| height: height, |
| child: Material( |
| color: MaterialStateProperty.resolveAs(backgroundColor, states), |
| clipBehavior: Clip.antiAlias, |
| shape: shape, |
| child: InkWell( |
| onTap: onTap, |
| onDoubleTap: isSelected ? onDoubleTap : null, |
| child: Center( |
| child: Text( |
| text, |
| style: effectiveStyle, |
| textScaler: TextScaler.noScaling, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// Displays the hour fragment. |
| /// |
| /// When tapped changes time picker dial mode to [_HourMinuteMode.hour]. |
| class _HourControl extends StatelessWidget { |
| const _HourControl(); |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasMediaQuery(context)); |
| final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| final String formattedHour = localizations.formatHour( |
| selectedTime, |
| alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context), |
| ); |
| |
| TimeOfDay hoursFromSelected(int hoursToAdd) { |
| switch (_TimePickerModel.hourDialTypeOf(context)) { |
| case _HourDialType.twentyFourHour: |
| case _HourDialType.twentyFourHourDoubleRing: |
| final int selectedHour = selectedTime.hour; |
| return selectedTime.replacing( |
| hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay, |
| ); |
| case _HourDialType.twelveHour: |
| // Cycle 1 through 12 without changing day period. |
| final int periodOffset = selectedTime.periodOffset; |
| final int hours = selectedTime.hourOfPeriod; |
| return selectedTime.replacing( |
| hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, |
| ); |
| } |
| } |
| |
| final TimeOfDay nextHour = hoursFromSelected(1); |
| final String formattedNextHour = localizations.formatHour( |
| nextHour, |
| alwaysUse24HourFormat: alwaysUse24HourFormat, |
| ); |
| final TimeOfDay previousHour = hoursFromSelected(-1); |
| final String formattedPreviousHour = localizations.formatHour( |
| previousHour, |
| alwaysUse24HourFormat: alwaysUse24HourFormat, |
| ); |
| |
| return Semantics( |
| value: '${localizations.timePickerHourModeAnnouncement} $formattedHour', |
| excludeSemantics: true, |
| increasedValue: formattedNextHour, |
| onIncrease: () { |
| _TimePickerModel.setSelectedTime(context, nextHour); |
| }, |
| decreasedValue: formattedPreviousHour, |
| onDecrease: () { |
| _TimePickerModel.setSelectedTime(context, previousHour); |
| }, |
| child: _HourMinuteControl( |
| isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.hour, |
| text: formattedHour, |
| onTap: Feedback.wrapForTap(() => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.hour), context)!, |
| onDoubleTap: _TimePickerModel.of(context, _TimePickerAspect.onHourDoubleTapped).onHourDoubleTapped, |
| ), |
| ); |
| } |
| } |
| |
| /// A passive fragment showing a string value. |
| /// |
| /// Used to display the appropriate separator between the input fields. |
| class _StringFragment extends StatelessWidget { |
| const _StringFragment({ required this.timeOfDayFormat }); |
| |
| final TimeOfDayFormat timeOfDayFormat; |
| |
| String _stringFragmentValue(TimeOfDayFormat timeOfDayFormat) { |
| switch (timeOfDayFormat) { |
| case TimeOfDayFormat.h_colon_mm_space_a: |
| case TimeOfDayFormat.a_space_h_colon_mm: |
| case TimeOfDayFormat.H_colon_mm: |
| case TimeOfDayFormat.HH_colon_mm: |
| return ':'; |
| case TimeOfDayFormat.HH_dot_mm: |
| return '.'; |
| case TimeOfDayFormat.frenchCanadian: |
| return 'h'; |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); |
| final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| final Set<MaterialState> states = <MaterialState>{}; |
| |
| final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>( |
| timePickerTheme.hourMinuteTextColor ?? defaultTheme.hourMinuteTextColor, |
| states, |
| ); |
| final TextStyle effectiveStyle = MaterialStateProperty.resolveAs<TextStyle>( |
| timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle, |
| states, |
| ).copyWith(color: effectiveTextColor); |
| |
| final double height; |
| switch (_TimePickerModel.entryModeOf(context)) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| height = defaultTheme.hourMinuteSize.height; |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| height = defaultTheme.hourMinuteInputSize.height; |
| } |
| |
| return ExcludeSemantics( |
| child: SizedBox( |
| width: timeOfDayFormat == TimeOfDayFormat.frenchCanadian ? 36 : 24, |
| height: height, |
| child: Text( |
| _stringFragmentValue(timeOfDayFormat), |
| style: effectiveStyle, |
| textScaler: TextScaler.noScaling, |
| textAlign: TextAlign.center, |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// Displays the minute fragment. |
| /// |
| /// When tapped changes time picker dial mode to [_HourMinuteMode.minute]. |
| class _MinuteControl extends StatelessWidget { |
| const _MinuteControl(); |
| |
| @override |
| Widget build(BuildContext context) { |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| final String formattedMinute = localizations.formatMinute(selectedTime); |
| final TimeOfDay nextMinute = selectedTime.replacing( |
| minute: (selectedTime.minute + 1) % TimeOfDay.minutesPerHour, |
| ); |
| final String formattedNextMinute = localizations.formatMinute(nextMinute); |
| final TimeOfDay previousMinute = selectedTime.replacing( |
| minute: (selectedTime.minute - 1) % TimeOfDay.minutesPerHour, |
| ); |
| final String formattedPreviousMinute = localizations.formatMinute(previousMinute); |
| |
| return Semantics( |
| excludeSemantics: true, |
| value: '${localizations.timePickerMinuteModeAnnouncement} $formattedMinute', |
| increasedValue: formattedNextMinute, |
| onIncrease: () { |
| _TimePickerModel.setSelectedTime(context, nextMinute); |
| }, |
| decreasedValue: formattedPreviousMinute, |
| onDecrease: () { |
| _TimePickerModel.setSelectedTime(context, previousMinute); |
| }, |
| child: _HourMinuteControl( |
| isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.minute, |
| text: formattedMinute, |
| onTap: Feedback.wrapForTap(() => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.minute), context)!, |
| onDoubleTap: _TimePickerModel.of(context, _TimePickerAspect.onMinuteDoubleTapped).onMinuteDoubleTapped, |
| ), |
| ); |
| } |
| } |
| |
| /// Displays the am/pm fragment and provides controls for switching between am |
| /// and pm. |
| class _DayPeriodControl extends StatelessWidget { |
| const _DayPeriodControl({ this.onPeriodChanged }); |
| |
| final ValueChanged<TimeOfDay>? onPeriodChanged; |
| |
| void _togglePeriod(BuildContext context) { |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; |
| final TimeOfDay newTime = selectedTime.replacing(hour: newHour); |
| if (onPeriodChanged != null) { |
| onPeriodChanged!.call(newTime); |
| } else { |
| _TimePickerModel.setSelectedTime(context, newTime); |
| } |
| } |
| |
| void _setAm(BuildContext context) { |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| 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); |
| } |
| |
| void _setPm(BuildContext context) { |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| 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); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); |
| final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); |
| final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); |
| final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); |
| final bool amSelected = selectedTime.period == DayPeriod.am; |
| final bool pmSelected = !amSelected; |
| final BorderSide resolvedSide = timePickerTheme.dayPeriodBorderSide ?? defaultTheme.dayPeriodBorderSide; |
| final OutlinedBorder resolvedShape = (timePickerTheme.dayPeriodShape ?? defaultTheme.dayPeriodShape) |
| .copyWith(side: resolvedSide); |
| |
| final Widget amButton = _AmPmButton( |
| selected: amSelected, |
| onPressed: () => _setAm(context), |
| label: materialLocalizations.anteMeridiemAbbreviation, |
| ); |
| |
| final Widget pmButton = _AmPmButton( |
| selected: pmSelected, |
| onPressed: () => _setPm(context), |
| label: materialLocalizations.postMeridiemAbbreviation, |
| ); |
| |
| Size dayPeriodSize; |
| final Orientation orientation; |
| switch (_TimePickerModel.entryModeOf(context)) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| orientation = _TimePickerModel.orientationOf(context); |
| switch (orientation) { |
| case Orientation.portrait: |
| dayPeriodSize = defaultTheme.dayPeriodPortraitSize; |
| case Orientation.landscape: |
| dayPeriodSize = defaultTheme.dayPeriodLandscapeSize; |
| } |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| orientation = Orientation.portrait; |
| dayPeriodSize = defaultTheme.dayPeriodInputSize; |
| } |
| |
| final Widget result; |
| switch (orientation) { |
| case Orientation.portrait: |
| result = _DayPeriodInputPadding( |
| minSize: dayPeriodSize, |
| orientation: orientation, |
| child: SizedBox.fromSize( |
| size: dayPeriodSize, |
| child: Material( |
| clipBehavior: Clip.antiAlias, |
| color: Colors.transparent, |
| shape: resolvedShape, |
| child: Column( |
| children: <Widget>[ |
| Expanded(child: amButton), |
| Container( |
| decoration: BoxDecoration(border: Border(top: resolvedSide)), |
| height: 1, |
| ), |
| Expanded(child: pmButton), |
| ], |
| ), |
| ), |
| ), |
| ); |
| case Orientation.landscape: |
| result = _DayPeriodInputPadding( |
| minSize: dayPeriodSize, |
| orientation: orientation, |
| child: SizedBox( |
| height: dayPeriodSize.height, |
| child: Material( |
| clipBehavior: Clip.antiAlias, |
| color: Colors.transparent, |
| shape: resolvedShape, |
| child: Row( |
| children: <Widget>[ |
| Expanded(child: amButton), |
| Container( |
| decoration: BoxDecoration(border: Border(left: resolvedSide)), |
| width: 1, |
| ), |
| Expanded(child: pmButton), |
| ], |
| ), |
| ), |
| ), |
| ); |
| } |
| return result; |
| } |
| } |
| |
| class _AmPmButton extends StatelessWidget { |
| const _AmPmButton({ |
| required this.onPressed, |
| required this.selected, |
| required this.label, |
| }); |
| |
| final bool selected; |
| final VoidCallback onPressed; |
| final String label; |
| |
| @override |
| Widget build(BuildContext context) { |
| final Set<MaterialState> states = <MaterialState>{ if (selected) MaterialState.selected }; |
| final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); |
| final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); |
| final Color resolvedBackgroundColor = MaterialStateProperty.resolveAs<Color>(timePickerTheme.dayPeriodColor ?? defaultTheme.dayPeriodColor, states); |
| final Color resolvedTextColor = MaterialStateProperty.resolveAs<Color>(timePickerTheme.dayPeriodTextColor ?? defaultTheme.dayPeriodTextColor, states); |
| final TextStyle? resolvedTextStyle = MaterialStateProperty.resolveAs<TextStyle?>(timePickerTheme.dayPeriodTextStyle ?? defaultTheme.dayPeriodTextStyle, states)?.copyWith(color: resolvedTextColor); |
| final TextScaler buttonTextScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2.0); |
| |
| return Material( |
| color: resolvedBackgroundColor, |
| child: InkWell( |
| onTap: Feedback.wrapForTap(onPressed, context), |
| child: Semantics( |
| checked: selected, |
| inMutuallyExclusiveGroup: true, |
| button: true, |
| child: Center( |
| child: Text( |
| label, |
| style: resolvedTextStyle, |
| textScaler: buttonTextScaler, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// A widget to pad the area around the [_DayPeriodControl]'s inner [Material]. |
| class _DayPeriodInputPadding extends SingleChildRenderObjectWidget { |
| const _DayPeriodInputPadding({ |
| required Widget super.child, |
| required this.minSize, |
| required this.orientation, |
| }); |
| |
| final Size minSize; |
| final Orientation orientation; |
| |
| @override |
| RenderObject createRenderObject(BuildContext context) { |
| return _RenderInputPadding(minSize, orientation); |
| } |
| |
| @override |
| void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { |
| renderObject |
| ..minSize = minSize |
| ..orientation = orientation; |
| } |
| } |
| |
| class _RenderInputPadding extends RenderShiftedBox { |
| _RenderInputPadding(this._minSize, this._orientation, [RenderBox? child]) : super(child); |
| |
| Size get minSize => _minSize; |
| Size _minSize; |
| set minSize(Size value) { |
| if (_minSize == value) { |
| return; |
| } |
| _minSize = value; |
| markNeedsLayout(); |
| } |
| |
| Orientation get orientation => _orientation; |
| Orientation _orientation; |
| set orientation(Orientation value) { |
| if (_orientation == value) { |
| return; |
| } |
| _orientation = value; |
| markNeedsLayout(); |
| } |
| |
| @override |
| double computeMinIntrinsicWidth(double height) { |
| if (child != null) { |
| return math.max(child!.getMinIntrinsicWidth(height), minSize.width); |
| } |
| return 0; |
| } |
| |
| @override |
| double computeMinIntrinsicHeight(double width) { |
| if (child != null) { |
| return math.max(child!.getMinIntrinsicHeight(width), minSize.height); |
| } |
| return 0; |
| } |
| |
| @override |
| double computeMaxIntrinsicWidth(double height) { |
| if (child != null) { |
| return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); |
| } |
| return 0; |
| } |
| |
| @override |
| double computeMaxIntrinsicHeight(double width) { |
| if (child != null) { |
| return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); |
| } |
| return 0; |
| } |
| |
| Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { |
| if (child != null) { |
| final Size childSize = layoutChild(child!, constraints); |
| final double width = math.max(childSize.width, minSize.width); |
| final double height = math.max(childSize.height, minSize.height); |
| return constraints.constrain(Size(width, height)); |
| } |
| return Size.zero; |
| } |
| |
| @override |
| Size computeDryLayout(BoxConstraints constraints) { |
| return _computeSize( |
| constraints: constraints, |
| layoutChild: ChildLayoutHelper.dryLayoutChild, |
| ); |
| } |
| |
| @override |
| void performLayout() { |
| size = _computeSize( |
| constraints: constraints, |
| layoutChild: ChildLayoutHelper.layoutChild, |
| ); |
| if (child != null) { |
| final BoxParentData childParentData = child!.parentData! as BoxParentData; |
| childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); |
| } |
| } |
| |
| @override |
| bool hitTest(BoxHitTestResult result, { required Offset position }) { |
| if (super.hitTest(result, position: position)) { |
| return true; |
| } |
| |
| if (position.dx < 0 || |
| position.dx > math.max(child!.size.width, minSize.width) || |
| position.dy < 0 || |
| position.dy > math.max(child!.size.height, minSize.height)) { |
| return false; |
| } |
| |
| Offset newPosition = child!.size.center(Offset.zero); |
| switch (orientation) { |
| case Orientation.portrait: |
| if (position.dy > newPosition.dy) { |
| newPosition += const Offset(0, 1); |
| } else { |
| newPosition += const Offset(0, -1); |
| } |
| case Orientation.landscape: |
| if (position.dx > newPosition.dx) { |
| newPosition += const Offset(1, 0); |
| } else { |
| newPosition += const Offset(-1, 0); |
| } |
| } |
| |
| return result.addWithRawTransform( |
| transform: MatrixUtils.forceToPoint(newPosition), |
| position: newPosition, |
| hitTest: (BoxHitTestResult result, Offset position) { |
| assert(position == newPosition); |
| return child!.hitTest(result, position: newPosition); |
| }, |
| ); |
| } |
| } |
| |
| class _TappableLabel { |
| _TappableLabel({ |
| required this.value, |
| required this.inner, |
| required this.painter, |
| required this.onTap, |
| }); |
| |
| /// The value this label is displaying. |
| final int value; |
| |
| /// This value is part of the "inner" ring of values on the dial, used for 24 |
| /// hour input. |
| final bool inner; |
| |
| /// Paints the text of the label. |
| final TextPainter painter; |
| |
| /// Called when a tap gesture is detected on the label. |
| final VoidCallback onTap; |
| } |
| |
| class _DialPainter extends CustomPainter { |
| _DialPainter({ |
| required this.primaryLabels, |
| required this.selectedLabels, |
| required this.backgroundColor, |
| required this.handColor, |
| required this.handWidth, |
| required this.dotColor, |
| required this.dotRadius, |
| required this.centerRadius, |
| required this.theta, |
| required this.radius, |
| required this.textDirection, |
| required this.selectedValue, |
| }) : super(repaint: PaintingBinding.instance.systemFonts); |
| |
| final List<_TappableLabel> primaryLabels; |
| final List<_TappableLabel> selectedLabels; |
| final Color backgroundColor; |
| final Color handColor; |
| final double handWidth; |
| final Color dotColor; |
| final double dotRadius; |
| final double centerRadius; |
| final double theta; |
| final double radius; |
| final TextDirection textDirection; |
| final int selectedValue; |
| |
| void dispose() { |
| for (final _TappableLabel label in primaryLabels) { |
| label.painter.dispose(); |
| } |
| for (final _TappableLabel label in selectedLabels) { |
| label.painter.dispose(); |
| } |
| primaryLabels.clear(); |
| selectedLabels.clear(); |
| } |
| |
| @override |
| void paint(Canvas canvas, Size size) { |
| final double dialRadius = clampDouble(size.shortestSide / 2, _kTimePickerDialMinRadius + dotRadius, double.infinity); |
| final double labelRadius = clampDouble(dialRadius - _kTimePickerDialPadding, _kTimePickerDialMinRadius, double.infinity); |
| final double innerLabelRadius = clampDouble(labelRadius - _kTimePickerInnerDialOffset, 0, double.infinity); |
| final double handleRadius = clampDouble(labelRadius - (radius < 0.5 ? 1 : 0) * (labelRadius - innerLabelRadius), _kTimePickerDialMinRadius, double.infinity); |
| final Offset center = Offset(size.width / 2, size.height / 2); |
| final Offset centerPoint = center; |
| canvas.drawCircle(centerPoint, dialRadius, Paint()..color = backgroundColor); |
| |
| Offset getOffsetForTheta(double theta, double radius) { |
| return center + Offset(radius * math.cos(theta), -radius * math.sin(theta)); |
| } |
| |
| void paintLabels(List<_TappableLabel> labels, double radius) { |
| if (labels.isEmpty) { |
| return; |
| } |
| final double labelThetaIncrement = -_kTwoPi / labels.length; |
| double labelTheta = math.pi / 2; |
| |
| for (final _TappableLabel label in labels) { |
| final TextPainter labelPainter = label.painter; |
| final Offset labelOffset = Offset(-labelPainter.width / 2, -labelPainter.height / 2); |
| labelPainter.paint(canvas, getOffsetForTheta(labelTheta, radius) + labelOffset); |
| labelTheta += labelThetaIncrement; |
| } |
| } |
| |
| void paintInnerOuterLabels(List<_TappableLabel>? labels) { |
| if (labels == null) { |
| return; |
| } |
| |
| paintLabels(labels.where((_TappableLabel label) => !label.inner).toList(), labelRadius); |
| paintLabels(labels.where((_TappableLabel label) => label.inner).toList(), innerLabelRadius); |
| } |
| |
| paintInnerOuterLabels(primaryLabels); |
| |
| final Paint selectorPaint = Paint()..color = handColor; |
| final Offset focusedPoint = getOffsetForTheta(theta, handleRadius); |
| canvas.drawCircle(centerPoint, centerRadius, selectorPaint); |
| canvas.drawCircle(focusedPoint, dotRadius, selectorPaint); |
| selectorPaint.strokeWidth = handWidth; |
| canvas.drawLine(centerPoint, focusedPoint, selectorPaint); |
| |
| // Add a dot inside the selector but only when it isn't over the labels. |
| // This checks that the selector's theta is between two labels. A remainder |
| // between 0.1 and 0.45 indicates that the selector is roughly not above any |
| // labels. The values were derived by manually testing the dial. |
| final double labelThetaIncrement = -_kTwoPi / primaryLabels.length; |
| if (theta % labelThetaIncrement > 0.1 && theta % labelThetaIncrement < 0.45) { |
| canvas.drawCircle(focusedPoint, 2, selectorPaint..color = dotColor); |
| } |
| |
| final Rect focusedRect = Rect.fromCircle( |
| center: focusedPoint, |
| radius: dotRadius, |
| ); |
| canvas |
| ..save() |
| ..clipPath(Path()..addOval(focusedRect)); |
| paintInnerOuterLabels(selectedLabels); |
| canvas.restore(); |
| } |
| |
| @override |
| bool shouldRepaint(_DialPainter oldPainter) { |
| return oldPainter.primaryLabels != primaryLabels |
| || oldPainter.selectedLabels != selectedLabels |
| || oldPainter.backgroundColor != backgroundColor |
| || oldPainter.handColor != handColor |
| || oldPainter.theta != theta; |
| } |
| } |
| |
| // Which kind of hour dial being presented. |
| enum _HourDialType { |
| twentyFourHour, |
| twentyFourHourDoubleRing, |
| twelveHour, |
| } |
| |
| class _Dial extends StatefulWidget { |
| const _Dial({ |
| required this.selectedTime, |
| required this.hourMinuteMode, |
| required this.hourDialType, |
| required this.onChanged, |
| required this.onHourSelected, |
| }); |
| |
| final TimeOfDay selectedTime; |
| final _HourMinuteMode hourMinuteMode; |
| final _HourDialType hourDialType; |
| final ValueChanged<TimeOfDay>? onChanged; |
| final VoidCallback? onHourSelected; |
| |
| @override |
| _DialState createState() => _DialState(); |
| } |
| |
| class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { |
| late ThemeData themeData; |
| late MaterialLocalizations localizations; |
| _DialPainter? painter; |
| late AnimationController _animationController; |
| late Tween<double> _thetaTween; |
| late Animation<double> _theta; |
| late Tween<double> _radiusTween; |
| late Animation<double> _radius; |
| bool _dragging = false; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _animationController = AnimationController( |
| duration: _kDialAnimateDuration, |
| vsync: this, |
| ); |
| _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime)); |
| _radiusTween = Tween<double>(begin: _getRadiusForTime(widget.selectedTime)); |
| _theta = _animationController |
| .drive(CurveTween(curve: standardEasing)) |
| .drive(_thetaTween) |
| ..addListener(() => setState(() { /* _theta.value has changed */ })); |
| _radius = _animationController |
| .drive(CurveTween(curve: standardEasing)) |
| .drive(_radiusTween) |
| ..addListener(() => setState(() { /* _radius.value has changed */ })); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| assert(debugCheckHasMediaQuery(context)); |
| themeData = Theme.of(context); |
| localizations = MaterialLocalizations.of(context); |
| } |
| |
| @override |
| void didUpdateWidget(_Dial oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget.hourMinuteMode != oldWidget.hourMinuteMode || widget.selectedTime != oldWidget.selectedTime) { |
| if (!_dragging) { |
| _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); |
| } |
| } |
| } |
| |
| @override |
| void dispose() { |
| _animationController.dispose(); |
| painter?.dispose(); |
| super.dispose(); |
| } |
| |
| static double _nearest(double target, double a, double b) { |
| return ((target - a).abs() < (target - b).abs()) ? a : b; |
| } |
| |
| void _animateTo(double targetTheta, double targetRadius) { |
| void animateToValue({ |
| required double target, |
| required Animation<double> animation, |
| required Tween<double> tween, |
| required AnimationController controller, |
| required double min, |
| required double max, |
| }) { |
| double beginValue = _nearest(target, animation.value, max); |
| beginValue = _nearest(target, beginValue, min); |
| tween |
| ..begin = beginValue |
| ..end = target; |
| controller |
| ..value = 0 |
| ..forward(); |
| } |
| |
| animateToValue( |
| target: targetTheta, |
| animation: _theta, |
| tween: _thetaTween, |
| controller: _animationController, |
| min: _theta.value - _kTwoPi, |
| max: _theta.value + _kTwoPi, |
| ); |
| animateToValue( |
| target: targetRadius, |
| animation: _radius, |
| tween: _radiusTween, |
| controller: _animationController, |
| min: 0, |
| max: 1, |
| ); |
| } |
| |
| double _getRadiusForTime(TimeOfDay time) { |
| switch (widget.hourMinuteMode) { |
| case _HourMinuteMode.hour: |
| switch (widget.hourDialType) { |
| case _HourDialType.twentyFourHourDoubleRing: |
| return time.hour >= 12 ? 0 : 1; |
| case _HourDialType.twentyFourHour: |
| case _HourDialType.twelveHour: |
| return 1; |
| } |
| case _HourMinuteMode.minute: |
| return 1; |
| } |
| } |
| |
| double _getThetaForTime(TimeOfDay time) { |
| final int hoursFactor; |
| switch (widget.hourDialType) { |
| case _HourDialType.twentyFourHour: |
| hoursFactor = TimeOfDay.hoursPerDay; |
| case _HourDialType.twentyFourHourDoubleRing: |
| hoursFactor = TimeOfDay.hoursPerPeriod; |
| case _HourDialType.twelveHour: |
| hoursFactor = TimeOfDay.hoursPerPeriod; |
| } |
| final double fraction; |
| switch (widget.hourMinuteMode) { |
| case _HourMinuteMode.hour: |
| fraction = (time.hour / hoursFactor) % hoursFactor; |
| case _HourMinuteMode.minute: |
| fraction = (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; |
| } |
| return (math.pi / 2 - fraction * _kTwoPi) % _kTwoPi; |
| } |
| |
| TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false, required double radius}) { |
| final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1; |
| switch (widget.hourMinuteMode) { |
| case _HourMinuteMode.hour: |
| int newHour; |
| switch (widget.hourDialType) { |
| case _HourDialType.twentyFourHour: |
| newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay; |
| case _HourDialType.twentyFourHourDoubleRing: |
| newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; |
| if (radius < 0.5) { |
| newHour = newHour + TimeOfDay.hoursPerPeriod; |
| } |
| case _HourDialType.twelveHour: |
| newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; |
| newHour = newHour + widget.selectedTime.periodOffset; |
| } |
| return widget.selectedTime.replacing(hour: newHour); |
| case _HourMinuteMode.minute: |
| int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour; |
| if (roundMinutes) { |
| // Round the minutes to nearest 5 minute interval. |
| minute = ((minute + 2) ~/ 5) * 5 % TimeOfDay.minutesPerHour; |
| } |
| return widget.selectedTime.replacing(minute: minute); |
| } |
| } |
| |
| TimeOfDay _notifyOnChangedIfNeeded({ bool roundMinutes = false }) { |
| final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes, radius: _radius.value); |
| if (widget.onChanged == null) { |
| return current; |
| } |
| if (current != widget.selectedTime) { |
| widget.onChanged!(current); |
| } |
| return current; |
| } |
| |
| void _updateThetaForPan({ bool roundMinutes = false }) { |
| setState(() { |
| final Offset offset = _position! - _center!; |
| final double labelRadius = _dialSize!.shortestSide / 2 - _kTimePickerDialPadding; |
| final double innerRadius = labelRadius - _kTimePickerInnerDialOffset; |
| double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2) % _kTwoPi; |
| final double radius = clampDouble((offset.distance - innerRadius) / _kTimePickerInnerDialOffset, 0, 1); |
| if (roundMinutes) { |
| angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes, radius: radius)); |
| } |
| // The controller doesn't animate during the pan gesture. |
| _thetaTween |
| ..begin = angle |
| ..end = angle; |
| _radiusTween |
| ..begin = radius |
| ..end = radius; |
| }); |
| } |
| |
| Offset? _position; |
| Offset? _center; |
| Size? _dialSize; |
| |
| void _handlePanStart(DragStartDetails details) { |
| assert(!_dragging); |
| _dragging = true; |
| final RenderBox box = context.findRenderObject()! as RenderBox; |
| _position = box.globalToLocal(details.globalPosition); |
| _dialSize = box.size; |
| _center = _dialSize!.center(Offset.zero); |
| _updateThetaForPan(); |
| _notifyOnChangedIfNeeded(); |
| } |
| |
| void _handlePanUpdate(DragUpdateDetails details) { |
| _position = _position! + details.delta; |
| _updateThetaForPan(); |
| _notifyOnChangedIfNeeded(); |
| } |
| |
| void _handlePanEnd(DragEndDetails details) { |
| assert(_dragging); |
| _dragging = false; |
| _position = null; |
| _center = null; |
| _dialSize = null; |
| _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); |
| if (widget.hourMinuteMode == _HourMinuteMode.hour) { |
| widget.onHourSelected?.call(); |
| } |
| } |
| |
| void _handleTapUp(TapUpDetails details) { |
| final RenderBox box = context.findRenderObject()! as RenderBox; |
| _position = box.globalToLocal(details.globalPosition); |
| _center = box.size.center(Offset.zero); |
| _dialSize = box.size; |
| _updateThetaForPan(roundMinutes: true); |
| final TimeOfDay newTime = _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, roundMinutes: true, radius: _radius.value); |
| _animateTo(_getThetaForTime(time), _getRadiusForTime(time)); |
| _dragging = false; |
| _position = null; |
| _center = null; |
| _dialSize = null; |
| } |
| |
| void _selectHour(int hour) { |
| _announceToAccessibility(context, localizations.formatDecimal(hour)); |
| final TimeOfDay time; |
| |
| TimeOfDay getAmPmTime() { |
| switch (widget.selectedTime.period) { |
| case DayPeriod.am: |
| return TimeOfDay(hour: hour, minute: widget.selectedTime.minute); |
| case DayPeriod.pm: |
| return TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute); |
| } |
| } |
| |
| switch (widget.hourMinuteMode) { |
| case _HourMinuteMode.hour: |
| switch (widget.hourDialType) { |
| case _HourDialType.twentyFourHour: |
| case _HourDialType.twentyFourHourDoubleRing: |
| time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); |
| case _HourDialType.twelveHour: |
| time = getAmPmTime(); |
| } |
| case _HourMinuteMode.minute: |
| time = getAmPmTime(); |
| } |
| final double angle = _getThetaForTime(time); |
| _thetaTween |
| ..begin = angle |
| ..end = angle; |
| _notifyOnChangedIfNeeded(); |
| } |
| |
| 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 |
| ..begin = angle |
| ..end = angle; |
| _notifyOnChangedIfNeeded(); |
| } |
| |
| static const List<TimeOfDay> _amHours = <TimeOfDay>[ |
| TimeOfDay(hour: 12, minute: 0), |
| TimeOfDay(hour: 1, minute: 0), |
| TimeOfDay(hour: 2, minute: 0), |
| TimeOfDay(hour: 3, minute: 0), |
| TimeOfDay(hour: 4, minute: 0), |
| TimeOfDay(hour: 5, minute: 0), |
| TimeOfDay(hour: 6, minute: 0), |
| TimeOfDay(hour: 7, minute: 0), |
| TimeOfDay(hour: 8, minute: 0), |
| TimeOfDay(hour: 9, minute: 0), |
| TimeOfDay(hour: 10, minute: 0), |
| TimeOfDay(hour: 11, minute: 0), |
| ]; |
| |
| // On M2, there's no inner ring of numbers. |
| static const List<TimeOfDay> _twentyFourHoursM2 = <TimeOfDay>[ |
| TimeOfDay(hour: 0, minute: 0), |
| TimeOfDay(hour: 2, minute: 0), |
| TimeOfDay(hour: 4, minute: 0), |
| TimeOfDay(hour: 6, minute: 0), |
| TimeOfDay(hour: 8, minute: 0), |
| TimeOfDay(hour: 10, minute: 0), |
| TimeOfDay(hour: 12, minute: 0), |
| TimeOfDay(hour: 14, minute: 0), |
| TimeOfDay(hour: 16, minute: 0), |
| TimeOfDay(hour: 18, minute: 0), |
| TimeOfDay(hour: 20, minute: 0), |
| TimeOfDay(hour: 22, minute: 0), |
| ]; |
| |
| static const List<TimeOfDay> _twentyFourHours = <TimeOfDay>[ |
| TimeOfDay(hour: 0, minute: 0), |
| TimeOfDay(hour: 1, minute: 0), |
| TimeOfDay(hour: 2, minute: 0), |
| TimeOfDay(hour: 3, minute: 0), |
| TimeOfDay(hour: 4, minute: 0), |
| TimeOfDay(hour: 5, minute: 0), |
| TimeOfDay(hour: 6, minute: 0), |
| TimeOfDay(hour: 7, minute: 0), |
| TimeOfDay(hour: 8, minute: 0), |
| TimeOfDay(hour: 9, minute: 0), |
| TimeOfDay(hour: 10, minute: 0), |
| TimeOfDay(hour: 11, minute: 0), |
| TimeOfDay(hour: 12, minute: 0), |
| TimeOfDay(hour: 13, minute: 0), |
| TimeOfDay(hour: 14, minute: 0), |
| TimeOfDay(hour: 15, minute: 0), |
| TimeOfDay(hour: 16, minute: 0), |
| TimeOfDay(hour: 17, minute: 0), |
| TimeOfDay(hour: 18, minute: 0), |
| TimeOfDay(hour: 19, minute: 0), |
| TimeOfDay(hour: 20, minute: 0), |
| TimeOfDay(hour: 21, minute: 0), |
| TimeOfDay(hour: 22, minute: 0), |
| TimeOfDay(hour: 23, minute: 0), |
| ]; |
| |
| _TappableLabel _buildTappableLabel({ |
| required TextStyle? textStyle, |
| required int selectedValue, |
| required int value, |
| required bool inner, |
| required String label, |
| required VoidCallback onTap, |
| }) { |
| return _TappableLabel( |
| value: value, |
| inner: inner, |
| painter: TextPainter( |
| text: TextSpan(style: textStyle, text: label), |
| textDirection: TextDirection.ltr, |
| textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2.0), |
| )..layout(), |
| onTap: onTap, |
| ); |
| } |
| |
| List<_TappableLabel> _build24HourRing({ |
| required TextStyle? textStyle, |
| required int selectedValue, |
| }) { |
| return <_TappableLabel>[ |
| if (themeData.useMaterial3) |
| for (final TimeOfDay timeOfDay in _twentyFourHours) |
| _buildTappableLabel( |
| textStyle: textStyle, |
| selectedValue: selectedValue, |
| inner: timeOfDay.hour >= 12, |
| value: timeOfDay.hour, |
| label: timeOfDay.hour != 0 |
| ? '${timeOfDay.hour}' |
| : localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), |
| onTap: () { |
| _selectHour(timeOfDay.hour); |
| }, |
| ), |
| if (!themeData.useMaterial3) |
| for (final TimeOfDay timeOfDay in _twentyFourHoursM2) |
| _buildTappableLabel( |
| textStyle: textStyle, |
| selectedValue: selectedValue, |
| inner: false, |
| value: timeOfDay.hour, |
| label: localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), |
| onTap: () { |
| _selectHour(timeOfDay.hour); |
| }, |
| ), |
| ]; |
| } |
| |
| List<_TappableLabel> _build12HourRing({ |
| required TextStyle? textStyle, |
| required int selectedValue, |
| }) { |
| return <_TappableLabel>[ |
| for (final TimeOfDay timeOfDay in _amHours) |
| _buildTappableLabel( |
| textStyle: textStyle, |
| selectedValue: selectedValue, |
| inner: false, |
| value: timeOfDay.hour, |
| label: localizations.formatHour(timeOfDay, alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)), |
| onTap: () { |
| _selectHour(timeOfDay.hour); |
| }, |
| ), |
| ]; |
| } |
| |
| List<_TappableLabel> _buildMinutes({ |
| required TextStyle? textStyle, |
| required int selectedValue, |
| }) { |
| const List<TimeOfDay> minuteMarkerValues = <TimeOfDay>[ |
| TimeOfDay(hour: 0, minute: 0), |
| TimeOfDay(hour: 0, minute: 5), |
| TimeOfDay(hour: 0, minute: 10), |
| TimeOfDay(hour: 0, minute: 15), |
| TimeOfDay(hour: 0, minute: 20), |
| TimeOfDay(hour: 0, minute: 25), |
| TimeOfDay(hour: 0, minute: 30), |
| TimeOfDay(hour: 0, minute: 35), |
| TimeOfDay(hour: 0, minute: 40), |
| TimeOfDay(hour: 0, minute: 45), |
| TimeOfDay(hour: 0, minute: 50), |
| TimeOfDay(hour: 0, minute: 55), |
| ]; |
| |
| return <_TappableLabel>[ |
| for (final TimeOfDay timeOfDay in minuteMarkerValues) |
| _buildTappableLabel( |
| textStyle: textStyle, |
| selectedValue: selectedValue, |
| inner: false, |
| value: timeOfDay.minute, |
| label: localizations.formatMinute(timeOfDay), |
| onTap: () { |
| _selectMinute(timeOfDay.minute); |
| }, |
| ), |
| ]; |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); |
| final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| final Color backgroundColor = timePickerTheme.dialBackgroundColor ?? defaultTheme.dialBackgroundColor; |
| final Color dialHandColor = timePickerTheme.dialHandColor ?? defaultTheme.dialHandColor; |
| final TextStyle labelStyle = timePickerTheme.dialTextStyle ?? defaultTheme.dialTextStyle; |
| final Color dialTextUnselectedColor = MaterialStateProperty |
| .resolveAs<Color>(timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, <MaterialState>{ }); |
| final Color dialTextSelectedColor = MaterialStateProperty |
| .resolveAs<Color>(timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, <MaterialState>{ MaterialState.selected }); |
| final TextStyle resolvedUnselectedLabelStyle = labelStyle.copyWith(color: dialTextUnselectedColor); |
| final TextStyle resolvedSelectedLabelStyle = labelStyle.copyWith(color: dialTextSelectedColor); |
| final Color dotColor = dialTextSelectedColor; |
| |
| List<_TappableLabel> primaryLabels; |
| List<_TappableLabel> selectedLabels; |
| final int selectedDialValue; |
| final double radiusValue; |
| switch (widget.hourMinuteMode) { |
| case _HourMinuteMode.hour: |
| switch (widget.hourDialType) { |
| case _HourDialType.twentyFourHour: |
| case _HourDialType.twentyFourHourDoubleRing: |
| selectedDialValue = widget.selectedTime.hour; |
| primaryLabels = _build24HourRing( |
| textStyle: resolvedUnselectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| selectedLabels = _build24HourRing( |
| textStyle: resolvedSelectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| radiusValue = theme.useMaterial3 ? _radius.value : 1; |
| case _HourDialType.twelveHour: |
| selectedDialValue = widget.selectedTime.hourOfPeriod; |
| primaryLabels = _build12HourRing( |
| textStyle: resolvedUnselectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| selectedLabels = _build12HourRing( |
| textStyle: resolvedSelectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| radiusValue = 1; |
| } |
| case _HourMinuteMode.minute: |
| selectedDialValue = widget.selectedTime.minute; |
| primaryLabels = _buildMinutes( |
| textStyle: resolvedUnselectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| selectedLabels = _buildMinutes( |
| textStyle: resolvedSelectedLabelStyle, |
| selectedValue: selectedDialValue, |
| ); |
| radiusValue = 1; |
| } |
| painter?.dispose(); |
| painter = _DialPainter( |
| selectedValue: selectedDialValue, |
| primaryLabels: primaryLabels, |
| selectedLabels: selectedLabels, |
| backgroundColor: backgroundColor, |
| handColor: dialHandColor, |
| handWidth: defaultTheme.handWidth, |
| dotColor: dotColor, |
| dotRadius: defaultTheme.dotRadius, |
| centerRadius: defaultTheme.centerRadius, |
| theta: _theta.value, |
| radius: radiusValue, |
| textDirection: Directionality.of(context), |
| ); |
| |
| return GestureDetector( |
| excludeFromSemantics: true, |
| onPanStart: _handlePanStart, |
| onPanUpdate: _handlePanUpdate, |
| onPanEnd: _handlePanEnd, |
| onTapUp: _handleTapUp, |
| child: CustomPaint( |
| key: const ValueKey<String>('time-picker-dial'), |
| painter: painter, |
| ), |
| ); |
| } |
| } |
| |
| class _TimePickerInput extends StatefulWidget { |
| const _TimePickerInput({ |
| required this.initialSelectedTime, |
| required this.errorInvalidText, |
| required this.hourLabelText, |
| required this.minuteLabelText, |
| required this.helpText, |
| required this.autofocusHour, |
| required this.autofocusMinute, |
| this.restorationId, |
| }); |
| |
| /// The time initially selected when the dialog is shown. |
| final TimeOfDay initialSelectedTime; |
| |
| /// Optionally provide your own validation error text. |
| final String? errorInvalidText; |
| |
| /// Optionally provide your own hour label text. |
| final String? hourLabelText; |
| |
| /// Optionally provide your own minute label text. |
| final String? minuteLabelText; |
| |
| final String helpText; |
| |
| final bool? autofocusHour; |
| |
| final bool? autofocusMinute; |
| |
| /// Restoration ID to save and restore the state of the time picker input |
| /// widget. |
| /// |
| /// If it is non-null, the widget will persist and restore its state |
| /// |
| /// The state of this widget is persisted in a [RestorationBucket] claimed |
| /// from the surrounding [RestorationScope] using the provided restoration ID. |
| final String? restorationId; |
| |
| @override |
| _TimePickerInputState createState() => _TimePickerInputState(); |
| } |
| |
| class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixin { |
| late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialSelectedTime); |
| final RestorableBool hourHasError = RestorableBool(false); |
| final RestorableBool minuteHasError = RestorableBool(false); |
| |
| @override |
| String? get restorationId => widget.restorationId; |
| |
| @override |
| void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
| registerForRestoration(_selectedTime, 'selected_time'); |
| registerForRestoration(hourHasError, 'hour_has_error'); |
| registerForRestoration(minuteHasError, 'minute_has_error'); |
| } |
| |
| int? _parseHour(String? value) { |
| if (value == null) { |
| return null; |
| } |
| |
| int? newHour = int.tryParse(value); |
| if (newHour == null) { |
| return null; |
| } |
| |
| if (MediaQuery.alwaysUse24HourFormatOf(context)) { |
| if (newHour >= 0 && newHour < 24) { |
| return newHour; |
| } |
| } else { |
| if (newHour > 0 && newHour < 13) { |
| if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) || |
| (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { |
| newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; |
| } |
| return newHour; |
| } |
| } |
| return null; |
| } |
| |
| int? _parseMinute(String? value) { |
| if (value == null) { |
| return null; |
| } |
| |
| final int? newMinute = int.tryParse(value); |
| if (newMinute == null) { |
| return null; |
| } |
| |
| if (newMinute >= 0 && newMinute < 60) { |
| return newMinute; |
| } |
| return null; |
| } |
| |
| void _handleHourSavedSubmitted(String? value) { |
| final int? newHour = _parseHour(value); |
| if (newHour != null) { |
| _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute); |
| _TimePickerModel.setSelectedTime(context, _selectedTime.value); |
| FocusScope.of(context).requestFocus(); |
| } |
| } |
| |
| void _handleHourChanged(String value) { |
| final int? newHour = _parseHour(value); |
| if (newHour != null && value.length == 2) { |
| // If a valid hour is typed, move focus to the minute TextField. |
| FocusScope.of(context).nextFocus(); |
| } |
| } |
| |
| void _handleMinuteSavedSubmitted(String? value) { |
| final int? newMinute = _parseMinute(value); |
| if (newMinute != null) { |
| _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!)); |
| _TimePickerModel.setSelectedTime(context, _selectedTime.value); |
| FocusScope.of(context).unfocus(); |
| } |
| } |
| |
| void _handleDayPeriodChanged(TimeOfDay value) { |
| _selectedTime.value = value; |
| _TimePickerModel.setSelectedTime(context, _selectedTime.value); |
| } |
| |
| String? _validateHour(String? value) { |
| final int? newHour = _parseHour(value); |
| setState(() { |
| hourHasError.value = newHour == null; |
| }); |
| // This is used as the validator for the [TextFormField]. |
| // Returning an empty string allows the field to go into an error state. |
| // Returning null means no error in the validation of the entered text. |
| return newHour == null ? '' : null; |
| } |
| |
| String? _validateMinute(String? value) { |
| final int? newMinute = _parseMinute(value); |
| setState(() { |
| minuteHasError.value = newMinute == null; |
| }); |
| // This is used as the validator for the [TextFormField]. |
| // Returning an empty string allows the field to go into an error state. |
| // Returning null means no error in the validation of the entered text. |
| return newMinute == null ? '' : null; |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasMediaQuery(context)); |
| final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context)); |
| final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; |
| final ThemeData theme = Theme.of(context); |
| final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); |
| final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); |
| final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle; |
| |
| return Padding( |
| padding: _TimePickerModel.useMaterial3Of(context) ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 16), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| Padding(padding: EdgeInsetsDirectional.only(bottom: _TimePickerModel.useMaterial3Of(context) ? 20 : 24), |
| child: Text( |
| widget.helpText, |
| style: _TimePickerModel.themeOf(context).helpTextStyle ?? _TimePickerModel.defaultThemeOf(context).helpTextStyle, |
| ), |
| ), |
| Row( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[ |
| Padding( |
| padding: const EdgeInsetsDirectional.only(end: 12), |
| child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), |
| ), |
| ], |
| Expanded( |
| child: Row( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| // Hour/minutes should not change positions in RTL locales. |
| textDirection: TextDirection.ltr, |
| children: <Widget>[ |
| Expanded( |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| Padding( |
| padding: const EdgeInsets.only(bottom: 10), |
| child: _HourTextField( |
| restorationId: 'hour_text_field', |
| selectedTime: _selectedTime.value, |
| style: hourMinuteStyle, |
| autofocus: widget.autofocusHour, |
| inputAction: TextInputAction.next, |
| validator: _validateHour, |
| onSavedSubmitted: _handleHourSavedSubmitted, |
| onChanged: _handleHourChanged, |
| hourLabelText: widget.hourLabelText, |
| ), |
| ), |
| if (!hourHasError.value && !minuteHasError.value) |
| ExcludeSemantics( |
| child: Text( |
| widget.hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, |
| style: theme.textTheme.bodySmall, |
| maxLines: 1, |
| overflow: TextOverflow.ellipsis, |
| ), |
| ), |
| ], |
| ), |
| ), |
| _StringFragment(timeOfDayFormat: timeOfDayFormat), |
| Expanded( |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| Padding( |
| padding: const EdgeInsets.only(bottom: 10), |
| child: _MinuteTextField( |
| restorationId: 'minute_text_field', |
| selectedTime: _selectedTime.value, |
| style: hourMinuteStyle, |
| autofocus: widget.autofocusMinute, |
| inputAction: TextInputAction.done, |
| validator: _validateMinute, |
| onSavedSubmitted: _handleMinuteSavedSubmitted, |
| minuteLabelText: widget.minuteLabelText, |
| ), |
| ), |
| if (!hourHasError.value && !minuteHasError.value) |
| ExcludeSemantics( |
| child: Text( |
| widget.minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel, |
| style: theme.textTheme.bodySmall, |
| maxLines: 1, |
| overflow: TextOverflow.ellipsis, |
| ), |
| ), |
| ], |
| ), |
| ), |
| ], |
| ), |
| ), |
| if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[ |
| Padding( |
| padding: const EdgeInsetsDirectional.only(start: 12), |
| child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), |
| ), |
| ], |
| ], |
| ), |
| if (hourHasError.value || minuteHasError.value) |
| Text( |
| widget.errorInvalidText ?? MaterialLocalizations.of(context).invalidTimeLabel, |
| style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.error), |
| ) |
| else |
| const SizedBox(height: 2), |
| ], |
| ), |
| ); |
| } |
| } |
| |
| class _HourTextField extends StatelessWidget { |
| const _HourTextField({ |
| required this.selectedTime, |
| required this.style, |
| required this.autofocus, |
| required this.inputAction, |
| required this.validator, |
| required this.onSavedSubmitted, |
| required this.onChanged, |
| required this.hourLabelText, |
| this.restorationId, |
| }); |
| |
| final TimeOfDay selectedTime; |
| final TextStyle style; |
| final bool? autofocus; |
| final TextInputAction inputAction; |
| final FormFieldValidator<String> validator; |
| final ValueChanged<String?> onSavedSubmitted; |
| final ValueChanged<String> onChanged; |
| final String? hourLabelText; |
| final String? restorationId; |
| |
| @override |
| Widget build(BuildContext context) { |
| return _HourMinuteTextField( |
| restorationId: restorationId, |
| selectedTime: selectedTime, |
| isHour: true, |
| autofocus: autofocus, |
| inputAction: inputAction, |
| style: style, |
| semanticHintText: hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, |
| validator: validator, |
| onSavedSubmitted: onSavedSubmitted, |
| onChanged: onChanged, |
| ); |
| } |
| } |
| |
| class _MinuteTextField extends StatelessWidget { |
| const _MinuteTextField({ |
| required this.selectedTime, |
| required this.style, |
| required this.autofocus, |
| required this.inputAction, |
| required this.validator, |
| required this.onSavedSubmitted, |
| required this.minuteLabelText, |
| this.restorationId, |
| }); |
| |
| final TimeOfDay selectedTime; |
| final TextStyle style; |
| final bool? autofocus; |
| final TextInputAction inputAction; |
| final FormFieldValidator<String> validator; |
| final ValueChanged<String?> onSavedSubmitted; |
| final String? minuteLabelText; |
| final String? restorationId; |
| |
| @override |
| Widget build(BuildContext context) { |
| return _HourMinuteTextField( |
| restorationId: restorationId, |
| selectedTime: selectedTime, |
| isHour: false, |
| autofocus: autofocus, |
| inputAction: inputAction, |
| style: style, |
| semanticHintText: minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel, |
| validator: validator, |
| onSavedSubmitted: onSavedSubmitted, |
| ); |
| } |
| } |
| |
| class _HourMinuteTextField extends StatefulWidget { |
| const _HourMinuteTextField({ |
| required this.selectedTime, |
| required this.isHour, |
| required this.autofocus, |
| required this.inputAction, |
| required this.style, |
| required this.semanticHintText, |
| required this.validator, |
| required this.onSavedSubmitted, |
| this.restorationId, |
| this.onChanged, |
| }); |
| |
| final TimeOfDay selectedTime; |
| final bool isHour; |
| final bool? autofocus; |
| final TextInputAction inputAction; |
| final TextStyle style; |
| final String semanticHintText; |
| final FormFieldValidator<String> validator; |
| final ValueChanged<String?> onSavedSubmitted; |
| final ValueChanged<String>? onChanged; |
| final String? restorationId; |
| |
| @override |
| _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); |
| } |
| |
| class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with RestorationMixin { |
| final RestorableTextEditingController controller = RestorableTextEditingController(); |
| final RestorableBool controllerHasBeenSet = RestorableBool(false); |
| late FocusNode focusNode; |
| |
| @override |
| void initState() { |
| super.initState(); |
| focusNode = FocusNode() |
| ..addListener(() { |
| setState(() { |
| // Rebuild when focus changes. |
| }); |
| }); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| // Only set the text value if it has not been populated with a localized |
| // version yet. |
| if (!controllerHasBeenSet.value) { |
| controllerHasBeenSet.value = true; |
| controller.value.text = _formattedValue; |
| } |
| } |
| |
| @override |
| String? get restorationId => widget.restorationId; |
| |
| @override |
| void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
| registerForRestoration(controller, 'text_editing_controller'); |
| registerForRestoration(controllerHasBeenSet, 'has_controller_been_set'); |
| } |
| |
| String get _formattedValue { |
| final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| return !widget.isHour |
| ? localizations.formatMinute(widget.selectedTime) |
| : localizations.formatHour( |
| widget.selectedTime, |
| alwaysUse24HourFormat: alwaysUse24HourFormat, |
| ); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); |
| final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); |
| |
| final InputDecorationTheme inputDecorationTheme = timePickerTheme.inputDecorationTheme ?? defaultTheme.inputDecorationTheme; |
| InputDecoration inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme); |
| // Remove the hint text when focused because the centered cursor |
| // appears odd above the hint text. |
| final String? hintText = focusNode.hasFocus ? null : _formattedValue; |
| |
| // Because the fill color is specified in both the inputDecorationTheme and |
| // the TimePickerTheme, if there's one in the user's input decoration theme, |
| // use that. If not, but there's one in the user's |
| // timePickerTheme.hourMinuteColor, use that, and otherwise use the default. |
| // We ignore the value in the fillColor of the input decoration in the |
| // default theme here, but it's the same as the hourMinuteColor. |
| final Color startingFillColor = |
| timePickerTheme.inputDecorationTheme?.fillColor ?? |
| timePickerTheme.hourMinuteColor ?? |
| defaultTheme.hourMinuteColor; |
| final Color fillColor; |
| if (theme.useMaterial3) { |
| fillColor = MaterialStateProperty.resolveAs<Color>( |
| startingFillColor, |
| <MaterialState>{ |
| if (focusNode.hasFocus) MaterialState.focused, |
| if (focusNode.hasFocus) MaterialState.selected, |
| }, |
| ); |
| } else { |
| fillColor = focusNode.hasFocus ? Colors.transparent : startingFillColor; |
| } |
| |
| inputDecoration = inputDecoration.copyWith( |
| hintText: hintText, |
| fillColor: fillColor, |
| ); |
| |
| final Set<MaterialState> states = <MaterialState>{ |
| if (focusNode.hasFocus) MaterialState.focused, |
| if (focusNode.hasFocus) MaterialState.selected, |
| }; |
| final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>( |
| timePickerTheme.hourMinuteTextColor ?? defaultTheme.hourMinuteTextColor, |
| states, |
| ); |
| final TextStyle effectiveStyle = MaterialStateProperty.resolveAs<TextStyle>(widget.style, states) |
| .copyWith(color: effectiveTextColor); |
| |
| return SizedBox.fromSize( |
| size: alwaysUse24HourFormat ? defaultTheme.hourMinuteInputSize24Hour : defaultTheme.hourMinuteInputSize, |
| child: MediaQuery.withNoTextScaling( |
| child: UnmanagedRestorationScope( |
| bucket: bucket, |
| child: Semantics( |
| label: widget.semanticHintText, |
| child: TextFormField( |
| restorationId: 'hour_minute_text_form_field', |
| autofocus: widget.autofocus ?? false, |
| expands: true, |
| maxLines: null, |
| inputFormatters: <TextInputFormatter>[ |
| LengthLimitingTextInputFormatter(2), |
| ], |
| focusNode: focusNode, |
| textAlign: TextAlign.center, |
| textInputAction: widget.inputAction, |
| keyboardType: TextInputType.number, |
| style: effectiveStyle, |
| controller: controller.value, |
| decoration: inputDecoration, |
| validator: widget.validator, |
| onEditingComplete: () => widget.onSavedSubmitted(controller.value.text), |
| onSaved: widget.onSavedSubmitted, |
| onFieldSubmitted: widget.onSavedSubmitted, |
| onChanged: widget.onChanged, |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// Signature for when the time picker entry mode is changed. |
| typedef EntryModeChangeCallback = void Function(TimePickerEntryMode); |
| |
| /// A Material Design time picker designed to appear inside a popup dialog. |
| /// |
| /// Pass this widget to [showDialog]. The value returned by [showDialog] is the |
| /// selected [TimeOfDay] if the user taps the "OK" button, or null if the user |
| /// taps the "CANCEL" button. The selected time is reported by calling |
| /// [Navigator.pop]. |
| /// |
| /// Use [showTimePicker] to show a dialog already containing a [TimePickerDialog]. |
| class TimePickerDialog extends StatefulWidget { |
| /// Creates a Material Design time picker. |
| /// |
| /// [initialTime] must not be null. |
| const TimePickerDialog({ |
| super.key, |
| required this.initialTime, |
| this.cancelText, |
| this.confirmText, |
| this.helpText, |
| this.errorInvalidText, |
| this.hourLabelText, |
| this.minuteLabelText, |
| this.restorationId, |
| this.initialEntryMode = TimePickerEntryMode.dial, |
| this.orientation, |
| this.onEntryModeChanged, |
| }); |
| |
| /// The time initially selected when the dialog is shown. |
| final TimeOfDay initialTime; |
| |
| /// Optionally provide your own text for the cancel button. |
| /// |
| /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. |
| final String? cancelText; |
| |
| /// Optionally provide your own text for the confirm button. |
| /// |
| /// If null, the button uses [MaterialLocalizations.okButtonLabel]. |
| final String? confirmText; |
| |
| /// Optionally provide your own help text to the header of the time picker. |
| final String? helpText; |
| |
| /// Optionally provide your own validation error text. |
| final String? errorInvalidText; |
| |
| /// Optionally provide your own hour label text. |
| final String? hourLabelText; |
| |
| /// Optionally provide your own minute label text. |
| final String? minuteLabelText; |
| |
| /// Restoration ID to save and restore the state of the [TimePickerDialog]. |
| /// |
| /// If it is non-null, the time picker will persist and restore the |
| /// dialog's state. |
| /// |
| /// The state of this widget is persisted in a [RestorationBucket] claimed |
| /// from the surrounding [RestorationScope] using the provided restoration ID. |
| /// |
| /// See also: |
| /// |
| /// * [RestorationManager], which explains how state restoration works in |
| /// Flutter. |
| final String? restorationId; |
| |
| /// The entry mode for the picker. Whether it's text input or a dial. |
| final TimePickerEntryMode initialEntryMode; |
| |
| /// The optional [orientation] parameter sets the [Orientation] to use when |
| /// displaying the dialog. |
| /// |
| /// By default, the orientation is derived from the [MediaQueryData.size] of |
| /// the ambient [MediaQuery]. If the aspect of the size is tall, then |
| /// [Orientation.portrait] is used, if the size is wide, then |
| /// [Orientation.landscape] is used. |
| /// |
| /// Use this parameter to override the default and force the dialog to appear |
| /// in either portrait or landscape mode regardless of the aspect of the |
| /// [MediaQueryData.size]. |
| final Orientation? orientation; |
| |
| /// Callback called when the selected entry mode is changed. |
| final EntryModeChangeCallback? onEntryModeChanged; |
| |
| @override |
| State<TimePickerDialog> createState() => _TimePickerDialogState(); |
| } |
| |
| class _TimePickerDialogState extends State<TimePickerDialog> with RestorationMixin { |
| late final RestorableEnum<TimePickerEntryMode> _entryMode = RestorableEnum<TimePickerEntryMode>(widget.initialEntryMode, values: TimePickerEntryMode.values); |
| late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); |
| final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); |
| final RestorableEnum<AutovalidateMode> _autovalidateMode = RestorableEnum<AutovalidateMode>(AutovalidateMode.disabled, values: AutovalidateMode.values); |
| late final RestorableEnumN<Orientation> _orientation = RestorableEnumN<Orientation>(widget.orientation, values: Orientation.values); |
| |
| // Base sizes |
| static const Size _kTimePickerPortraitSize = Size(310, 468); |
| static const Size _kTimePickerLandscapeSize = Size(524, 342); |
| static const Size _kTimePickerLandscapeSizeM2 = Size(508, 300); |
| static const Size _kTimePickerInputSize = Size(312, 216); |
| |
| // Absolute minimum dialog sizes, which is the point at which it begins |
| // scrolling to fit everything in. |
| static const Size _kTimePickerMinPortraitSize = Size(238, 326); |
| static const Size _kTimePickerMinLandscapeSize = Size(416, 248); |
| static const Size _kTimePickerMinInputSize = Size(312, 196); |
| |
| @override |
| String? get restorationId => widget.restorationId; |
| |
| @override |
| void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
| registerForRestoration(_selectedTime, 'selected_time'); |
| registerForRestoration(_entryMode, 'entry_mode'); |
| registerForRestoration(_autovalidateMode, 'autovalidate_mode'); |
| registerForRestoration(_orientation, 'orientation'); |
| } |
| |
| void _handleTimeChanged(TimeOfDay value) { |
| if (value != _selectedTime.value) { |
| setState(() { |
| _selectedTime.value = value; |
| }); |
| } |
| } |
| |
| void _handleEntryModeChanged(TimePickerEntryMode value) { |
| if (value != _entryMode.value) { |
| setState(() { |
| switch (_entryMode.value) { |
| case TimePickerEntryMode.dial: |
| _autovalidateMode.value = AutovalidateMode.disabled; |
| case TimePickerEntryMode.input: |
| _formKey.currentState!.save(); |
| case TimePickerEntryMode.dialOnly: |
| break; |
| case TimePickerEntryMode.inputOnly: |
| break; |
| } |
| _entryMode.value = value; |
| widget.onEntryModeChanged?.call(value); |
| }); |
| } |
| } |
| |
| void _toggleEntryMode() { |
| switch (_entryMode.value) { |
| case TimePickerEntryMode.dial: |
| _handleEntryModeChanged(TimePickerEntryMode.input); |
| case TimePickerEntryMode.input: |
| _handleEntryModeChanged(TimePickerEntryMode.dial); |
| case TimePickerEntryMode.dialOnly: |
| case TimePickerEntryMode.inputOnly: |
| FlutterError('Can not change entry mode from $_entryMode'); |
| } |
| } |
| |
| void _handleCancel() { |
| Navigator.pop(context); |
| } |
| |
| void _handleOk() { |
| if (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) { |
| final FormState form = _formKey.currentState!; |
| if (!form.validate()) { |
| setState(() { |
| _autovalidateMode.value = AutovalidateMode.always; |
| }); |
| return; |
| } |
| form.save(); |
| } |
| Navigator.pop(context, _selectedTime.value); |
| } |
| |
| Size _minDialogSize(BuildContext context, {required bool useMaterial3}) { |
| final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); |
| |
| switch (_entryMode.value) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| switch (orientation) { |
| case Orientation.portrait: |
| return _kTimePickerMinPortraitSize; |
| case Orientation.landscape: |
| return _kTimePickerMinLandscapeSize; |
| } |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); |
| final double timePickerWidth; |
| switch (timeOfDayFormat) { |
| case TimeOfDayFormat.HH_colon_mm: |
| case TimeOfDayFormat.HH_dot_mm: |
| case TimeOfDayFormat.frenchCanadian: |
| case TimeOfDayFormat.H_colon_mm: |
| final _TimePickerDefaults defaultTheme = useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| timePickerWidth = _kTimePickerMinInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; |
| case TimeOfDayFormat.a_space_h_colon_mm: |
| case TimeOfDayFormat.h_colon_mm_space_a: |
| timePickerWidth = _kTimePickerMinInputSize.width - (useMaterial3 ? 32 : 0); |
| } |
| return Size(timePickerWidth, _kTimePickerMinInputSize.height); |
| } |
| } |
| |
| Size _dialogSize(BuildContext context, {required bool useMaterial3}) { |
| final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); |
| // Constrain the textScaleFactor to prevent layout issues. Since only some |
| // parts of the time picker scale up with textScaleFactor, we cap the factor |
| // to 1.1 as that provides enough space to reasonably fit all the content. |
| final double textScaleFactor = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.1).textScaleFactor; |
| |
| final Size timePickerSize; |
| switch (_entryMode.value) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| switch (orientation) { |
| case Orientation.portrait: |
| timePickerSize = _kTimePickerPortraitSize; |
| case Orientation.landscape: |
| timePickerSize = Size( |
| _kTimePickerLandscapeSize.width * textScaleFactor, |
| useMaterial3 ? _kTimePickerLandscapeSize.height : _kTimePickerLandscapeSizeM2.height |
| ); |
| } |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); |
| final double timePickerWidth; |
| switch (timeOfDayFormat) { |
| case TimeOfDayFormat.HH_colon_mm: |
| case TimeOfDayFormat.HH_dot_mm: |
| case TimeOfDayFormat.frenchCanadian: |
| case TimeOfDayFormat.H_colon_mm: |
| final _TimePickerDefaults defaultTheme = useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| timePickerWidth = _kTimePickerInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; |
| case TimeOfDayFormat.a_space_h_colon_mm: |
| case TimeOfDayFormat.h_colon_mm_space_a: |
| timePickerWidth = _kTimePickerInputSize.width - (useMaterial3 ? 32 : 0); |
| } |
| timePickerSize = Size(timePickerWidth, _kTimePickerInputSize.height); |
| } |
| return Size(timePickerSize.width, timePickerSize.height * textScaleFactor); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasMediaQuery(context)); |
| final ThemeData theme = Theme.of(context); |
| final TimePickerThemeData pickerTheme = TimePickerTheme.of(context); |
| final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| final ShapeBorder shape = pickerTheme.shape ?? defaultTheme.shape; |
| final Color entryModeIconColor = pickerTheme.entryModeIconColor ?? defaultTheme.entryModeIconColor; |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| |
| final Widget actions = Padding( |
| padding: EdgeInsetsDirectional.only(start: theme.useMaterial3 ? 0 : 4), |
| child: Row( |
| children: <Widget>[ |
| if (_entryMode.value == TimePickerEntryMode.dial || _entryMode.value == TimePickerEntryMode.input) |
| IconButton( |
| // In material3 mode, we want to use the color as part of the |
| // button style which applies its own opacity. In material2 mode, |
| // we want to use the color as the color, which already includes |
| // the opacity. |
| color: theme.useMaterial3 ? null : entryModeIconColor, |
| style: theme.useMaterial3 ? IconButton.styleFrom(foregroundColor: entryModeIconColor) : null, |
| onPressed: _toggleEntryMode, |
| icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard_outlined : Icons.access_time), |
| tooltip: _entryMode.value == TimePickerEntryMode.dial |
| ? MaterialLocalizations.of(context).inputTimeModeButtonLabel |
| : MaterialLocalizations.of(context).dialModeButtonLabel, |
| ), |
| Expanded( |
| child: Container( |
| alignment: AlignmentDirectional.centerEnd, |
| constraints: const BoxConstraints(minHeight: 36), |
| child: OverflowBar( |
| spacing: 8, |
| overflowAlignment: OverflowBarAlignment.end, |
| children: <Widget>[ |
| TextButton( |
| onPressed: _handleCancel, |
| child: Text(widget.cancelText ?? |
| (theme.useMaterial3 |
| ? localizations.cancelButtonLabel |
| : localizations.cancelButtonLabel.toUpperCase())), |
| ), |
| TextButton( |
| onPressed: _handleOk, |
| child: Text(widget.confirmText ?? localizations.okButtonLabel), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ], |
| ), |
| ); |
| |
| final Offset tapTargetSizeOffset; |
| switch (theme.materialTapTargetSize) { |
| case MaterialTapTargetSize.padded: |
| tapTargetSizeOffset = Offset.zero; |
| case MaterialTapTargetSize.shrinkWrap: |
| // _dialogSize returns "padded" sizes. |
| tapTargetSizeOffset = const Offset(0, -12); |
| } |
| final Size dialogSize = _dialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; |
| final Size minDialogSize = _minDialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; |
| return Dialog( |
| shape: shape, |
| elevation: pickerTheme.elevation ?? defaultTheme.elevation, |
| backgroundColor: pickerTheme.backgroundColor ?? defaultTheme.backgroundColor, |
| insetPadding: EdgeInsets.symmetric( |
| horizontal: 16, |
| vertical: (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) ? 0 : 24, |
| ), |
| child: Padding( |
| padding: pickerTheme.padding ?? defaultTheme.padding, |
| child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { |
| final Size constrainedSize = constraints.constrain(dialogSize); |
| final Size allowedSize = Size( |
| constrainedSize.width < minDialogSize.width ? minDialogSize.width : constrainedSize.width, |
| constrainedSize.height < minDialogSize.height ? minDialogSize.height : constrainedSize.height, |
| ); |
| return SingleChildScrollView( |
| restorationId: 'time_picker_scroll_view_horizontal', |
| scrollDirection: Axis.horizontal, |
| child: SingleChildScrollView( |
| restorationId: 'time_picker_scroll_view_vertical', |
| child: AnimatedContainer( |
| width: allowedSize.width, |
| height: allowedSize.height, |
| duration: _kDialogSizeAnimationDuration, |
| curve: Curves.easeIn, |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: <Widget>[ |
| Expanded( |
| child: Form( |
| key: _formKey, |
| autovalidateMode: _autovalidateMode.value, |
| child: _TimePicker( |
| time: widget.initialTime, |
| onTimeChanged: _handleTimeChanged, |
| helpText: widget.helpText, |
| cancelText: widget.cancelText, |
| confirmText: widget.confirmText, |
| errorInvalidText: widget.errorInvalidText, |
| hourLabelText: widget.hourLabelText, |
| minuteLabelText: widget.minuteLabelText, |
| restorationId: 'time_picker', |
| entryMode: _entryMode.value, |
| orientation: widget.orientation, |
| onEntryModeChanged: _handleEntryModeChanged, |
| ), |
| ), |
| ), |
| actions, |
| ], |
| ), |
| ), |
| ), |
| ); |
| }), |
| ), |
| ); |
| } |
| } |
| |
| // The _TimePicker widget is constructed so that in the future we could expose |
| // this as a public API for embedding time pickers into other non-dialog |
| // widgets, once we're sure we want to support that. |
| |
| /// A Time Picker widget that can be embedded into another widget. |
| class _TimePicker extends StatefulWidget { |
| /// Creates a const Material Design time picker. |
| const _TimePicker({ |
| required this.time, |
| required this.onTimeChanged, |
| this.helpText, |
| this.cancelText, |
| this.confirmText, |
| this.errorInvalidText, |
| this.hourLabelText, |
| this.minuteLabelText, |
| this.restorationId, |
| this.entryMode = TimePickerEntryMode.dial, |
| this.orientation, |
| this.onEntryModeChanged, |
| }); |
| |
| /// Optionally provide your own text for the help text at the top of the |
| /// control. |
| /// |
| /// If null, the widget uses [MaterialLocalizations.timePickerDialHelpText] |
| /// when the [entryMode] is [TimePickerEntryMode.dial], and |
| /// [MaterialLocalizations.timePickerInputHelpText] when the [entryMode] is |
| /// [TimePickerEntryMode.input]. |
| final String? helpText; |
| |
| /// Optionally provide your own text for the cancel button. |
| /// |
| /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. |
| final String? cancelText; |
| |
| /// Optionally provide your own text for the confirm button. |
| /// |
| /// If null, the button uses [MaterialLocalizations.okButtonLabel]. |
| final String? confirmText; |
| |
| /// Optionally provide your own validation error text. |
| final String? errorInvalidText; |
| |
| /// Optionally provide your own hour label text. |
| final String? hourLabelText; |
| |
| /// Optionally provide your own minute label text. |
| final String? minuteLabelText; |
| |
| /// Restoration ID to save and restore the state of the [TimePickerDialog]. |
| /// |
| /// If it is non-null, the time picker will persist and restore the |
| /// dialog's state. |
| /// |
| /// The state of this widget is persisted in a [RestorationBucket] claimed |
| /// from the surrounding [RestorationScope] using the provided restoration ID. |
| /// |
| /// See also: |
| /// |
| /// * [RestorationManager], which explains how state restoration works in |
| /// Flutter. |
| final String? restorationId; |
| |
| /// The initial entry mode for the picker. Whether it's text input or a dial. |
| final TimePickerEntryMode entryMode; |
| |
| /// The currently selected time of day. |
| final TimeOfDay time; |
| |
| final ValueChanged<TimeOfDay>? onTimeChanged; |
| |
| /// The optional [orientation] parameter sets the [Orientation] to use when |
| /// displaying the dialog. |
| /// |
| /// By default, the orientation is derived from the [MediaQueryData.size] of |
| /// the ambient [MediaQuery]. If the aspect of the size is tall, then |
| /// [Orientation.portrait] is used, if the size is wide, then |
| /// [Orientation.landscape] is used. |
| /// |
| /// Use this parameter to override the default and force the dialog to appear |
| /// in either portrait or landscape mode regardless of the aspect of the |
| /// [MediaQueryData.size]. |
| final Orientation? orientation; |
| |
| /// Callback called when the selected entry mode is changed. |
| final EntryModeChangeCallback? onEntryModeChanged; |
| |
| @override |
| State<_TimePicker> createState() => _TimePickerState(); |
| } |
| |
| class _TimePickerState extends State<_TimePicker> with RestorationMixin { |
| Timer? _vibrateTimer; |
| late MaterialLocalizations localizations; |
| final RestorableEnum<_HourMinuteMode> _hourMinuteMode = |
| RestorableEnum<_HourMinuteMode>(_HourMinuteMode.hour, values: _HourMinuteMode.values); |
| final RestorableEnumN<_HourMinuteMode> _lastModeAnnounced = |
| RestorableEnumN<_HourMinuteMode>(null, values: _HourMinuteMode.values); |
| 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); |
| RestorableTimeOfDay get selectedTime => _selectedTime; |
| late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.time); |
| |
| @override |
| void dispose() { |
| _vibrateTimer?.cancel(); |
| _vibrateTimer = null; |
| super.dispose(); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| localizations = MaterialLocalizations.of(context); |
| _announceInitialTimeOnce(); |
| _announceModeOnce(); |
| } |
| |
| @override |
| void didUpdateWidget (_TimePicker oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (oldWidget.orientation != widget.orientation) { |
| _orientation.value = widget.orientation; |
| } |
| if (oldWidget.time != widget.time) { |
| _selectedTime.value = widget.time; |
| } |
| } |
| |
| void _setEntryMode(TimePickerEntryMode mode){ |
| widget.onEntryModeChanged?.call(mode); |
| } |
| |
| @override |
| String? get restorationId => widget.restorationId; |
| |
| @override |
| void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
| registerForRestoration(_hourMinuteMode, 'hour_minute_mode'); |
| 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'); |
| } |
| |
| void _vibrate() { |
| switch (Theme.of(context).platform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| _vibrateTimer?.cancel(); |
| _vibrateTimer = Timer(_kVibrateCommitDelay, () { |
| HapticFeedback.vibrate(); |
| _vibrateTimer = null; |
| }); |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| break; |
| } |
| } |
| |
| void _handleHourMinuteModeChanged(_HourMinuteMode mode) { |
| _vibrate(); |
| setState(() { |
| _hourMinuteMode.value = mode; |
| _announceModeOnce(); |
| }); |
| } |
| |
| void _handleEntryModeToggle() { |
| setState(() { |
| TimePickerEntryMode newMode = widget.entryMode; |
| switch (widget.entryMode) { |
| case TimePickerEntryMode.dial: |
| newMode = TimePickerEntryMode.input; |
| case TimePickerEntryMode.input: |
| _autofocusHour.value = false; |
| _autofocusMinute.value = false; |
| newMode = TimePickerEntryMode.dial; |
| case TimePickerEntryMode.dialOnly: |
| case TimePickerEntryMode.inputOnly: |
| FlutterError('Can not change entry mode from ${widget.entryMode}'); |
| } |
| _setEntryMode(newMode); |
| }); |
| } |
| |
| 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(() { |
| _selectedTime.value = value; |
| widget.onTimeChanged?.call(value); |
| }); |
| } |
| |
| void _handleHourDoubleTapped() { |
| _autofocusHour.value = true; |
| _handleEntryModeToggle(); |
| } |
| |
| void _handleMinuteDoubleTapped() { |
| _autofocusMinute.value = true; |
| _handleEntryModeToggle(); |
| } |
| |
| void _handleHourSelected() { |
| setState(() { |
| _hourMinuteMode.value = _HourMinuteMode.minute; |
| }); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasMediaQuery(context)); |
| final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); |
| final ThemeData theme = Theme.of(context); |
| final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); |
| final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); |
| final HourFormat timeOfDayHour = hourFormat(of: timeOfDayFormat); |
| final _HourDialType hourMode; |
| switch (timeOfDayHour) { |
| case HourFormat.HH: |
| case HourFormat.H: |
| hourMode = theme.useMaterial3 ? _HourDialType.twentyFourHourDoubleRing : _HourDialType.twentyFourHour; |
| case HourFormat.h: |
| hourMode = _HourDialType.twelveHour; |
| } |
| |
| final String helpText; |
| final Widget picker; |
| switch (widget.entryMode) { |
| case TimePickerEntryMode.dial: |
| case TimePickerEntryMode.dialOnly: |
| helpText = widget.helpText ?? (theme.useMaterial3 |
| ? localizations.timePickerDialHelpText |
| : localizations.timePickerDialHelpText.toUpperCase()); |
| |
| final EdgeInsetsGeometry dialPadding; |
| switch (orientation) { |
| case Orientation.portrait: |
| dialPadding = const EdgeInsets.only(left: 12, right: 12, top: 36); |
| case Orientation.landscape: |
| switch (theme.materialTapTargetSize) { |
| case MaterialTapTargetSize.padded: |
| dialPadding = const EdgeInsetsDirectional.only(start: 64); |
| case MaterialTapTargetSize.shrinkWrap: |
| dialPadding = const EdgeInsetsDirectional.only(start: 64); |
| } |
| } |
| 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, |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| switch (orientation) { |
| case Orientation.portrait: |
| picker = Column( |
| mainAxisSize: MainAxisSize.min, |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| Padding( |
| padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), |
| child: _TimePickerHeader(helpText: helpText), |
| ), |
| Expanded( |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| // Dial grows and shrinks with the available space. |
| Expanded( |
| child: Padding( |
| padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), |
| child: dial, |
| ), |
| ), |
| ], |
| ), |
| ), |
| ], |
| ); |
| case Orientation.landscape: |
| picker = Column( |
| children: <Widget>[ |
| Expanded( |
| child: Padding( |
| padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), |
| child: Row( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: <Widget>[ |
| _TimePickerHeader(helpText: helpText), |
| Expanded(child: dial), |
| ], |
| ), |
| ), |
| ), |
| ], |
| ); |
| } |
| case TimePickerEntryMode.input: |
| case TimePickerEntryMode.inputOnly: |
| final String helpText = widget.helpText ?? (theme.useMaterial3 |
| ? localizations.timePickerInputHelpText |
| : localizations.timePickerInputHelpText.toUpperCase()); |
| |
| picker = Column( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| _TimePickerInput( |
| initialSelectedTime: _selectedTime.value, |
| errorInvalidText: widget.errorInvalidText, |
| hourLabelText: widget.hourLabelText, |
| minuteLabelText: widget.minuteLabelText, |
| helpText: helpText, |
| autofocusHour: _autofocusHour.value, |
| autofocusMinute: _autofocusMinute.value, |
| restorationId: 'time_picker_input', |
| ), |
| ], |
| ); |
| } |
| return _TimePickerModel( |
| entryMode: widget.entryMode, |
| selectedTime: _selectedTime.value, |
| hourMinuteMode: _hourMinuteMode.value, |
| orientation: orientation, |
| onHourMinuteModeChanged: _handleHourMinuteModeChanged, |
| onHourDoubleTapped: _handleHourDoubleTapped, |
| onMinuteDoubleTapped: _handleMinuteDoubleTapped, |
| hourDialType: hourMode, |
| onSelectedTimeChanged: _handleTimeChanged, |
| useMaterial3: theme.useMaterial3, |
| use24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), |
| theme: TimePickerTheme.of(context), |
| defaultTheme: defaultTheme, |
| child: picker, |
| ); |
| } |
| } |
| |
| /// Shows a dialog containing a Material Design time picker. |
| /// |
| /// The returned Future resolves to the time selected by the user when the user |
| /// closes the dialog. If the user cancels the dialog, null is returned. |
| /// |
| /// {@tool snippet} Show a dialog with [initialTime] equal to the current time. |
| /// |
| /// ```dart |
| /// Future<TimeOfDay?> selectedTime = showTimePicker( |
| /// initialTime: TimeOfDay.now(), |
| /// context: context, |
| /// ); |
| /// ``` |
| /// {@end-tool} |
| /// |
| /// The [context], [barrierDismissible], [barrierColor], [barrierLabel], |
| /// [useRootNavigator] and [routeSettings] arguments are passed to [showDialog], |
| /// the documentation for which discusses how it is used. |
| /// |
| /// The [builder] parameter can be used to wrap the dialog widget to add |
| /// inherited widgets like [Localizations.override], [Directionality], or |
| /// [MediaQuery]. |
| /// |
| /// The `initialEntryMode` parameter can be used to determine the initial time |
| /// entry selection of the picker (either a clock dial or text input). |
| /// |
| /// Optional strings for the [helpText], [cancelText], [errorInvalidText], |
| |