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

5
@TestOn('!chrome')
6 7 8 9 10 11 12
import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
13
  late FakeBuilder mockHelper;
14

15
  setUp(() {
16
    mockHelper = FakeBuilder();
17 18
  });

19
  int testListLength = 10;
20
  SliverList buildAListOfStuff() {
21 22
    return SliverList(
      delegate: SliverChildBuilderDelegate(
23
        (BuildContext context, int index) {
24
          return SizedBox(
25
            height: 200.0,
26
            child: Center(child: Text(index.toString())),
27 28
          );
        },
29
        childCount: testListLength,
30 31 32 33
      ),
    );
  }

34
  void uiTestGroup() {
35 36
    testWidgets("doesn't invoke anything without user interaction", (WidgetTester tester) async {
      await tester.pumpWidget(
37 38
        CupertinoApp(
          home: CustomScrollView(
39
            slivers: <Widget>[
40
              CupertinoSliverRefreshControl(
41
                builder: mockHelper.builder,
42 43 44 45 46 47 48
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

49
      expect(mockHelper.invocations, isEmpty);
50 51

      expect(
52
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
53
        Offset.zero,
54
      );
Dan Field's avatar
Dan Field committed
55
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
56 57 58

    testWidgets('calls the indicator builder when starting to overscroll', (WidgetTester tester) async {
      await tester.pumpWidget(
59 60
        CupertinoApp(
          home: CustomScrollView(
61
            slivers: <Widget>[
62
              CupertinoSliverRefreshControl(
63
                builder: mockHelper.builder,
64 65 66 67 68 69 70 71
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      // Drag down but not enough to trigger the refresh.
72
      await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0);
73 74
      await tester.pump();

75
      // The function is referenced once while passing into CupertinoSliverRefreshControl
76
      // and is called.
77 78 79 80 81
      expect(mockHelper.invocations.first, matchesBuilder(
        refreshState: RefreshIndicatorMode.drag,
        pulledExtent: 50,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
82
      ));
83
      expect(mockHelper.invocations, hasLength(1));
84 85

      expect(
86
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
87 88
        const Offset(0.0, 50.0),
      );
Dan Field's avatar
Dan Field committed
89
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
90 91 92 93 94

    testWidgets(
      "don't call the builder if overscroll doesn't move slivers like on Android",
      (WidgetTester tester) async {
        await tester.pumpWidget(
95
          Directionality(
96
            textDirection: TextDirection.ltr,
97 98 99 100 101 102 103 104 105 106
            child: MediaQuery(
              data: const MediaQueryData(),
              child: CustomScrollView(
                slivers: <Widget>[
                  CupertinoSliverRefreshControl(
                    builder: mockHelper.builder,
                  ),
                  buildAListOfStuff(),
                ],
              ),
107 108 109 110 111 112 113 114
            ),
          ),
        );

        // Drag down but not enough to trigger the refresh.
        await tester.drag(find.text('0'), const Offset(0.0, 50.0));
        await tester.pump();

115
        expect(mockHelper.invocations, isEmpty);
116 117

        expect(
118
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
119
          Offset.zero,
120
        );
121 122 123
      },
      variant: TargetPlatformVariant.only(TargetPlatform.android),
    );
124

125
    testWidgets('let the builder update as canceled drag scrolls away', (WidgetTester tester) async {
126
      await tester.pumpWidget(
127 128
        CupertinoApp(
          home: CustomScrollView(
129
            slivers: <Widget>[
130
              CupertinoSliverRefreshControl(
131
                builder: mockHelper.builder,
132 133 134 135 136 137 138 139
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      // Drag down but not enough to trigger the refresh.
140
      await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0);
141 142 143 144 145
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 20));
      await tester.pump(const Duration(milliseconds: 20));
      await tester.pump(const Duration(seconds: 3));

146 147 148 149 150 151
      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 50,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
152
        ),
153 154 155 156 157
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(48.36801747187993),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
158
        ),
159 160 161 162 163
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(44.63031931875867),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
164
        ),
165 166 167
      ]));
      // The builder isn't called again when the sliver completely goes away.
      expect(mockHelper.invocations, hasLength(3));
168 169

      expect(
170
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
171
        Offset.zero,
172
      );
Dan Field's avatar
Dan Field committed
173
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
174 175 176 177

    testWidgets('drag past threshold triggers refresh task', (WidgetTester tester) async {
      final List<MethodCall> platformCallLog = <MethodCall>[];

178
      tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
179
        platformCallLog.add(methodCall);
180
        return null;
181 182 183
      });

      await tester.pumpWidget(
184 185
        CupertinoApp(
          home: CustomScrollView(
186
            slivers: <Widget>[
187
              CupertinoSliverRefreshControl(
188 189
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
190 191 192 193 194 195 196
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

197
      final TestGesture gesture = await tester.startGesture(Offset.zero);
198 199 200 201 202 203 204
      await gesture.moveBy(const Offset(0.0, 99.0));
      await tester.pump();
      await gesture.moveBy(const Offset(0.0, -30.0));
      await tester.pump();
      await gesture.moveBy(const Offset(0.0, 50.0));
      await tester.pump();

205 206 207 208 209 210
      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 99,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
211
        ),
212 213 214 215 216
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(86.78169),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
217
        ),
218 219 220 221 222
        matchesBuilder(
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: moreOrLessEquals(105.80452021305739),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
223
        ),
224 225 226 227
      ]));
      // The refresh callback is triggered after the frame.
      expect(mockHelper.invocations.last, const RefreshTaskInvocation());
      expect(mockHelper.invocations, hasLength(4));
228 229 230 231 232

      expect(
        platformCallLog.last,
        isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'),
      );
Dan Field's avatar
Dan Field committed
233
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
234 235 236 237 238

    testWidgets(
      'refreshing task keeps the sliver expanded forever until done',
      (WidgetTester tester) async {
        await tester.pumpWidget(
239 240
          CupertinoApp(
            home: CustomScrollView(
241
              slivers: <Widget>[
242
                CupertinoSliverRefreshControl(
243 244
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
245 246 247 248 249 250 251
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

252
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
253 254 255 256
        await tester.pump();
        // Let it start snapping back.
        await tester.pump(const Duration(milliseconds: 50));

257 258 259 260 261 262
        expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
          matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: 150,
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
263
          ),
264 265 266 267 268 269
          equals(const RefreshTaskInvocation()),
          matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: moreOrLessEquals(127.10396988577114),
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
270
          ),
271
        ]));
272 273 274

        // Reaches refresh state and sliver's at 60.0 in height after a while.
        await tester.pump(const Duration(seconds: 1));
275 276 277 278 279 280 281

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.refresh,
          pulledExtent: 60,
          refreshIndicatorExtent: 60, // Default value.
          refreshTriggerPullDistance: 100, // Default value.
        )));
282 283 284 285

        // Stays in that state forever until future completes.
        await tester.pump(const Duration(seconds: 1000));
        expect(
286
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
287 288 289
          const Offset(0.0, 60.0),
        );

290
        mockHelper.refreshCompleter.complete(null);
291 292
        await tester.pump();

293 294 295 296 297 298 299
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshIndicatorExtent: 60, // Default value.
          refreshTriggerPullDistance: 100, // Default value.
        )));
        expect(mockHelper.invocations, hasLength(5));
300 301 302
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
303

304 305 306 307 308 309
    testWidgets(
      'refreshing task keeps the sliver expanded forever until completes with error',
      (WidgetTester tester) async {
        final FlutterError error = FlutterError('Oops');
        double errorCount = 0;

310
        runZonedGuarded(
311
          () async {
312
            mockHelper.refreshCompleter = Completer<void>.sync();
313
            await tester.pumpWidget(
314 315
              CupertinoApp(
                home: CustomScrollView(
316 317
                  slivers: <Widget>[
                    CupertinoSliverRefreshControl(
318 319
                      builder: mockHelper.builder,
                      onRefresh: mockHelper.refreshTask,
320 321 322 323
                    ),
                    buildAListOfStuff(),
                  ],
                ),
324
              ),
325 326 327 328 329 330 331
            );

            await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
            await tester.pump();
            // Let it start snapping back.
            await tester.pump(const Duration(milliseconds: 50));

332 333 334 335 336 337
            expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
             matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: 150,
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
338
              ),
339 340 341 342 343 344
              equals(const RefreshTaskInvocation()),
              matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: moreOrLessEquals(127.10396988577114),
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
345
              ),
346
            ]));
347

348 349
            // Reaches refresh state and sliver's at 60.0 in height after a while.
            await tester.pump(const Duration(seconds: 1));
350 351 352 353 354 355
            expect(mockHelper.invocations, contains(matchesBuilder(
              refreshState: RefreshIndicatorMode.refresh,
              pulledExtent: 60,
              refreshIndicatorExtent: 60, // Default value.
              refreshTriggerPullDistance: 100, // Default value.
            )));
356

357 358 359
            // Stays in that state forever until future completes.
            await tester.pump(const Duration(seconds: 1000));
            expect(
360
              tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
361 362
              const Offset(0.0, 60.0),
            );
363

364
            mockHelper.refreshCompleter.completeError(error);
365
            await tester.pump();
366

367 368 369 370 371 372 373
            expect(mockHelper.invocations, contains(matchesBuilder(
              refreshState: RefreshIndicatorMode.done,
              pulledExtent: 60,
              refreshIndicatorExtent: 60, // Default value.
              refreshTriggerPullDistance: 100, // Default value.
            )));
            expect(mockHelper.invocations, hasLength(5));
374
          },
375
          (Object e, StackTrace stack) {
376 377 378 379 380
            expect(e, error);
            expect(errorCount, 0);
            errorCount++;
          },
        );
381 382 383
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
384

385
    testWidgets('expanded refreshing sliver scrolls normally', (WidgetTester tester) async {
386
      mockHelper.refreshIndicator = const Center(child: Text('-1'));
387 388

      await tester.pumpWidget(
389 390
        CupertinoApp(
          home: CustomScrollView(
391
            slivers: <Widget>[
392
              CupertinoSliverRefreshControl(
393 394
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
395 396 397 398 399 400 401
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

402
      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
403 404
      await tester.pump();

405 406 407 408 409 410
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
411 412 413 414

      // Given a box constraint of 150, the Center will occupy all that height.
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
415
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
416 417
      );

418
      await tester.drag(find.text('0'), const Offset(0.0, -300.0), touchSlopY: 0, warnIfMissed: false); // hits the list
419 420 421
      await tester.pump();

      // Refresh indicator still being told to layout the same way.
422 423 424 425 426 427
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
428 429 430

      // Now the sliver is scrolled off screen.
      expect(
431
        tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
432 433 434
        moreOrLessEquals(-175.38461538461536),
      );
      expect(
435
        tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
436 437 438 439 440 441 442 443 444
        moreOrLessEquals(-115.38461538461536),
      );
      expect(
        tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
        moreOrLessEquals(-115.38461538461536),
      );

      // Scroll the top of the refresh indicator back to overscroll, it will
      // snap to the size of the refresh indicator and stay there.
445
      await tester.drag(find.text('1'), const Offset(0.0, 200.0), warnIfMissed: false); // hits the list
446 447 448 449
      await tester.pump();
      await tester.pump(const Duration(seconds: 2));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
450
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
451 452 453
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
454
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
455
      );
Dan Field's avatar
Dan Field committed
456
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
457 458

    testWidgets('expanded refreshing sliver goes away when done', (WidgetTester tester) async {
459
      mockHelper.refreshIndicator = const Center(child: Text('-1'));
460 461

      await tester.pumpWidget(
462 463
        CupertinoApp(
          home: CustomScrollView(
464
            slivers: <Widget>[
465
              CupertinoSliverRefreshControl(
466 467
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
468 469 470 471 472 473 474
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

475
      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
476
      await tester.pump();
477 478 479 480 481 482
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
483 484
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
485
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
486
      );
487
      expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
488 489 490 491 492

      // Rebuilds the sliver with a layout extent now.
      await tester.pump();
      // Let it snap back to occupy the indicator's final sliver space only.
      await tester.pump(const Duration(seconds: 2));
493 494 495 496 497 498 499

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
500 501
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
502
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
503 504 505
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
506
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
507 508
      );

509
      mockHelper.refreshCompleter.complete(null);
510
      await tester.pump();
511 512 513 514 515 516
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.done,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
517 518 519 520 521

      await tester.pump(const Duration(seconds: 5));
      expect(find.text('-1'), findsNothing);
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
522
        const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
523
      );
Dan Field's avatar
Dan Field committed
524
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
525 526

    testWidgets('builder still called when sliver snapped back more than 90%', (WidgetTester tester) async {
527
      mockHelper.refreshIndicator = const Center(child: Text('-1'));
528 529

      await tester.pumpWidget(
530 531
        CupertinoApp(
          home: CustomScrollView(
532 533
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
534 535
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
536 537 538 539 540 541 542
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

543
      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
544
      await tester.pump();
545 546 547 548 549 550
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
551 552
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
553
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
554
      );
555
      expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
556 557 558 559 560

      // Rebuilds the sliver with a layout extent now.
      await tester.pump();
      // Let it snap back to occupy the indicator's final sliver space only.
      await tester.pump(const Duration(seconds: 2));
561 562 563 564 565 566
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
567 568
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
569
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
570 571 572
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
573
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
574 575
      );

576
      mockHelper.refreshCompleter.complete(null);
577
      await tester.pump();
578 579 580 581 582 583 584

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.done,
        pulledExtent: 60,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
585 586 587 588 589 590 591 592 593 594 595 596

      // Waiting for refresh control to reach approximately 5% of height
      await tester.pump(const Duration(milliseconds: 400));

      expect(
        tester.getRect(find.widgetWithText(Center, '0')).top,
        moreOrLessEquals(3.0, epsilon: 4e-1),
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')).height,
        moreOrLessEquals(3.0, epsilon: 4e-1),
      );
597 598 599 600 601 602
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.inactive,
        pulledExtent: 2.6980688300546443, // ~5% of 60.0
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
603
      expect(find.text('-1'), findsOneWidget);
Dan Field's avatar
Dan Field committed
604
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
605 606 607 608

    testWidgets(
      'retracting sliver during done cannot be pulled to refresh again until fully retracted',
      (WidgetTester tester) async {
609
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
610 611

        await tester.pumpWidget(
612 613
          CupertinoApp(
            home: CustomScrollView(
614
              slivers: <Widget>[
615
                CupertinoSliverRefreshControl(
616 617
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
618 619 620 621 622 623 624
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

625
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0);
626
        await tester.pump();
627
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
628

629
        mockHelper.refreshCompleter.complete(null);
630
        await tester.pump();
631 632 633 634 635 636
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
637 638 639 640

        // Let it start going away but not fully.
        await tester.pump(const Duration(milliseconds: 100));
        // The refresh indicator is still building.
641 642 643 644 645 646 647
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 91.31180913199277,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));

648 649 650 651 652 653 654
        expect(
          tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
          moreOrLessEquals(91.311809131992776),
        );

        // Start another drag by an amount that would have been enough to
        // trigger another refresh if it were in the right state.
655
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0, warnIfMissed: false);
656 657 658 659
        await tester.pump();

        // Instead, it's still in the done state because the sliver never
        // fully retracted.
660 661 662 663 664 665
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 147.3772721631821,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
666 667 668 669 670 671

        // Now let it fully go away.
        await tester.pump(const Duration(seconds: 5));
        expect(find.text('-1'), findsNothing);
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
672
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
673 674 675
        );

        // Start another drag. It's now in drag mode.
676
        await tester.drag(find.text('0'), const Offset(0.0, 40.0), touchSlopY: 0.0);
677
        await tester.pump();
678 679 680 681 682 683
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 40,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
684 685 686
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
687 688 689 690

    testWidgets(
      'sliver held in overscroll when task finishes completes normally',
      (WidgetTester tester) async {
691
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
692 693

        await tester.pumpWidget(
694 695
          CupertinoApp(
            home: CustomScrollView(
696
              slivers: <Widget>[
697
                CupertinoSliverRefreshControl(
698 699
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
700 701 702 703 704 705 706
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

707
        final TestGesture gesture = await tester.startGesture(Offset.zero);
708 709 710
        // Start a refresh.
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
711
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
712 713

        // Complete the task while held down.
714
        mockHelper.refreshCompleter.complete(null);
715
        await tester.pump();
716 717 718 719 720 721 722

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
723 724
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
725
          const Rect.fromLTRB(0.0, 150.0, 800.0, 350.0),
726 727 728 729 730 731 732 733
        );

        await gesture.up();
        await tester.pump();
        await tester.pump(const Duration(seconds: 5));
        expect(find.text('-1'), findsNothing);
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
734
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
735
        );
736 737 738
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
739 740 741 742

    testWidgets(
      'sliver scrolled away when task completes properly removes itself',
      (WidgetTester tester) async {
743 744 745 746 747
        if (testListLength < 4) {
          // This test only makes sense when the list is long enough that
          // the indicator can be scrolled away while refreshing.
          return;
        }
748
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
749 750

        await tester.pumpWidget(
751 752
          CupertinoApp(
            home: CustomScrollView(
753
              slivers: <Widget>[
754
                CupertinoSliverRefreshControl(
755 756
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
757 758 759 760 761 762 763 764 765 766
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        // Start a refresh.
        await tester.drag(find.text('0'), const Offset(0.0, 150.0));
        await tester.pump();
767
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
768 769 770 771 772

        await tester.drag(find.text('0'), const Offset(0.0, -300.0));
        await tester.pump();

        // Refresh indicator still being told to layout the same way.
773 774 775 776 777 778
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
779 780 781

        // Now the sliver is scrolled off screen.
        expect(
782
          tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
783 784 785
          moreOrLessEquals(-175.38461538461536),
        );
        expect(
786
          tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
787 788 789 790
          moreOrLessEquals(-115.38461538461536),
        );

        // Complete the task while scrolled away.
791
        mockHelper.refreshCompleter.complete(null);
792 793 794 795 796 797 798 799 800 801 802 803 804 805 806
        // The sliver is instantly gone since there is no overscroll physics
        // simulation.
        await tester.pump();

        // The next item's position is not disturbed.
        expect(
          tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
          moreOrLessEquals(-115.38461538461536),
        );

        // Scrolling past the first item still results in a new overscroll.
        // The layout extent is gone.
        await tester.drag(find.text('1'), const Offset(0.0, 120.0));
        await tester.pump();

807 808 809 810 811 812
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 4.615384615384642,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
813 814 815 816 817 818 819

        // Snaps away normally.
        await tester.pump();
        await tester.pump(const Duration(seconds: 2));
        expect(find.text('-1'), findsNothing);
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
820
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
821
        );
822 823 824
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
825 826 827 828

    testWidgets(
      "don't do anything unless it can be overscrolled at the start of the list",
      (WidgetTester tester) async {
829
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
830 831

        await tester.pumpWidget(
832 833
          CupertinoApp(
            home: CustomScrollView(
834 835
              slivers: <Widget>[
                buildAListOfStuff(),
836
                CupertinoSliverRefreshControl( // it's in the middle now.
837 838
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
839 840 841 842 843 844 845
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

846 847
        await tester.fling(find.byType(SizedBox).first, const Offset(0.0, 200.0), 2000.0);
        await tester.fling(find.byType(SizedBox).first, const Offset(0.0, -200.0), 3000.0, warnIfMissed: false); // IgnorePointer is enabled while scroll is ballistic.
848

849
        expect(mockHelper.invocations, isEmpty);
850 851 852
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
853 854 855 856

    testWidgets(
      'without an onRefresh, builder is called with arm for one frame then sliver goes away',
      (WidgetTester tester) async {
857
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
858 859

        await tester.pumpWidget(
860 861
          CupertinoApp(
            home: CustomScrollView(
862
              slivers: <Widget>[
863
                CupertinoSliverRefreshControl(
864
                  builder: mockHelper.builder,
865 866 867 868 869 870 871
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

872
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0);
873
        await tester.pump();
874 875 876 877 878 879

        expect(mockHelper.invocations.first, matchesBuilder(
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: 150.0,
          refreshTriggerPullDistance: 100.0, // Default value.
          refreshIndicatorExtent: 60.0, // Default value.
880 881 882
        ));

        await tester.pump(const Duration(milliseconds: 10));
883 884 885

        expect(mockHelper.invocations.last, matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
886
          pulledExtent: moreOrLessEquals(148.6463892921364),
887 888
          refreshTriggerPullDistance: 100.0, // Default value.
          refreshIndicatorExtent: 60.0, // Default value.
889 890 891 892 893 894
        ));

        await tester.pump(const Duration(seconds: 5));
        expect(find.text('-1'), findsNothing);
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
895
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
896
        );
897 898 899
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
900 901 902

    testWidgets('Should not crash when dragged', (WidgetTester tester) async {
      await tester.pumpWidget(
903 904
        CupertinoApp(
          home: CustomScrollView(
905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921
            physics: const BouncingScrollPhysics(),
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                onRefresh: () async => Future<void>.delayed(const Duration(days: 2000)),
              ),
            ],
          ),
        ),
      );

      await tester.dragFrom(const Offset(100, 10), const Offset(0.0, 50.0), touchSlopY: 0);
      await tester.pump();

      await tester.dragFrom(const Offset(100, 10), const Offset(0, 500), touchSlopY: 0);
      await tester.pump();

      expect(tester.takeException(), isNull);
Dan Field's avatar
Dan Field committed
922
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
923 924 925 926

    // Test to make sure the refresh sliver's overscroll isn't eaten by the
    // nav bar sliver https://github.com/flutter/flutter/issues/74516.
    testWidgets(
927 928 929 930 931 932 933 934 935 936 937 938 939 940 941
      'properly displays when the refresh sliver is behind the large title nav bar sliver',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          CupertinoApp(
            home: CustomScrollView(
              slivers: <Widget>[
                const CupertinoSliverNavigationBar(
                  largeTitle: Text('Title'),
                ),
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                ),
                buildAListOfStuff(),
              ],
            ),
942
          ),
943
        );
944

945
        final double initialFirstCellY = tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy;
946

947 948 949
        // Drag down but not enough to trigger the refresh.
        await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0);
        await tester.pump();
950

951 952 953 954 955 956 957
        expect(mockHelper.invocations.first, matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 50,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ));
        expect(mockHelper.invocations, hasLength(1));
958

959 960 961 962 963 964 965
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          initialFirstCellY + 50,
        );
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
966
  }
967

968
  void stateMachineTestGroup() {
969 970
    testWidgets('starts in inactive state', (WidgetTester tester) async {
      await tester.pumpWidget(
971 972
        CupertinoApp(
          home: CustomScrollView(
973
            slivers: <Widget>[
974
              CupertinoSliverRefreshControl(
975
                builder: mockHelper.builder,
976 977 978 979 980 981 982 983
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      expect(
984
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
985 986
        RefreshIndicatorMode.inactive,
      );
Dan Field's avatar
Dan Field committed
987
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
988 989 990

    testWidgets('goes to drag and returns to inactive in a small drag', (WidgetTester tester) async {
      await tester.pumpWidget(
991 992
        CupertinoApp(
          home: CustomScrollView(
993
            slivers: <Widget>[
994
              CupertinoSliverRefreshControl(
995
                builder: mockHelper.builder,
996 997 998 999 1000 1001 1002 1003 1004 1005 1006
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      await tester.drag(find.text('0'), const Offset(0.0, 20.0));
      await tester.pump();

      expect(
1007
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1008 1009 1010 1011 1012 1013
        RefreshIndicatorMode.drag,
      );

      await tester.pump(const Duration(seconds: 2));

      expect(
1014
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1015 1016
        RefreshIndicatorMode.inactive,
      );
Dan Field's avatar
Dan Field committed
1017
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1018 1019 1020

    testWidgets('goes to armed the frame it passes the threshold', (WidgetTester tester) async {
      await tester.pumpWidget(
1021 1022
        CupertinoApp(
          home: CustomScrollView(
1023
            slivers: <Widget>[
1024
              CupertinoSliverRefreshControl(
1025
                builder: mockHelper.builder,
1026 1027 1028 1029 1030 1031 1032 1033
                refreshTriggerPullDistance: 80.0,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

1034
      final TestGesture gesture = await tester.startGesture(Offset.zero);
1035 1036 1037
      await gesture.moveBy(const Offset(0.0, 79.0));
      await tester.pump();
      expect(
1038
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1039 1040 1041 1042 1043 1044
        RefreshIndicatorMode.drag,
      );

      await gesture.moveBy(const Offset(0.0, 3.0)); // Overscrolling, need to move more than 1px.
      await tester.pump();
      expect(
1045
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1046 1047
        RefreshIndicatorMode.armed,
      );
Dan Field's avatar
Dan Field committed
1048
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1049 1050 1051 1052 1053

    testWidgets(
      'goes to refresh the frame it crossed back the refresh threshold',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1054 1055
          CupertinoApp(
            home: CustomScrollView(
1056
              slivers: <Widget>[
1057
                CupertinoSliverRefreshControl(
1058 1059
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1060 1061 1062 1063 1064 1065 1066 1067 1068
                  refreshTriggerPullDistance: 90.0,
                  refreshIndicatorExtent: 50.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1069
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1070 1071 1072
        await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it.
        await tester.pump();
        expect(
1073
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1074 1075 1076 1077 1078 1079
          RefreshIndicatorMode.armed,
        );

        await gesture.moveBy(const Offset(0.0, -80.0)); // Overscrolling, need to move more than -40.
        await tester.pump();
        expect(
1080
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1081 1082 1083
          moreOrLessEquals(49.775111111111116), // Below 50 now.
        );
        expect(
1084
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1085 1086
          RefreshIndicatorMode.refresh,
        );
1087 1088 1089
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1090 1091 1092 1093 1094

    testWidgets(
      'goes to done internally as soon as the task finishes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1095 1096
          CupertinoApp(
            home: CustomScrollView(
1097
              slivers: <Widget>[
1098
                CupertinoSliverRefreshControl(
1099 1100
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1101 1102 1103 1104 1105 1106 1107
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1108
        await tester.drag(find.text('0'), const Offset(0.0, 100.0), touchSlopY: 0.0);
1109 1110
        await tester.pump();
        expect(
1111
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1112 1113 1114 1115 1116 1117 1118
          RefreshIndicatorMode.armed,
        );
        // The sliver scroll offset correction is applied on the next frame.
        await tester.pump();

        await tester.pump(const Duration(seconds: 2));
        expect(
1119
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1120 1121 1122
          RefreshIndicatorMode.refresh,
        );
        expect(
1123
          tester.getRect(find.widgetWithText(SizedBox, '0')),
Dan Field's avatar
Dan Field committed
1124
          const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
1125 1126
        );

1127
        mockHelper.refreshCompleter.complete(null);
1128 1129 1130 1131
        // The task completed between frames. The internal state goes to done
        // right away even though the sliver gets a new offset correction the
        // next frame.
        expect(
1132
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1133 1134
          RefreshIndicatorMode.done,
        );
1135 1136 1137
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1138 1139 1140 1141 1142

    testWidgets(
      'goes back to inactive when retracting back past 10% of arming distance',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1143 1144
          CupertinoApp(
            home: CustomScrollView(
1145
              slivers: <Widget>[
1146
                CupertinoSliverRefreshControl(
1147 1148
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1149 1150 1151 1152 1153 1154 1155
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1156
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1157 1158 1159
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1160
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1161 1162 1163
          RefreshIndicatorMode.armed,
        );

1164
        mockHelper.refreshCompleter.complete(null);
1165
        expect(
1166
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1167 1168 1169 1170 1171 1172 1173 1174
          RefreshIndicatorMode.done,
        );
        await tester.pump();

        // Now back in overscroll mode.
        await gesture.moveBy(const Offset(0.0, -200.0));
        await tester.pump();
        expect(
1175
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1176 1177 1178 1179
          moreOrLessEquals(27.944444444444457),
        );
        // Need to bring it to 100 * 0.1 to reset to inactive.
        expect(
1180
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1181 1182 1183 1184 1185 1186
          RefreshIndicatorMode.done,
        );

        await gesture.moveBy(const Offset(0.0, -35.0));
        await tester.pump();
        expect(
1187
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1188 1189 1190
          moreOrLessEquals(9.313890708161875),
        );
        expect(
1191
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1192 1193
          RefreshIndicatorMode.inactive,
        );
1194 1195 1196
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1197 1198 1199 1200 1201

    testWidgets(
      'goes back to inactive if already scrolled away when task completes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1202 1203
          CupertinoApp(
            home: CustomScrollView(
1204
              slivers: <Widget>[
1205
                CupertinoSliverRefreshControl(
1206 1207
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1208 1209 1210 1211 1212 1213 1214
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1215
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1216 1217 1218
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1219
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1220 1221 1222 1223 1224 1225 1226 1227
          RefreshIndicatorMode.armed,
        );
        await tester.pump(); // Sliver scroll offset correction is applied one frame later.

        await gesture.moveBy(const Offset(0.0, -300.0));
        await tester.pump();
        // The refresh indicator is offscreen now.
        expect(
1228
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1229 1230 1231
          moreOrLessEquals(-145.0332383665717),
        );
        expect(
1232
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1233 1234 1235
          RefreshIndicatorMode.refresh,
        );

1236
        mockHelper.refreshCompleter.complete(null);
1237 1238 1239
        // The sliver layout extent is removed on next frame.
        await tester.pump();
        expect(
1240
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1241 1242 1243 1244
          RefreshIndicatorMode.inactive,
        );
        // Nothing moved.
        expect(
1245
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1246 1247 1248 1249 1250
          moreOrLessEquals(-145.0332383665717),
        );
        await tester.pump(const Duration(seconds: 2));
        // Everything stayed as is.
        expect(
1251
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1252 1253
          moreOrLessEquals(-145.0332383665717),
        );
1254 1255 1256
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1257 1258 1259 1260

    testWidgets(
      "don't have to build any indicators or occupy space during refresh",
      (WidgetTester tester) async {
1261
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
1262 1263

        await tester.pumpWidget(
1264 1265
          CupertinoApp(
            home: CustomScrollView(
1266
              slivers: <Widget>[
1267
                CupertinoSliverRefreshControl(
1268
                  builder: null,
1269
                  onRefresh: mockHelper.refreshTask,
1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280
                  refreshIndicatorExtent: 0.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        await tester.drag(find.text('0'), const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1281
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1282 1283 1284 1285 1286 1287 1288
          RefreshIndicatorMode.armed,
        );

        await tester.pump();
        await tester.pump(const Duration(seconds: 5));
        // In refresh mode but has no UI.
        expect(
1289
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1290 1291 1292 1293
          RefreshIndicatorMode.refresh,
        );
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
1294
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
1295 1296
        );

1297
        mockHelper.refreshCompleter.complete(null);
1298 1299 1300
        await tester.pump();
        // Goes to inactive right away since the sliver is already collapsed.
        expect(
1301
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1302 1303
          RefreshIndicatorMode.inactive,
        );
1304 1305 1306
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1307

1308
    testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async {
1309
      await tester.pumpWidget(
1310 1311
        CupertinoApp(
          home: Builder(
1312 1313 1314 1315 1316 1317 1318
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                10, 100, 10,
              );
            },
1319 1320 1321
          ),
        ),
      );
1322
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0);
1323 1324

      await tester.pumpWidget(
1325 1326
        CupertinoApp(
          home: Builder(
1327 1328 1329 1330 1331 1332 1333
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                26, 100, 10,
              );
            },
1334 1335 1336
          ),
        ),
      );
1337
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 26.0 / 100.0);
1338

1339
      await tester.pumpWidget(
1340 1341
        CupertinoApp(
          home: Builder(
1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                100, 100, 10,
              );
            },
          ),
        ),
      );
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 100.0 / 100.0);
1353
    });
1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373

    testWidgets('indicator should not become larger when overscrolled', (WidgetTester tester) async {
      // test for https://github.com/flutter/flutter/issues/79841
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: Builder(
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.done,
                120, 100, 10,
              );
            },
          ),
        ),
      );

      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).radius, 14.0);
    });
1374
  }
1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389

  group('UI tests long list', uiTestGroup);

  // Test the internal state machine directly to make sure the UI aren't just
  // correct by coincidence.
  group('state machine test long list', stateMachineTestGroup);

  // Retest everything and make sure that it still works when the whole list
  // is smaller than the viewport size.
  testListLength = 2;
  group('UI tests short list', uiTestGroup);

  // Test the internal state machine directly to make sure the UI aren't just
  // correct by coincidence.
  group('state machine test short list', stateMachineTestGroup);
1390 1391 1392 1393 1394 1395

  testWidgets(
    'Does not crash when paintExtent > remainingPaintExtent',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/46871.
      await tester.pumpWidget(
1396 1397
        CupertinoApp(
          home: CustomScrollView(
1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412
            physics: const BouncingScrollPhysics(),
            slivers: <Widget>[
              const CupertinoSliverRefreshControl(),
              SliverList(
                delegate: SliverChildBuilderDelegate(
                  (BuildContext context, int index) => const SizedBox(height: 100),
                  childCount: 20,
                ),
              ),
            ],
          ),
        ),
      );

      // Drag the content down far enough so that
1413
      // geometry.paintExtent > constraints.maxPaintExtent
1414 1415 1416 1417
      await tester.dragFrom(const Offset(10, 10), const Offset(0, 500));
      await tester.pump();

      expect(tester.takeException(), isNull);
1418 1419
    },
  );
1420 1421
}

1422 1423 1424 1425 1426 1427
class FakeBuilder {
  Completer<void> refreshCompleter = Completer<void>.sync();
  final List<MockHelperInvocation> invocations = <MockHelperInvocation>[];

  Widget refreshIndicator = Container();

1428 1429 1430 1431 1432 1433
  Widget builder(
    BuildContext context,
    RefreshIndicatorMode refreshState,
    double pulledExtent,
    double refreshTriggerPullDistance,
    double refreshIndicatorExtent,
1434 1435 1436 1437 1438 1439 1440 1441 1442 1443
  ) {
    if (pulledExtent < 0.0) {
      throw TestFailure('The pulledExtent should never be less than 0.0');
    }
    if (refreshTriggerPullDistance < 0.0) {
      throw TestFailure('The refreshTriggerPullDistance should never be less than 0.0');
    }
    if (refreshIndicatorExtent < 0.0) {
      throw TestFailure('The refreshIndicatorExtent should never be less than 0.0');
    }
1444 1445 1446 1447 1448 1449
    invocations.add(BuilderInvocation(
      refreshState: refreshState,
      pulledExtent: pulledExtent,
      refreshTriggerPullDistance: refreshTriggerPullDistance,
      refreshIndicatorExtent: refreshIndicatorExtent,
    ));
1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470
    return refreshIndicator;
  }

  Future<void> refreshTask() {
    invocations.add(const RefreshTaskInvocation());
    return refreshCompleter.future;
  }
}

abstract class MockHelperInvocation {
  const MockHelperInvocation();
}

@immutable
class RefreshTaskInvocation extends MockHelperInvocation {
  const RefreshTaskInvocation();
}

@immutable
class BuilderInvocation extends MockHelperInvocation {
  const BuilderInvocation({
1471 1472 1473 1474
    required this.refreshState,
    required this.pulledExtent,
    required this.refreshIndicatorExtent,
    required this.refreshTriggerPullDistance,
1475 1476 1477 1478 1479 1480 1481 1482 1483 1484
  });

  final RefreshIndicatorMode refreshState;
  final double pulledExtent;
  final double refreshTriggerPullDistance;
  final double refreshIndicatorExtent;

  @override
  String toString() => '{refreshState: $refreshState, pulledExtent: $pulledExtent, refreshTriggerPullDistance: $refreshTriggerPullDistance, refreshIndicatorExtent: $refreshIndicatorExtent}';
}
1485

1486
Matcher matchesBuilder({
1487 1488 1489 1490
  required RefreshIndicatorMode refreshState,
  required dynamic pulledExtent,
  required dynamic refreshTriggerPullDistance,
  required dynamic refreshIndicatorExtent,
1491 1492 1493 1494 1495 1496
}) {
  return isA<BuilderInvocation>()
    .having((BuilderInvocation invocation) => invocation.refreshState, 'refreshState', refreshState)
    .having((BuilderInvocation invocation) => invocation.pulledExtent, 'pulledExtent', pulledExtent)
    .having((BuilderInvocation invocation) => invocation.refreshTriggerPullDistance, 'refreshTriggerPullDistance', refreshTriggerPullDistance)
    .having((BuilderInvocation invocation) => invocation.refreshIndicatorExtent, 'refreshIndicatorExtent', refreshIndicatorExtent);
1497
}