diff --git a/packages/flutter/lib/src/material/time.dart b/packages/flutter/lib/src/material/time.dart index cf71d7b1da5..b4ae09148cb 100644 --- a/packages/flutter/lib/src/material/time.dart +++ b/packages/flutter/lib/src/material/time.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; import 'debug.dart'; import 'material_localizations.dart'; @@ -134,6 +135,40 @@ class TimeOfDay { } } +/// A [RestorableValue] that knows how to save and restore [TimeOfDay]. +/// +/// {@macro flutter.widgets.RestorableNum}. +class RestorableTimeOfDay extends RestorableValue { + /// Creates a [RestorableTimeOfDay]. + /// + /// {@macro flutter.widgets.RestorableNum.constructor} + RestorableTimeOfDay(TimeOfDay defaultValue) : _defaultValue = defaultValue; + + final TimeOfDay _defaultValue; + + @override + TimeOfDay createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(TimeOfDay? oldValue) { + assert(debugIsSerializableForRestoration(value.hour)); + assert(debugIsSerializableForRestoration(value.minute)); + notifyListeners(); + } + + @override + TimeOfDay fromPrimitives(Object? data) { + final List timeData = data! as List; + return TimeOfDay( + minute: timeData[0]! as int, + hour: timeData[1]! as int, + ); + } + + @override + Object? toPrimitives() => [value.minute, value.hour]; +} + /// Determines how the time picker invoked using [showTimePicker] formats and /// lays out the time controls. /// diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index face52ee924..14a3744508c 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -1293,6 +1293,7 @@ class _TimePickerInput extends StatefulWidget { required this.autofocusHour, required this.autofocusMinute, required this.onChanged, + this.restorationId, }) : assert(initialSelectedTime != null), assert(onChanged != null), super(key: key); @@ -1309,19 +1310,32 @@ class _TimePickerInput extends StatefulWidget { final ValueChanged onChanged; + /// 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> { - late TimeOfDay _selectedTime; - bool hourHasError = false; - bool minuteHasError = false; +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 - void initState() { - super.initState(); - _selectedTime = widget.initialSelectedTime; + 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) { @@ -1340,8 +1354,8 @@ class _TimePickerInputState extends State<_TimePickerInput> { } } else { if (newHour > 0 && newHour < 13) { - if ((_selectedTime.period == DayPeriod.pm && newHour != 12) - || (_selectedTime.period == DayPeriod.am && newHour == 12)) { + if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) + || (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; } return newHour; @@ -1369,8 +1383,8 @@ class _TimePickerInputState extends State<_TimePickerInput> { void _handleHourSavedSubmitted(String? value) { final int? newHour = _parseHour(value); if (newHour != null) { - _selectedTime = TimeOfDay(hour: newHour, minute: _selectedTime.minute); - widget.onChanged(_selectedTime); + _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute); + widget.onChanged(_selectedTime.value); } } @@ -1385,20 +1399,20 @@ class _TimePickerInputState extends State<_TimePickerInput> { void _handleMinuteSavedSubmitted(String? value) { final int? newMinute = _parseMinute(value); if (newMinute != null) { - _selectedTime = TimeOfDay(hour: _selectedTime.hour, minute: int.parse(value!)); - widget.onChanged(_selectedTime); + _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!)); + widget.onChanged(_selectedTime.value); } } void _handleDayPeriodChanged(TimeOfDay value) { - _selectedTime = value; - widget.onChanged(_selectedTime); + _selectedTime.value = value; + widget.onChanged(_selectedTime.value); } String? _validateHour(String? value) { final int? newHour = _parseHour(value); setState(() { - hourHasError = newHour == null; + 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. @@ -1409,7 +1423,7 @@ class _TimePickerInputState extends State<_TimePickerInput> { String? _validateMinute(String? value) { final int? newMinute = _parseMinute(value); setState(() { - minuteHasError = newMinute == null; + 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. @@ -1441,7 +1455,7 @@ class _TimePickerInputState extends State<_TimePickerInput> { children: [ if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ _DayPeriodControl( - selectedTime: _selectedTime, + selectedTime: _selectedTime.value, orientation: Orientation.portrait, onChanged: _handleDayPeriodChanged, ), @@ -1459,7 +1473,8 @@ class _TimePickerInputState extends State<_TimePickerInput> { children: [ const SizedBox(height: 8.0), _HourTextField( - selectedTime: _selectedTime, + restorationId: 'hour_text_field', + selectedTime: _selectedTime.value, style: hourMinuteStyle, autofocus: widget.autofocusHour, validator: _validateHour, @@ -1467,7 +1482,7 @@ class _TimePickerInputState extends State<_TimePickerInput> { onChanged: _handleHourChanged, ), const SizedBox(height: 8.0), - if (!hourHasError && !minuteHasError) + if (!hourHasError.value && !minuteHasError.value) ExcludeSemantics( child: Text( MaterialLocalizations.of(context).timePickerHourLabel, @@ -1490,14 +1505,15 @@ class _TimePickerInputState extends State<_TimePickerInput> { children: [ const SizedBox(height: 8.0), _MinuteTextField( - selectedTime: _selectedTime, + restorationId: 'minute_text_field', + selectedTime: _selectedTime.value, style: hourMinuteStyle, autofocus: widget.autofocusMinute, validator: _validateMinute, onSavedSubmitted: _handleMinuteSavedSubmitted, ), const SizedBox(height: 8.0), - if (!hourHasError && !minuteHasError) + if (!hourHasError.value && !minuteHasError.value) ExcludeSemantics( child: Text( MaterialLocalizations.of(context).timePickerMinuteLabel, @@ -1515,14 +1531,14 @@ class _TimePickerInputState extends State<_TimePickerInput> { if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ const SizedBox(width: 12.0), _DayPeriodControl( - selectedTime: _selectedTime, + selectedTime: _selectedTime.value, orientation: Orientation.portrait, onChanged: _handleDayPeriodChanged, ), ], ], ), - if (hourHasError || minuteHasError) + if (hourHasError.value || minuteHasError.value) Text( MaterialLocalizations.of(context).invalidTimeLabel, style: theme.textTheme.bodyText2!.copyWith(color: theme.colorScheme.error), @@ -1544,6 +1560,7 @@ class _HourTextField extends StatelessWidget { required this.validator, required this.onSavedSubmitted, required this.onChanged, + this.restorationId, }) : super(key: key); final TimeOfDay selectedTime; @@ -1552,10 +1569,12 @@ class _HourTextField extends StatelessWidget { final FormFieldValidator validator; final ValueChanged onSavedSubmitted; final ValueChanged onChanged; + final String? restorationId; @override Widget build(BuildContext context) { return _HourMinuteTextField( + restorationId: restorationId, selectedTime: selectedTime, isHour: true, autofocus: autofocus, @@ -1576,6 +1595,7 @@ class _MinuteTextField extends StatelessWidget { required this.autofocus, required this.validator, required this.onSavedSubmitted, + this.restorationId, }) : super(key: key); final TimeOfDay selectedTime; @@ -1583,10 +1603,12 @@ class _MinuteTextField extends StatelessWidget { final bool? autofocus; final FormFieldValidator validator; final ValueChanged onSavedSubmitted; + final String? restorationId; @override Widget build(BuildContext context) { return _HourMinuteTextField( + restorationId: restorationId, selectedTime: selectedTime, isHour: false, autofocus: autofocus, @@ -1608,6 +1630,7 @@ class _HourMinuteTextField extends StatefulWidget { required this.semanticHintText, required this.validator, required this.onSavedSubmitted, + this.restorationId, this.onChanged, }) : super(key: key); @@ -1619,13 +1642,15 @@ class _HourMinuteTextField extends StatefulWidget { final FormFieldValidator validator; final ValueChanged onSavedSubmitted; final ValueChanged? onChanged; + final String? restorationId; @override _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); } -class _HourMinuteTextFieldState extends State<_HourMinuteTextField> { - TextEditingController? controller; +class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + final RestorableBool controllerHasBeenSet = RestorableBool(false); late FocusNode focusNode; @override @@ -1639,7 +1664,21 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> { @override void didChangeDependencies() { super.didChangeDependencies(); - controller ??= TextEditingController(text: _formattedValue); + // 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 { @@ -1701,24 +1740,28 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> { height: _kTimePickerHeaderControlHeight, child: MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), - child: TextFormField( - autofocus: widget.autofocus ?? false, - expands: true, - maxLines: null, - inputFormatters: [ - LengthLimitingTextInputFormatter(2), - ], - focusNode: focusNode, - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - style: widget.style.copyWith(color: timePickerTheme.hourMinuteTextColor ?? colorScheme.onSurface), - controller: controller, - decoration: inputDecoration, - validator: widget.validator, - onEditingComplete: () => widget.onSavedSubmitted(controller!.text), - onSaved: widget.onSavedSubmitted, - onFieldSubmitted: widget.onSavedSubmitted, - onChanged: widget.onChanged, + child: UnmanagedRestorationScope( + bucket: bucket, + child: TextFormField( + restorationId: 'hour_minute_text_form_field', + autofocus: widget.autofocus ?? false, + expands: true, + maxLines: null, + inputFormatters: [ + LengthLimitingTextInputFormatter(2), + ], + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: widget.style.copyWith(color: timePickerTheme.hourMinuteTextColor ?? colorScheme.onSurface), + controller: controller.value, + decoration: inputDecoration, + validator: widget.validator, + onEditingComplete: () => widget.onSavedSubmitted(controller.value.text), + onSaved: widget.onSavedSubmitted, + onFieldSubmitted: widget.onSavedSubmitted, + onChanged: widget.onChanged, + ), ), ), ); @@ -1731,16 +1774,17 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> { /// 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]. -class _TimePickerDialog extends StatefulWidget { +class TimePickerDialog extends StatefulWidget { /// Creates a material time picker. /// /// [initialTime] must not be null. - const _TimePickerDialog({ + const TimePickerDialog({ Key? key, required this.initialTime, - required this.cancelText, - required this.confirmText, - required this.helpText, + this.cancelText, + this.confirmText, + this.helpText, + this.restorationId, this.initialEntryMode = TimePickerEntryMode.dial, }) : assert(initialTime != null), super(key: key); @@ -1764,21 +1808,115 @@ class _TimePickerDialog extends StatefulWidget { /// Optionally provide your own help text to the header of the time picker. final String? helpText; + /// 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; + @override _TimePickerDialogState createState() => _TimePickerDialogState(); } -class _TimePickerDialogState extends State<_TimePickerDialog> { - final GlobalKey _formKey = GlobalKey(); +// A restorable [TimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableTimePickerEntryMode extends RestorableValue { + _RestorableTimePickerEntryMode( + TimePickerEntryMode defaultValue, + ) : _defaultValue = defaultValue; + + final TimePickerEntryMode _defaultValue; @override - void initState() { - super.initState(); - _selectedTime = widget.initialTime; - _entryMode = widget.initialEntryMode; - _autoValidate = false; + TimePickerEntryMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(TimePickerEntryMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); } + @override + TimePickerEntryMode fromPrimitives(Object? data) => TimePickerEntryMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [_RestorableTimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableTimePickerMode extends RestorableValue<_TimePickerMode> { + _RestorableTimePickerMode( + _TimePickerMode defaultValue, + ) : _defaultValue = defaultValue; + + final _TimePickerMode _defaultValue; + + @override + _TimePickerMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_TimePickerMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); + } + + @override + _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [_RestorableTimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +// +// This value can be null. +class _RestorableTimePickerModeN extends RestorableValue<_TimePickerMode?> { + _RestorableTimePickerModeN( + _TimePickerMode? defaultValue, + ) : _defaultValue = defaultValue; + + final _TimePickerMode? _defaultValue; + + @override + _TimePickerMode? createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_TimePickerMode? oldValue) { + assert(debugIsSerializableForRestoration(value?.index)); + notifyListeners(); + } + + @override + _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value?.index; +} + +class _TimePickerDialogState extends State with RestorationMixin { + final GlobalKey _formKey = GlobalKey(); + + late final _RestorableTimePickerEntryMode _entryMode = _RestorableTimePickerEntryMode(widget.initialEntryMode); + final _RestorableTimePickerMode _mode = _RestorableTimePickerMode(_TimePickerMode.hour); + final _RestorableTimePickerModeN _lastModeAnnounced = _RestorableTimePickerModeN(null); + final RestorableBool _autoValidate = RestorableBool(false); + final RestorableBoolN _autofocusHour = RestorableBoolN(null); + final RestorableBoolN _autofocusMinute = RestorableBoolN(null); + final RestorableBool _announcedInitialTime = RestorableBool(false); + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -1787,15 +1925,23 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { _announceModeOnce(); } - late TimePickerEntryMode _entryMode; - _TimePickerMode _mode = _TimePickerMode.hour; - _TimePickerMode? _lastModeAnnounced; - late bool _autoValidate; - bool? _autofocusHour; - bool? _autofocusMinute; + @override + String? get restorationId => widget.restorationId; - TimeOfDay get selectedTime => _selectedTime; - late TimeOfDay _selectedTime; + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_entryMode, 'entry_mode'); + registerForRestoration(_mode, 'mode'); + registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); + registerForRestoration(_autoValidate, 'autovalidate'); + registerForRestoration(_autofocusHour, 'autofocus_hour'); + registerForRestoration(_autofocusMinute, 'autofocus_minute'); + registerForRestoration(_announcedInitialTime, 'announced_initial_time'); + registerForRestoration(_selectedTime, 'selected_time'); + } + + RestorableTimeOfDay get selectedTime => _selectedTime; + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); Timer? _vibrateTimer; late MaterialLocalizations localizations; @@ -1821,35 +1967,35 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { void _handleModeChanged(_TimePickerMode mode) { _vibrate(); setState(() { - _mode = mode; + _mode.value = mode; _announceModeOnce(); }); } void _handleEntryModeToggle() { setState(() { - switch (_entryMode) { + switch (_entryMode.value) { case TimePickerEntryMode.dial: - _autoValidate = false; - _entryMode = TimePickerEntryMode.input; + _autoValidate.value = false; + _entryMode.value = TimePickerEntryMode.input; break; case TimePickerEntryMode.input: _formKey.currentState!.save(); - _autofocusHour = false; - _autofocusMinute = false; - _entryMode = TimePickerEntryMode.dial; + _autofocusHour.value = false; + _autofocusMinute.value = false; + _entryMode.value = TimePickerEntryMode.dial; break; } }); } void _announceModeOnce() { - if (_lastModeAnnounced == _mode) { + if (_lastModeAnnounced.value == _mode.value) { // Already announced it. return; } - switch (_mode) { + switch (_mode.value) { case _TimePickerMode.hour: _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement); break; @@ -1857,13 +2003,11 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement); break; } - _lastModeAnnounced = _mode; + _lastModeAnnounced.value = _mode.value; } - bool _announcedInitialTime = false; - void _announceInitialTimeOnce() { - if (_announcedInitialTime) + if (_announcedInitialTime.value) return; final MediaQueryData media = MediaQuery.of(context); @@ -1872,29 +2016,29 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { context, localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat), ); - _announcedInitialTime = true; + _announcedInitialTime.value = true; } void _handleTimeChanged(TimeOfDay value) { _vibrate(); setState(() { - _selectedTime = value; + _selectedTime.value = value; }); } void _handleHourDoubleTapped() { - _autofocusHour = true; + _autofocusHour.value = true; _handleEntryModeToggle(); } void _handleMinuteDoubleTapped() { - _autofocusMinute = true; + _autofocusMinute.value = true; _handleEntryModeToggle(); } void _handleHourSelected() { setState(() { - _mode = _TimePickerMode.minute; + _mode.value = _TimePickerMode.minute; }); } @@ -1903,15 +2047,15 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { } void _handleOk() { - if (_entryMode == TimePickerEntryMode.input) { + if (_entryMode.value == TimePickerEntryMode.input) { final FormState form = _formKey.currentState!; if (!form.validate()) { - setState(() { _autoValidate = true; }); + setState(() { _autoValidate.value = true; }); return; } form.save(); } - Navigator.pop(context, _selectedTime); + Navigator.pop(context, _selectedTime.value); } Size _dialogSize(BuildContext context) { @@ -1924,7 +2068,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { final double timePickerWidth; final double timePickerHeight; - switch (_entryMode) { + switch (_entryMode.value) { case TimePickerEntryMode.dial: switch (orientation) { case Orientation.portrait: @@ -1967,8 +2111,8 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6, ), onPressed: _handleEntryModeToggle, - icon: Icon(_entryMode == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time), - tooltip: _entryMode == TimePickerEntryMode.dial + icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time), + tooltip: _entryMode.value == TimePickerEntryMode.dial ? MaterialLocalizations.of(context).inputTimeModeButtonLabel : MaterialLocalizations.of(context).dialModeButtonLabel, ), @@ -1997,7 +2141,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ); final Widget picker; - switch (_entryMode) { + switch (_entryMode.value) { case TimePickerEntryMode.dial: final Widget dial = Padding( padding: orientation == Orientation.portrait ? const EdgeInsets.symmetric(horizontal: 36, vertical: 24) : const EdgeInsets.all(24), @@ -2005,9 +2149,9 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { child: AspectRatio( aspectRatio: 1.0, child: _Dial( - mode: _mode, + mode: _mode.value, use24HourDials: use24HourDials, - selectedTime: _selectedTime, + selectedTime: _selectedTime.value, onChanged: _handleTimeChanged, onHourSelected: _handleHourSelected, ), @@ -2016,8 +2160,8 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ); final Widget header = _TimePickerHeader( - selectedTime: _selectedTime, - mode: _mode, + selectedTime: _selectedTime.value, + mode: _mode.value, orientation: orientation, onModeChanged: _handleModeChanged, onChanged: _handleTimeChanged, @@ -2067,17 +2211,19 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { case TimePickerEntryMode.input: picker = Form( key: _formKey, - autovalidate: _autoValidate, + autovalidate: _autoValidate.value, child: SingleChildScrollView( + restorationId: 'time_picker_scroll_view', child: Column( mainAxisSize: MainAxisSize.min, children: [ _TimePickerInput( - initialSelectedTime: _selectedTime, + initialSelectedTime: _selectedTime.value, helpText: widget.helpText, - autofocusHour: _autofocusHour, - autofocusMinute: _autofocusMinute, + autofocusHour: _autofocusHour.value, + autofocusMinute: _autofocusMinute.value, onChanged: _handleTimeChanged, + restorationId: 'time_picker_input', ), actions, ], @@ -2093,7 +2239,7 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { backgroundColor: TimePickerTheme.of(context).backgroundColor ?? theme.colorScheme.surface, insetPadding: EdgeInsets.symmetric( horizontal: 16.0, - vertical: _entryMode == TimePickerEntryMode.input ? 0.0 : 24.0, + vertical: _entryMode.value == TimePickerEntryMode.input ? 0.0 : 24.0, ), child: AnimatedContainer( width: dialogSize.width, @@ -2204,7 +2350,7 @@ Future showTimePicker({ assert(initialEntryMode != null); assert(debugCheckHasMaterialLocalizations(context)); - final Widget dialog = _TimePickerDialog( + final Widget dialog = TimePickerDialog( initialTime: initialTime, initialEntryMode: initialEntryMode, cancelText: cancelText, diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index e2175f0eb08..44983cac488 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart @@ -12,40 +12,88 @@ import 'feedback_tester.dart'; final Finder _hourControl = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl'); final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl'); -final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePickerDialog'); +final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == 'TimePickerDialog'); -class _TimePickerLauncher extends StatelessWidget { +class _TimePickerLauncher extends StatefulWidget { const _TimePickerLauncher({ Key? key, required this.onChanged, - this.locale, this.entryMode = TimePickerEntryMode.dial, + this.restorationId, }) : super(key: key); final ValueChanged onChanged; - final Locale? locale; final TimePickerEntryMode entryMode; + final String? restorationId; + + @override + _TimePickerLauncherState createState() => _TimePickerLauncherState(); +} + +class _TimePickerLauncherState extends State<_TimePickerLauncher> with RestorationMixin { + @override + String? get restorationId => widget.restorationId; + + late final RestorableRouteFuture _restorableTimePickerRouteFuture = RestorableRouteFuture( + onComplete: _selectTime, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _timePickerRoute, + arguments: { + 'entryMode': widget.entryMode.index, + }, + ); + }, + ); + + static Route _timePickerRoute( + BuildContext context, + Object? arguments, + ) { + final Map args = arguments! as Map; + final TimePickerEntryMode entryMode = TimePickerEntryMode.values[args['entryMode'] as int]; + return DialogRoute( + context: context, + builder: (BuildContext context) { + return TimePickerDialog( + restorationId: 'time_picker_dialog', + initialTime: const TimeOfDay(hour: 7, minute: 0), + initialEntryMode: entryMode, + ); + }, + ); + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_restorableTimePickerRouteFuture, 'time_picker_route_future'); + } + + void _selectTime(TimeOfDay? newSelectedTime) { + widget.onChanged(newSelectedTime); + } @override Widget build(BuildContext context) { - return MaterialApp( - locale: locale, - home: Material( - child: Center( - child: Builder( - builder: (BuildContext context) { - return ElevatedButton( - child: const Text('X'), - onPressed: () async { - onChanged(await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - initialEntryMode: entryMode, - )); - }, - ); - } - ), + return Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () async { + if (widget.restorationId == null) { + widget.onChanged(await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + initialEntryMode: widget.entryMode, + )); + } else { + _restorableTimePickerRouteFuture.present(); + } + }, + ); + }, ), ), ); @@ -56,8 +104,17 @@ Future startPicker( WidgetTester tester, ValueChanged onChanged, { TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + String? restorationId, }) async { - await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: const Locale('en', 'US'), entryMode: entryMode)); + await tester.pumpWidget(MaterialApp( + restorationScopeId: 'app', + locale: const Locale('en', 'US'), + home: _TimePickerLauncher( + onChanged: onChanged, + entryMode: entryMode, + restorationId: restorationId, + ), + )); await tester.tap(find.text('X')); await tester.pumpAndSettle(const Duration(seconds: 1)); return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey('time-picker-dial'))) : null; @@ -428,7 +485,7 @@ void _tests() { // Ensure we preserve day period as we roll over. final dynamic pickerState = tester.state(_timePickerDialog); - expect(pickerState.selectedTime, const TimeOfDay(hour: 1, minute: 0)); + expect(pickerState.selectedTime.value, const TimeOfDay(hour: 1, minute: 0)); await actAndExpect( initialValue: '1', @@ -493,7 +550,7 @@ void _tests() { // Ensure we preserve hour period as we roll over. final dynamic pickerState = tester.state(_timePickerDialog); - expect(pickerState.selectedTime, const TimeOfDay(hour: 11, minute: 0)); + expect(pickerState.selectedTime.value, const TimeOfDay(hour: 11, minute: 0)); await actAndExpect( initialValue: '00', @@ -939,6 +996,102 @@ void _testsInput() { expect(hourFieldTop, separatorTop); expect(minuteFieldTop, separatorTop); }); + + testWidgets('Time Picker state restoration test - dial mode', (WidgetTester tester) async { + TimeOfDay? result; + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) { result = time; }, + restorationId: 'restorable_time_picker', + ))!; + final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 + final Offset min45 = Offset(center.dx - 50.0, center.dy); // 45 mins (or 9:00 hours) + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Setting to PM adds 12 hours (18:45) + await tester.tap(find.text('PM')); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 18, minute: 45))); + + // Test restoring from before PM was selected (6:45) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('Time Picker state restoration test - input mode', (WidgetTester tester) async { + TimeOfDay? result; + await startPicker( + tester, + (TimeOfDay? time) { result = time; }, + entryMode: TimePickerEntryMode.input, + restorationId: 'restorable_time_picker', + ); + await tester.enterText(find.byType(TextField).first, '9'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // Setting to PM adds 12 hours (21:12) + await tester.tap(find.text('PM')); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 21, minute: 12))); + + // Restoring from before PM was set (9:12) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + }); + + testWidgets('Time Picker state restoration test - switching modes', (WidgetTester tester) async { + TimeOfDay? result; + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) { result = time; }, + restorationId: 'restorable_time_picker', + ))!; + + final TestRestorationData restorationData = await tester.getRestorationData(); + // Switch to input mode from dial mode. + await tester.tap(find.byIcon(Icons.keyboard)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + // Select time using input mode controls. + await tester.enterText(find.byType(TextField).first, '9'); + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + + // Restoring from dial mode. + await tester.restoreFrom(restorationData); + final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 + final Offset min45 = Offset(center.dx - 50.0, center.dy); // 45 mins (or 9:00 hours) + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); } final Finder findDialPaint = find.descendant( diff --git a/packages/flutter/test/material/time_test.dart b/packages/flutter/test/material/time_test.dart index 7e5ada049d8..94e153e11d9 100644 --- a/packages/flutter/test/material/time_test.dart +++ b/packages/flutter/test/material/time_test.dart @@ -26,4 +26,143 @@ void main() { expect(await pumpTest(true), '07:00'); }); }); + + group('RestorableTimeOfDay tests', () { + testWidgets('value is not accessible when not registered', (WidgetTester tester) async { + expect(() => RestorableTimeOfDay(const TimeOfDay(hour: 20, minute: 4)).value, throwsAssertionError); + }); + + testWidgets('work when not in restoration scope', (WidgetTester tester) async { + await tester.pumpWidget(const _RestorableWidget()); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + }); + + testWidgets('restart and restore', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + + // Restores to previous values. + await tester.restartAndRestore(); + final _RestorableWidgetState oldState = state; + state = tester.state(find.byType(_RestorableWidget)); + expect(state, isNot(same(oldState))); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + }); + + testWidgets('restore to older state', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + final TestRestorationData restorationData = await tester.getRestorationData(); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 4, minute: 4); + }); + await tester.pump(); + + // Restore to previous. + await tester.restoreFrom(restorationData); + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + + // Restore to empty data will re-initialize to default values. + await tester.restoreFrom(TestRestorationData.empty); + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + }); + + testWidgets('call notifiers when value changes', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + final List notifyLog = []; + + state.timeOfDay.addListener(() { + notifyLog.add('hello world'); + }); + + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + expect(notifyLog.single, 'hello world'); + notifyLog.clear(); + await tester.pump(); + + // Does not notify when set to same value. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + + expect(notifyLog, isEmpty); + }); + }); +} + +class _RestorableWidget extends StatefulWidget { + const _RestorableWidget({Key? key}) : super(key: key); + + @override + State<_RestorableWidget> createState() => _RestorableWidgetState(); +} + +class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin { + final RestorableTimeOfDay timeOfDay = RestorableTimeOfDay(const TimeOfDay(hour: 10, minute: 5)); + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(timeOfDay, 'time_of_day'); + } + + void setProperties(VoidCallback callback) { + setState(callback); + } + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } + + @override + String get restorationId => 'widget'; }