Unverified Commit 07849778 authored by Shi-Hao Hong's avatar Shi-Hao Hong Committed by GitHub

[State Restoration] Restorable `TimePickerDialog` widget, `RestorableTimeOfDay` (#80566)

parent e6d4b8cf
......@@ -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<TimeOfDay> {
/// 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<Object?> timeData = data! as List<Object?>;
return TimeOfDay(
minute: timeData[0]! as int,
hour: timeData[1]! as int,
);
}
@override
Object? toPrimitives() => <int>[value.minute, value.hour];
}
/// Determines how the time picker invoked using [showTimePicker] formats and
/// lays out the time controls.
///
......
......@@ -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<TimeOfDay?> 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<TimeOfDay?> _restorableTimePickerRouteFuture = RestorableRouteFuture<TimeOfDay?>(
onComplete: _selectTime,
onPresent: (NavigatorState navigator, Object? arguments) {
return navigator.restorablePush(
_timePickerRoute,
arguments: <String, int>{
'entryMode': widget.entryMode.index,
},
);
},
);
static Route<TimeOfDay> _timePickerRoute(
BuildContext context,
Object? arguments,
) {
final Map<dynamic, dynamic> args = arguments! as Map<dynamic, dynamic>;
final TimePickerEntryMode entryMode = TimePickerEntryMode.values[args['entryMode'] as int];
return DialogRoute<TimeOfDay>(
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<Offset?> startPicker(
WidgetTester tester,
ValueChanged<TimeOfDay?> 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<String>('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(
......
......@@ -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<String> notifyLog = <String>[];
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';
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment