time_picker_test.dart 41.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hans Muller's avatar
Hans Muller committed
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
@TestOn('!chrome')
Hans Muller's avatar
Hans Muller committed
6
import 'package:flutter/material.dart';
Yegor's avatar
Yegor committed
7
import 'package:flutter/rendering.dart';
Hans Muller's avatar
Hans Muller committed
8 9
import 'package:flutter_test/flutter_test.dart';

10
import '../widgets/semantics_tester.dart';
11 12
import 'feedback_tester.dart';

13 14
final Finder _hourControl = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl');
final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl');
15
final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == 'TimePickerDialog');
16

17
class _TimePickerLauncher extends StatefulWidget {
18
  const _TimePickerLauncher({
19 20
    Key? key,
    required this.onChanged,
21
    this.entryMode = TimePickerEntryMode.dial,
22
    this.restorationId,
23
  }) : super(key: key);
24

25
  final ValueChanged<TimeOfDay?> onChanged;
26
  final TimePickerEntryMode entryMode;
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
  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);
  }
75 76 77

  @override
  Widget build(BuildContext context) {
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
    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();
                }
              },
            );
          },
97 98
        ),
      ),
99
    );
100 101
  }
}
Hans Muller's avatar
Hans Muller committed
102

103
Future<Offset?> startPicker(
104
    WidgetTester tester,
105
    ValueChanged<TimeOfDay?> onChanged, {
106
      TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
107
      String? restorationId,
108
    }) async {
109 110 111 112 113 114 115 116 117
  await tester.pumpWidget(MaterialApp(
    restorationScopeId: 'app',
    locale: const Locale('en', 'US'),
    home: _TimePickerLauncher(
      onChanged: onChanged,
      entryMode: entryMode,
      restorationId: restorationId,
    ),
  ));
118
  await tester.tap(find.text('X'));
119
  await tester.pumpAndSettle(const Duration(seconds: 1));
120
  return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey<String>('time-picker-dial'))) : null;
121
}
Hans Muller's avatar
Hans Muller committed
122

123
Future<void> finishPicker(WidgetTester tester) async {
124
  final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(tester.element(find.byType(ElevatedButton)));
Yegor's avatar
Yegor committed
125
  await tester.tap(find.text(materialLocalizations.okButtonLabel));
126
  await tester.pumpAndSettle(const Duration(seconds: 1));
127
}
Hans Muller's avatar
Hans Muller committed
128

129
void main() {
130
  group('Time picker - Dial', () {
131 132
    _tests();
  });
133 134 135 136

  group('Time picker - Input', () {
    _testsInput();
  });
137 138 139
}

void _tests() {
140
  testWidgets('tap-select an hour', (WidgetTester tester) async {
141
    TimeOfDay? result;
142

143
    Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
144
    await tester.tapAt(Offset(center.dx, center.dy - 50.0)); // 12:00 AM
145 146 147
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 0, minute: 0)));

148
    center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
149
    await tester.tapAt(Offset(center.dx + 50.0, center.dy));
150 151 152
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 3, minute: 0)));

153
    center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
154
    await tester.tapAt(Offset(center.dx, center.dy + 50.0));
155 156 157
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 6, minute: 0)));

158
    center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
159 160
    await tester.tapAt(Offset(center.dx, center.dy + 50.0));
    await tester.tapAt(Offset(center.dx - 50, center.dy));
161 162
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 9, minute: 0)));
Ian Hickson's avatar
Ian Hickson committed
163 164
  });

165
  testWidgets('drag-select an hour', (WidgetTester tester) async {
166
    late TimeOfDay result;
167

168
    final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!;
169 170 171 172
    final Offset hour0 = Offset(center.dx, center.dy - 50.0); // 12:00 AM
    final Offset hour3 = Offset(center.dx + 50.0, center.dy);
    final Offset hour6 = Offset(center.dx, center.dy + 50.0);
    final Offset hour9 = Offset(center.dx - 50.0, center.dy);
173

174 175 176 177 178
    TestGesture gesture;

    gesture = await tester.startGesture(hour3);
    await gesture.moveBy(hour0 - hour3);
    await gesture.up();
179 180
    await finishPicker(tester);
    expect(result.hour, 0);
181

182
    expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center));
183 184 185
    gesture = await tester.startGesture(hour0);
    await gesture.moveBy(hour3 - hour0);
    await gesture.up();
186 187
    await finishPicker(tester);
    expect(result.hour, 3);
188

189
    expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center));
190 191 192
    gesture = await tester.startGesture(hour3);
    await gesture.moveBy(hour6 - hour3);
    await gesture.up();
193 194
    await finishPicker(tester);
    expect(result.hour, equals(6));
195

196
    expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center));
197 198 199
    gesture = await tester.startGesture(hour6);
    await gesture.moveBy(hour9 - hour6);
    await gesture.up();
200 201
    await finishPicker(tester);
    expect(result.hour, equals(9));
Hans Muller's avatar
Hans Muller committed
202
  });
203

204
  testWidgets('tap-select switches from hour to minute', (WidgetTester tester) async {
205
    late TimeOfDay result;
206

207
    final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!;
208 209 210 211 212 213 214 215 216 217 218
    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.tapAt(min45);
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 6, minute: 45)));
  });

  testWidgets('drag-select switches from hour to minute', (WidgetTester tester) async {
219
    late TimeOfDay result;
220

221
    final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!;
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
    final Offset hour3 = Offset(center.dx + 50.0, center.dy);
    final Offset hour6 = Offset(center.dx, center.dy + 50.0);
    final Offset hour9 = Offset(center.dx - 50.0, center.dy);

    TestGesture gesture = await tester.startGesture(hour6);
    await gesture.moveBy(hour9 - hour6);
    await gesture.up();
    await tester.pump(const Duration(milliseconds: 50));
    gesture = await tester.startGesture(hour6);
    await gesture.moveBy(hour3 - hour6);
    await gesture.up();
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 9, minute: 15)));
  });

237
  testWidgets('tap-select rounds down to nearest 5 minute increment', (WidgetTester tester) async {
238
    late TimeOfDay result;
239

240
    final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!;
241 242 243 244 245 246 247 248 249 250 251
    final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
    final Offset min46 = Offset(center.dx - 50.0, center.dy - 5); // 46 mins

    await tester.tapAt(hour6);
    await tester.pump(const Duration(milliseconds: 50));
    await tester.tapAt(min46);
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 6, minute: 45)));
  });

  testWidgets('tap-select rounds up to nearest 5 minute increment', (WidgetTester tester) async {
252
    late TimeOfDay result;
253

254
    final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!;
255 256 257 258 259 260 261 262 263 264
    final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
    final Offset min48 = Offset(center.dx - 50.0, center.dy - 15); // 48 mins

    await tester.tapAt(hour6);
    await tester.pump(const Duration(milliseconds: 50));
    await tester.tapAt(min48);
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 6, minute: 50)));
  });

265
  group('haptic feedback', () {
266 267
    const Duration kFastFeedbackInterval = Duration(milliseconds: 10);
    const Duration kSlowFeedbackInterval = Duration(milliseconds: 200);
268
    late FeedbackTester feedback;
269

270
    setUp(() {
271
      feedback = FeedbackTester();
272 273
    });

274
    tearDown(() {
275
      feedback.dispose();
276 277 278
    });

    testWidgets('tap-select vibrates once', (WidgetTester tester) async {
279
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
280
      await tester.tapAt(Offset(center.dx, center.dy - 50.0));
281
      await finishPicker(tester);
282
      expect(feedback.hapticCount, 1);
283 284 285
    });

    testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async {
286
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
287
      await tester.tapAt(Offset(center.dx, center.dy - 50.0));
288
      await tester.pump(kFastFeedbackInterval);
289
      await tester.tapAt(Offset(center.dx, center.dy + 50.0));
290
      await finishPicker(tester);
291
      expect(feedback.hapticCount, 1);
292 293 294
    });

    testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async {
295
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
296
      await tester.tapAt(Offset(center.dx, center.dy - 50.0));
297
      await tester.pump(kSlowFeedbackInterval);
298
      await tester.tapAt(Offset(center.dx, center.dy + 50.0));
299
      await tester.pump(kSlowFeedbackInterval);
300
      await tester.tapAt(Offset(center.dx, center.dy - 50.0));
301
      await finishPicker(tester);
302
      expect(feedback.hapticCount, 3);
303 304 305
    });

    testWidgets('drag-select vibrates once', (WidgetTester tester) async {
306
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
307 308
      final Offset hour0 = Offset(center.dx, center.dy - 50.0);
      final Offset hour3 = Offset(center.dx + 50.0, center.dy);
309

310
      final TestGesture gesture = await tester.startGesture(hour3);
311 312 313
      await gesture.moveBy(hour0 - hour3);
      await gesture.up();
      await finishPicker(tester);
314
      expect(feedback.hapticCount, 1);
315 316 317
    });

    testWidgets('quick drag-select vibrates once', (WidgetTester tester) async {
318
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
319 320
      final Offset hour0 = Offset(center.dx, center.dy - 50.0);
      final Offset hour3 = Offset(center.dx + 50.0, center.dy);
321

322
      final TestGesture gesture = await tester.startGesture(hour3);
323
      await gesture.moveBy(hour0 - hour3);
324
      await tester.pump(kFastFeedbackInterval);
325
      await gesture.moveBy(hour3 - hour0);
326
      await tester.pump(kFastFeedbackInterval);
327 328 329
      await gesture.moveBy(hour0 - hour3);
      await gesture.up();
      await finishPicker(tester);
330
      expect(feedback.hapticCount, 1);
331 332 333
    });

    testWidgets('slow drag-select vibrates once', (WidgetTester tester) async {
334
      final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
335 336
      final Offset hour0 = Offset(center.dx, center.dy - 50.0);
      final Offset hour3 = Offset(center.dx + 50.0, center.dy);
337

338
      final TestGesture gesture = await tester.startGesture(hour3);
339
      await gesture.moveBy(hour0 - hour3);
340
      await tester.pump(kSlowFeedbackInterval);
341
      await gesture.moveBy(hour3 - hour0);
342
      await tester.pump(kSlowFeedbackInterval);
343 344 345
      await gesture.moveBy(hour0 - hour3);
      await gesture.up();
      await finishPicker(tester);
346
      expect(feedback.hapticCount, 3);
347 348
    });
  });
349

350
  const List<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
351
  const List<String> labels00To22 = <String>['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22'];
352 353 354 355

  testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, false);

356
    final CustomPaint dialPaint = tester.widget(findDialPaint);
357
    final dynamic dialPainter = dialPaint.painter;
358 359
    final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
    expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
360

361 362
    final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
    expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
363 364 365 366 367
  });

  testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, true);

368
    final CustomPaint dialPaint = tester.widget(findDialPaint);
369
    final dynamic dialPainter = dialPaint.painter;
370 371 372 373 374
    final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
    expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);

    final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
    expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
375 376 377
  });

  testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async {
378
    final SemanticsTester semantics = SemanticsTester(tester);
379 380
    await mediaQueryBoilerplate(tester, false);

381 382 383 384 385
    expect(
      semantics,
      includesNodeWith(
        label: 'AM',
        actions: <SemanticsAction>[SemanticsAction.tap],
386 387 388 389 390 391 392
        flags: <SemanticsFlag>[
          SemanticsFlag.isButton,
          SemanticsFlag.isChecked,
          SemanticsFlag.isInMutuallyExclusiveGroup,
          SemanticsFlag.hasCheckedState,
          SemanticsFlag.isFocusable,
        ],
393 394 395 396 397 398 399
      ),
    );
    expect(
      semantics,
      includesNodeWith(
        label: 'PM',
        actions: <SemanticsAction>[SemanticsAction.tap],
400 401 402 403 404 405
        flags: <SemanticsFlag>[
          SemanticsFlag.isButton,
          SemanticsFlag.isInMutuallyExclusiveGroup,
          SemanticsFlag.hasCheckedState,
          SemanticsFlag.isFocusable,
        ],
406 407
      ),
    );
408 409 410 411 412

    semantics.dispose();
  });

  testWidgets('provides semantics information for header and footer', (WidgetTester tester) async {
413
    final SemanticsTester semantics = SemanticsTester(tester);
414 415 416
    await mediaQueryBoilerplate(tester, true);

    expect(semantics, isNot(includesNodeWith(label: ':')));
417 418 419 420 421 422 423 424 425 426
    expect(
      semantics.nodesWith(value: 'Select minutes 00'),
      hasLength(1),
      reason: '00 appears once in the header',
    );
    expect(
      semantics.nodesWith(value: 'Select hours 07'),
      hasLength(1),
      reason: '07 appears once in the header',
    );
427 428 429 430 431 432 433 434 435 436
    expect(semantics, includesNodeWith(label: 'CANCEL'));
    expect(semantics, includesNodeWith(label: 'OK'));

    // In 24-hour mode we don't have AM/PM control.
    expect(semantics, isNot(includesNodeWith(label: 'AM')));
    expect(semantics, isNot(includesNodeWith(label: 'PM')));

    semantics.dispose();
  });

437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462
  testWidgets('provides semantics information for text fields', (WidgetTester tester) async {
    final SemanticsTester semantics = SemanticsTester(tester);
    await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, accessibleNavigation: true);

    expect(
      semantics,
      includesNodeWith(
        label: 'Hour',
        value: '07',
        actions: <SemanticsAction>[SemanticsAction.tap],
        flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isMultiline],
      ),
    );
    expect(
      semantics,
      includesNodeWith(
        label: 'Minute',
        value: '00',
        actions: <SemanticsAction>[SemanticsAction.tap],
        flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isMultiline],
      ),
    );

    semantics.dispose();
  });

463
  testWidgets('can increment and decrement hours', (WidgetTester tester) async {
464
    final SemanticsTester semantics = SemanticsTester(tester);
465

466
    Future<void> actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue }) async {
467
      final SemanticsNode elevenHours = semantics.nodesWith(
468
        value: 'Select hours $initialValue',
469 470
        ancestor: tester.renderObject(_hourControl).debugSemantics,
      ).single;
471
      tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action);
472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492
      await tester.pumpAndSettle();
      expect(
        find.descendant(of: _hourControl, matching: find.text(finalValue)),
        findsOneWidget,
      );
    }

    // 12-hour format
    await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 0));
    await actAndExpect(
      initialValue: '11',
      action: SemanticsAction.increase,
      finalValue: '12',
    );
    await actAndExpect(
      initialValue: '12',
      action: SemanticsAction.increase,
      finalValue: '1',
    );

    // Ensure we preserve day period as we roll over.
493
    final dynamic pickerState = tester.state(_timePickerDialog);
494
    expect(pickerState.selectedTime.value, const TimeOfDay(hour: 1, minute: 0));
495 496 497 498 499 500

    await actAndExpect(
      initialValue: '1',
      action: SemanticsAction.decrease,
      finalValue: '12',
    );
501
    await tester.pumpWidget(Container()); // clear old boilerplate
502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524

    // 24-hour format
    await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 23, minute: 0));
    await actAndExpect(
      initialValue: '23',
      action: SemanticsAction.increase,
      finalValue: '00',
    );
    await actAndExpect(
      initialValue: '00',
      action: SemanticsAction.increase,
      finalValue: '01',
    );
    await actAndExpect(
      initialValue: '01',
      action: SemanticsAction.decrease,
      finalValue: '00',
    );
    await actAndExpect(
      initialValue: '00',
      action: SemanticsAction.decrease,
      finalValue: '23',
    );
525 526

    semantics.dispose();
527 528 529
  });

  testWidgets('can increment and decrement minutes', (WidgetTester tester) async {
530
    final SemanticsTester semantics = SemanticsTester(tester);
531

532
    Future<void> actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue }) async {
533
      final SemanticsNode elevenHours = semantics.nodesWith(
534
        value: 'Select minutes $initialValue',
535 536
        ancestor: tester.renderObject(_minuteControl).debugSemantics,
      ).single;
537
      tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action);
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557
      await tester.pumpAndSettle();
      expect(
        find.descendant(of: _minuteControl, matching: find.text(finalValue)),
        findsOneWidget,
      );
    }

    await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 58));
    await actAndExpect(
      initialValue: '58',
      action: SemanticsAction.increase,
      finalValue: '59',
    );
    await actAndExpect(
      initialValue: '59',
      action: SemanticsAction.increase,
      finalValue: '00',
    );

    // Ensure we preserve hour period as we roll over.
558
    final dynamic pickerState = tester.state(_timePickerDialog);
559
    expect(pickerState.selectedTime.value, const TimeOfDay(hour: 11, minute: 0));
560 561 562 563 564 565 566 567 568 569 570

    await actAndExpect(
      initialValue: '00',
      action: SemanticsAction.decrease,
      finalValue: '59',
    );
    await actAndExpect(
      initialValue: '59',
      action: SemanticsAction.decrease,
      finalValue: '58',
    );
571 572

    semantics.dispose();
573
  });
574

575
  testWidgets('header touch regions are large enough', (WidgetTester tester) async {
576 577 578
    // Ensure picker is displayed in portrait mode.
    tester.binding.window.physicalSizeTestValue = const Size(400, 800);
    tester.binding.window.devicePixelRatioTestValue = 1;
579 580
    await mediaQueryBoilerplate(tester, false);

581 582 583 584
    final Size dayPeriodControlSize = tester.getSize(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'));
    expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48.0));
    // Height should be double the minimum size to account for both AM/PM stacked.
    expect(dayPeriodControlSize.height, greaterThanOrEqualTo(48.0 * 2));
585 586 587 588 589 590 591 592 593 594 595 596 597 598

    final Size hourSize = tester.getSize(find.ancestor(
      of: find.text('7'),
      matching: find.byType(InkWell),
    ));
    expect(hourSize.width, greaterThanOrEqualTo(48.0));
    expect(hourSize.height, greaterThanOrEqualTo(48.0));

    final Size minuteSize = tester.getSize(find.ancestor(
      of: find.text('00'),
      matching: find.byType(InkWell),
    ));
    expect(minuteSize.width, greaterThanOrEqualTo(48.0));
    expect(minuteSize.height, greaterThanOrEqualTo(48.0));
599

600 601
    tester.binding.window.clearPhysicalSizeTestValue();
    tester.binding.window.clearDevicePixelRatioTestValue();
602 603
  });

604 605 606 607 608 609 610
  testWidgets('builder parameter', (WidgetTester tester) async {
    Widget buildFrame(TextDirection textDirection) {
      return MaterialApp(
        home: Material(
          child: Center(
            child: Builder(
              builder: (BuildContext context) {
611
                return ElevatedButton(
612 613 614 615 616
                  child: const Text('X'),
                  onPressed: () {
                    showTimePicker(
                      context: context,
                      initialTime: const TimeOfDay(hour: 7, minute: 0),
617
                      builder: (BuildContext context, Widget? child) {
618 619
                        return Directionality(
                          textDirection: textDirection,
620
                          child: child!,
621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650
                        );
                      },
                    );
                  },
                );
              },
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildFrame(TextDirection.ltr));
    await tester.tap(find.text('X'));
    await tester.pumpAndSettle();
    final double ltrOkRight = tester.getBottomRight(find.text('OK')).dx;

    await tester.tap(find.text('OK')); // dismiss the dialog
    await tester.pumpAndSettle();

    await tester.pumpWidget(buildFrame(TextDirection.rtl));
    await tester.tap(find.text('X'));
    await tester.pumpAndSettle();

    // Verify that the time picker is being laid out RTL.
    // We expect the left edge of the 'OK' button in the RTL
    // layout to match the gap between right edge of the 'OK'
    // button and the right edge of the 800 wide window.
    expect(tester.getBottomLeft(find.text('OK')).dx, 800 - ltrOkRight);
  });
651 652 653 654 655 656 657 658 659 660 661 662

  testWidgets('uses root navigator by default', (WidgetTester tester) async {
    final PickerObserver rootObserver = PickerObserver();
    final PickerObserver nestedObserver = PickerObserver();

    await tester.pumpWidget(MaterialApp(
      navigatorObservers: <NavigatorObserver>[rootObserver],
      home: Navigator(
        observers: <NavigatorObserver>[nestedObserver],
        onGenerateRoute: (RouteSettings settings) {
          return MaterialPageRoute<dynamic>(
            builder: (BuildContext context) {
663
              return ElevatedButton(
664 665 666 667 668 669 670 671 672 673 674 675 676 677 678
                onPressed: () {
                  showTimePicker(
                    context: context,
                    initialTime: const TimeOfDay(hour: 7, minute: 0),
                  );
                },
                child: const Text('Show Picker'),
              );
            },
          );
        },
      ),
    ));

    // Open the dialog.
679
    await tester.tap(find.byType(ElevatedButton));
680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695

    expect(rootObserver.pickerCount, 1);
    expect(nestedObserver.pickerCount, 0);
  });

  testWidgets('uses nested navigator if useRootNavigator is false', (WidgetTester tester) async {
    final PickerObserver rootObserver = PickerObserver();
    final PickerObserver nestedObserver = PickerObserver();

    await tester.pumpWidget(MaterialApp(
      navigatorObservers: <NavigatorObserver>[rootObserver],
      home: Navigator(
        observers: <NavigatorObserver>[nestedObserver],
        onGenerateRoute: (RouteSettings settings) {
          return MaterialPageRoute<dynamic>(
            builder: (BuildContext context) {
696
              return ElevatedButton(
697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712
                onPressed: () {
                  showTimePicker(
                    context: context,
                    useRootNavigator: false,
                    initialTime: const TimeOfDay(hour: 7, minute: 0),
                  );
                },
                child: const Text('Show Picker'),
              );
            },
          );
        },
      ),
    ));

    // Open the dialog.
713
    await tester.tap(find.byType(ElevatedButton));
714 715 716 717

    expect(rootObserver.pickerCount, 0);
    expect(nestedObserver.pickerCount, 1);
  });
718

719 720 721 722 723
  testWidgets('optional text parameters are utilized', (WidgetTester tester) async {
    const String cancelText = 'Custom Cancel';
    const String confirmText = 'Custom OK';
    const String helperText = 'Custom Help';
    await tester.pumpWidget(MaterialApp(
724 725 726 727 728 729 730 731 732 733 734 735 736
      home: Material(
        child: Center(
          child: Builder(
            builder: (BuildContext context) {
              return ElevatedButton(
                child: const Text('X'),
                onPressed: () async {
                  await showTimePicker(
                    context: context,
                    initialTime: const TimeOfDay(hour: 7, minute: 0),
                    cancelText: cancelText,
                    confirmText: confirmText,
                    helpText: helperText,
737
                  );
738 739 740
                },
              );
            },
741
          ),
742 743
        ),
      ),
744 745 746
    ));

    // Open the picker.
747
    await tester.tap(find.text('X'));
748
    await tester.pumpAndSettle(const Duration(seconds: 1));
749

750 751 752 753
    expect(find.text(cancelText), findsOneWidget);
    expect(find.text(confirmText), findsOneWidget);
    expect(find.text(helperText), findsOneWidget);
  });
754

755 756 757 758 759 760 761 762 763 764 765 766 767
  testWidgets('OK Cancel button layout', (WidgetTester tester) async {
    Widget buildFrame(TextDirection textDirection) {
      return MaterialApp(
        home: Material(
          child: Center(
            child: Builder(
              builder: (BuildContext context) {
                return ElevatedButton(
                  child: const Text('X'),
                  onPressed: () {
                    showTimePicker(
                      context: context,
                      initialTime: const TimeOfDay(hour: 7, minute: 0),
768
                      builder: (BuildContext context, Widget? child) {
769 770
                        return Directionality(
                          textDirection: textDirection,
771
                          child: child!,
772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842
                        );
                      },
                    );
                  },
                );
              },
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildFrame(TextDirection.ltr));
    await tester.tap(find.text('X'));
    await tester.pumpAndSettle();
    expect(tester.getBottomRight(find.text('OK')).dx, 638);
    expect(tester.getBottomLeft(find.text('OK')).dx, 610);
    expect(tester.getBottomRight(find.text('CANCEL')).dx, 576);
    await tester.tap(find.text('OK'));
    await tester.pumpAndSettle();

    await tester.pumpWidget(buildFrame(TextDirection.rtl));
    await tester.tap(find.text('X'));
    await tester.pumpAndSettle();
    expect(tester.getBottomLeft(find.text('OK')).dx, 162);
    expect(tester.getBottomRight(find.text('OK')).dx, 190);
    expect(tester.getBottomLeft(find.text('CANCEL')).dx, 224);
    await tester.tap(find.text('OK'));
    await tester.pumpAndSettle();
  });

  testWidgets('text scale affects certain elements and not others', (WidgetTester tester) async {
    await mediaQueryBoilerplate(
      tester,
      false,
      textScaleFactor: 1.0,
      initialTime: const TimeOfDay(hour: 7, minute: 41),
    );

    final double minutesDisplayHeight = tester.getSize(find.text('41')).height;
    final double amHeight = tester.getSize(find.text('AM')).height;

    await tester.tap(find.text('OK')); // dismiss the dialog
    await tester.pumpAndSettle();

    // Verify that the time display is not affected by text scale.
    await mediaQueryBoilerplate(
      tester,
      false,
      textScaleFactor: 2.0,
      initialTime: const TimeOfDay(hour: 7, minute: 41),
    );

    final double amHeight2x = tester.getSize(find.text('AM')).height;
    expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
    expect(amHeight2x, greaterThanOrEqualTo(amHeight * 2));

    await tester.tap(find.text('OK')); // dismiss the dialog
    await tester.pumpAndSettle();

    // Verify that text scale for AM/PM is at most 2x.
    await mediaQueryBoilerplate(
      tester,
      false,
      textScaleFactor: 3.0,
      initialTime: const TimeOfDay(hour: 7, minute: 41),
    );

    expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
    expect(tester.getSize(find.text('AM')).height, equals(amHeight2x));
  });
843
}
844

845 846 847 848 849
void _testsInput() {
  testWidgets('Initial entry mode is used', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
    expect(find.byType(TextField), findsNWidgets(2));
  });
850

851
  testWidgets('Initial time is the default', (WidgetTester tester) async {
852
    late TimeOfDay result;
853
    await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input);
854 855 856
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 7, minute: 0)));
  });
857

858 859 860 861 862
  testWidgets('Help text is used - Input', (WidgetTester tester) async {
    const String helpText = 'help';
    await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, helpText: helpText);
    expect(find.text(helpText), findsOneWidget);
  });
863

864 865 866
  testWidgets('Can toggle to dial entry mode', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
    await tester.tap(find.byIcon(Icons.access_time));
867
    await tester.pumpAndSettle();
868
    expect(find.byType(TextField), findsNothing);
869
  });
870

871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953
  testWidgets('Can double tap hours (when selected) to enter input mode', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, false, entryMode: TimePickerEntryMode.dial);
    final Finder hourFinder = find.ancestor(
      of: find.text('7'),
      matching: find.byType(InkWell),
    );

    expect(find.byType(TextField), findsNothing);

    // Double tap the hour.
    await tester.tap(hourFinder);
    await tester.pump(const Duration(milliseconds: 100));
    await tester.tap(hourFinder);
    await tester.pumpAndSettle();

    expect(find.byType(TextField), findsWidgets);
  });

  testWidgets('Can not double tap hours (when not selected) to enter input mode', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, false, entryMode: TimePickerEntryMode.dial);
    final Finder hourFinder = find.ancestor(
      of: find.text('7'),
      matching: find.byType(InkWell),
    );
    final Finder minuteFinder = find.ancestor(
      of: find.text('00'),
      matching: find.byType(InkWell),
    );

    expect(find.byType(TextField), findsNothing);

    // Switch to minutes mode.
    await tester.tap(minuteFinder);
    await tester.pumpAndSettle();

    // Double tap the hour.
    await tester.tap(hourFinder);
    await tester.pump(const Duration(milliseconds: 100));
    await tester.tap(hourFinder);
    await tester.pumpAndSettle();

    expect(find.byType(TextField), findsNothing);
  });

  testWidgets('Can double tap minutes (when selected) to enter input mode', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, false, entryMode: TimePickerEntryMode.dial);
    final Finder minuteFinder = find.ancestor(
      of: find.text('00'),
      matching: find.byType(InkWell),
    );

    expect(find.byType(TextField), findsNothing);

    // Switch to minutes mode.
    await tester.tap(minuteFinder);
    await tester.pumpAndSettle();

    // Double tap the minutes.
    await tester.tap(minuteFinder);
    await tester.pump(const Duration(milliseconds: 100));
    await tester.tap(minuteFinder);
    await tester.pumpAndSettle();

    expect(find.byType(TextField), findsWidgets);
  });

  testWidgets('Can not double tap minutes (when not selected) to enter input mode', (WidgetTester tester) async {
    await mediaQueryBoilerplate(tester, false, entryMode: TimePickerEntryMode.dial);
    final Finder minuteFinder = find.ancestor(
      of: find.text('00'),
      matching: find.byType(InkWell),
    );

    expect(find.byType(TextField), findsNothing);

    // Double tap the minutes.
    await tester.tap(minuteFinder);
    await tester.pump(const Duration(milliseconds: 100));
    await tester.tap(minuteFinder);
    await tester.pumpAndSettle();

    expect(find.byType(TextField), findsNothing);
  });
954

955
  testWidgets('Entered text returns time', (WidgetTester tester) async {
956
    late TimeOfDay result;
957
    await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input);
958 959 960 961 962
    await tester.enterText(find.byType(TextField).first, '9');
    await tester.enterText(find.byType(TextField).last, '12');
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 9, minute: 12)));
  });
963

964
  testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async {
965
    late TimeOfDay result;
966
    await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input);
967 968 969 970 971 972
    await tester.enterText(find.byType(TextField).first, '8');
    await tester.enterText(find.byType(TextField).last, '15');
    await tester.tap(find.byIcon(Icons.access_time));
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 8, minute: 15)));
  });
973

974
  testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async {
975
    TimeOfDay? result;
976
    await startPicker(tester, (TimeOfDay? time) { result = time; }, entryMode: TimePickerEntryMode.input);
977

978 979 980 981 982
    // Invalid hour.
    await tester.enterText(find.byType(TextField).first, '88');
    await tester.enterText(find.byType(TextField).last, '15');
    await finishPicker(tester);
    expect(result, null);
983

984 985
    // Invalid minute.
    await tester.enterText(find.byType(TextField).first, '8');
986
    await tester.enterText(find.byType(TextField).last, '95');
987 988
    await finishPicker(tester);
    expect(result, null);
989

990 991 992 993 994
    await tester.enterText(find.byType(TextField).first, '8');
    await tester.enterText(find.byType(TextField).last, '15');
    await finishPicker(tester);
    expect(result, equals(const TimeOfDay(hour: 8, minute: 15)));
  });
995 996 997

  // Fixes regression that was reverted in https://github.com/flutter/flutter/pull/64094#pullrequestreview-469836378.
  testWidgets('Ensure hour/minute fields are top-aligned with the separator', (WidgetTester tester) async {
998
    await startPicker(tester, (TimeOfDay? time) { }, entryMode: TimePickerEntryMode.input);
999 1000 1001 1002 1003 1004
    final double hourFieldTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField')).dy;
    final double minuteFieldTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField')).dy;
    final double separatorTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_StringFragment')).dy;
    expect(hourFieldTop, separatorTop);
    expect(minuteFieldTop, separatorTop);
  });
1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100

  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)));
  });
Hans Muller's avatar
Hans Muller committed
1101
}
1102

1103 1104 1105 1106 1107
final Finder findDialPaint = find.descendant(
  of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
  matching: find.byWidgetPredicate((Widget w) => w is CustomPaint),
);

1108 1109 1110 1111
class PickerObserver extends NavigatorObserver {
  int pickerCount = 0;

  @override
1112
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
1113
    if (route is DialogRoute) {
1114 1115 1116 1117 1118
      pickerCount++;
    }
    super.didPush(route, previousRoute);
  }
}
1119 1120

Future<void> mediaQueryBoilerplate(
1121 1122 1123 1124 1125 1126 1127 1128
  WidgetTester tester,
  bool alwaysUse24HourFormat, {
  TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0),
  double textScaleFactor = 1.0,
  TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
  String? helpText,
  bool accessibleNavigation = false,
}) async {
1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139
  await tester.pumpWidget(
    Localizations(
      locale: const Locale('en', 'US'),
      delegates: const <LocalizationsDelegate<dynamic>>[
        DefaultMaterialLocalizations.delegate,
        DefaultWidgetsLocalizations.delegate,
      ],
      child: MediaQuery(
        data: MediaQueryData(
          alwaysUse24HourFormat: alwaysUse24HourFormat,
          textScaleFactor: textScaleFactor,
1140
          accessibleNavigation: accessibleNavigation,
1141 1142 1143 1144 1145 1146 1147
        ),
        child: Material(
          child: Directionality(
            textDirection: TextDirection.ltr,
            child: Navigator(
              onGenerateRoute: (RouteSettings settings) {
                return MaterialPageRoute<void>(builder: (BuildContext context) {
1148
                  return TextButton(
1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169
                    onPressed: () {
                      showTimePicker(
                        context: context,
                        initialTime: initialTime,
                        initialEntryMode: entryMode,
                        helpText: helpText,
                      );
                    },
                    child: const Text('X'),
                  );
                });
              },
            ),
          ),
        ),
      ),
    ),
  );
  await tester.tap(find.text('X'));
  await tester.pumpAndSettle();
}