refresh_test.dart 60.2 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
library;
7 8 9
import 'dart:async';

import 'package:flutter/cupertino.dart';
10
import 'package:flutter/foundation.dart';
11 12 13 14
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
15
  late FakeBuilder mockHelper;
16

17
  setUp(() {
18
    mockHelper = FakeBuilder();
19 20
  });

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

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

51
      expect(mockHelper.invocations, isEmpty);
52 53

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

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

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

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

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

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

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

117
        expect(mockHelper.invocations, isEmpty);
118 119

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

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

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

148 149 150 151 152 153
      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 50,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
154
        ),
155 156 157 158 159 160 161
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(48.07979523362715),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )
        else matchesBuilder(
162 163 164 165
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(48.36801747187993),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
166
        ),
167 168 169 170 171 172 173
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(43.98499220391114),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )
        else matchesBuilder(
174 175 176 177
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(44.63031931875867),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
178
        ),
179 180 181
      ]));
      // The builder isn't called again when the sliver completely goes away.
      expect(mockHelper.invocations, hasLength(3));
182 183

      expect(
184
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
185
        Offset.zero,
186
      );
Dan Field's avatar
Dan Field committed
187
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
188 189 190 191

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

192
      tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
193
        platformCallLog.add(methodCall);
194
        return null;
195 196 197
      });

      await tester.pumpWidget(
198 199
        CupertinoApp(
          home: CustomScrollView(
200
            slivers: <Widget>[
201
              CupertinoSliverRefreshControl(
202 203
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
204 205 206 207 208 209 210
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

211
      final TestGesture gesture = await tester.startGesture(Offset.zero);
212 213 214 215
      await gesture.moveBy(const Offset(0.0, 99.0));
      await tester.pump();
      await gesture.moveBy(const Offset(0.0, -30.0));
      await tester.pump();
216 217 218 219 220 221
      if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
        await gesture.moveBy(const Offset(0.0, 70.0));
      }
      else {
        await gesture.moveBy(const Offset(0.0, 50.0));
      }
222 223
      await tester.pump();

224 225 226 227 228 229
      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 99,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
230
        ),
231 232 233 234 235 236 237
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(97.3552275),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )
        else matchesBuilder(
238 239 240 241
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(86.78169),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
242
        ),
243 244 245 246 247 248 249
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) matchesBuilder(
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: moreOrLessEquals(100.79409877743257),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )
        else matchesBuilder(
250 251 252 253
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: moreOrLessEquals(105.80452021305739),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
254
        ),
255 256 257 258
      ]));
      // The refresh callback is triggered after the frame.
      expect(mockHelper.invocations.last, const RefreshTaskInvocation());
      expect(mockHelper.invocations, hasLength(4));
259 260 261 262 263

      expect(
        platformCallLog.last,
        isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'),
      );
Dan Field's avatar
Dan Field committed
264
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
265 266 267 268 269

    testWidgets(
      'refreshing task keeps the sliver expanded forever until done',
      (WidgetTester tester) async {
        await tester.pumpWidget(
270 271
          CupertinoApp(
            home: CustomScrollView(
272
              slivers: <Widget>[
273
                CupertinoSliverRefreshControl(
274 275
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
276 277 278 279 280 281 282
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

288 289 290 291 292 293
        expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
          matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: 150,
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
294
          ),
295
          equals(const RefreshTaskInvocation()),
296 297 298 299 300 301 302
          if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: moreOrLessEquals(124.87933920045268),
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
          )
          else matchesBuilder(
303 304 305 306
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: moreOrLessEquals(127.10396988577114),
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
307
          ),
308
        ]));
309 310 311

        // Reaches refresh state and sliver's at 60.0 in height after a while.
        await tester.pump(const Duration(seconds: 1));
312 313 314 315 316 317 318

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.refresh,
          pulledExtent: 60,
          refreshIndicatorExtent: 60, // Default value.
          refreshTriggerPullDistance: 100, // Default value.
        )));
319 320 321 322

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

327
        mockHelper.refreshCompleter.complete(null);
328 329
        await tester.pump();

330 331 332 333 334 335 336
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshIndicatorExtent: 60, // Default value.
          refreshTriggerPullDistance: 100, // Default value.
        )));
        expect(mockHelper.invocations, hasLength(5));
337 338 339
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
340

341 342 343 344 345
    testWidgets(
      'refreshing task keeps the sliver expanded forever until completes with error',
      (WidgetTester tester) async {
        final FlutterError error = FlutterError('Oops');
        double errorCount = 0;
346
        final TargetPlatform? platform = debugDefaultTargetPlatformOverride; // Will not be correct within the zone.
347

348
        runZonedGuarded(
349
          () async {
350
            mockHelper.refreshCompleter = Completer<void>.sync();
351
            await tester.pumpWidget(
352 353
              CupertinoApp(
                home: CustomScrollView(
354 355
                  slivers: <Widget>[
                    CupertinoSliverRefreshControl(
356 357
                      builder: mockHelper.builder,
                      onRefresh: mockHelper.refreshTask,
358 359 360 361
                    ),
                    buildAListOfStuff(),
                  ],
                ),
362
              ),
363 364 365 366 367 368 369
            );

            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));

370 371 372 373 374 375
            expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
             matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: 150,
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
376
              ),
377
              equals(const RefreshTaskInvocation()),
378 379 380 381 382 383 384
              if (platform == TargetPlatform.macOS) matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: moreOrLessEquals(124.87933920045268),
                refreshTriggerPullDistance: 100, // Default value.
                refreshIndicatorExtent: 60, // Default value.
              )
              else matchesBuilder(
385 386 387 388
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: moreOrLessEquals(127.10396988577114),
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
389
              ),
390
            ]));
391

392 393
            // Reaches refresh state and sliver's at 60.0 in height after a while.
            await tester.pump(const Duration(seconds: 1));
394 395 396 397 398 399
            expect(mockHelper.invocations, contains(matchesBuilder(
              refreshState: RefreshIndicatorMode.refresh,
              pulledExtent: 60,
              refreshIndicatorExtent: 60, // Default value.
              refreshTriggerPullDistance: 100, // Default value.
            )));
400

401 402 403
            // Stays in that state forever until future completes.
            await tester.pump(const Duration(seconds: 1000));
            expect(
404
              tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
405 406
              const Offset(0.0, 60.0),
            );
407

408
            mockHelper.refreshCompleter.completeError(error);
409
            await tester.pump();
410

411 412 413 414 415 416 417
            expect(mockHelper.invocations, contains(matchesBuilder(
              refreshState: RefreshIndicatorMode.done,
              pulledExtent: 60,
              refreshIndicatorExtent: 60, // Default value.
              refreshTriggerPullDistance: 100, // Default value.
            )));
            expect(mockHelper.invocations, hasLength(5));
418
          },
419
          (Object e, StackTrace stack) {
420 421 422 423 424
            expect(e, error);
            expect(errorCount, 0);
            errorCount++;
          },
        );
425 426 427
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
428

429
    testWidgets('expanded refreshing sliver scrolls normally', (WidgetTester tester) async {
430
      mockHelper.refreshIndicator = const Center(child: Text('-1'));
431 432

      await tester.pumpWidget(
433 434
        CupertinoApp(
          home: CustomScrollView(
435
            slivers: <Widget>[
436
              CupertinoSliverRefreshControl(
437 438
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
439 440 441 442 443 444 445
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

446
      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
447 448
      await tester.pump();

449 450 451 452 453 454
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
455 456 457 458

      // 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
459
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
460 461
      );

462 463 464 465 466 467
      if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
        await tester.drag(find.text('0'), const Offset(0.0, -600.0), touchSlopY: 0, warnIfMissed: false); // hits the list
      }
      else {
        await tester.drag(find.text('0'), const Offset(0.0, -300.0), touchSlopY: 0, warnIfMissed: false); // hits the list
      }
468 469 470
      await tester.pump();

      // Refresh indicator still being told to layout the same way.
471 472 473 474 475 476
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
477 478

      // Now the sliver is scrolled off screen.
479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506
      if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
        expect(
          tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
          moreOrLessEquals(-38.625),
        );
        expect(
          tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
          moreOrLessEquals(21.375),
        );
        expect(
          tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
          moreOrLessEquals(21.375),
        );
      }
      else {
        expect(
          tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
          moreOrLessEquals(-175.38461538461536),
        );
        expect(
          tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
          moreOrLessEquals(-115.38461538461536),
        );
        expect(
          tester.getTopLeft(find.widgetWithText(Center, '0')).dy,
          moreOrLessEquals(-115.38461538461536),
        );
      }
507 508 509

      // Scroll the top of the refresh indicator back to overscroll, it will
      // snap to the size of the refresh indicator and stay there.
510
      await tester.drag(find.text('1'), const Offset(0.0, 200.0), warnIfMissed: false); // hits the list
511 512 513 514
      await tester.pump();
      await tester.pump(const Duration(seconds: 2));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
515
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
516 517 518
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
519
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
520
      );
Dan Field's avatar
Dan Field committed
521
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
522 523

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

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

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

      // 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));
558 559 560 561 562 563 564

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
565 566
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
567
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
568 569 570
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
571
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
572 573
      );

574
      mockHelper.refreshCompleter.complete(null);
575
      await tester.pump();
576 577 578 579 580 581
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.done,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
582 583 584 585 586

      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
587
        const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
588
      );
Dan Field's avatar
Dan Field committed
589
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
590 591

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

      await tester.pumpWidget(
595 596
        CupertinoApp(
          home: CustomScrollView(
597 598
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
599 600
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
601 602 603 604 605 606 607
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

608
      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
609
      await tester.pump();
610 611 612 613 614 615
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
616 617
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
618
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
619
      );
620
      expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
621 622 623 624 625

      // 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));
626 627 628 629 630 631
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
632 633
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
Dan Field's avatar
Dan Field committed
634
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
635 636 637
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
638
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
639 640
      );

641
      mockHelper.refreshCompleter.complete(null);
642
      await tester.pump();
643 644 645 646 647 648 649

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.done,
        pulledExtent: 60,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
650 651 652 653

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

654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685
      if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
        expect(
          tester.getRect(find.widgetWithText(Center, '0')).top,
          moreOrLessEquals(3.9543032206542765, epsilon: 4e-1),
        );
        expect(
          tester.getRect(find.widgetWithText(Center, '-1')).height,
          moreOrLessEquals(3.9543032206542765, epsilon: 4e-1),
        );
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.inactive,
          pulledExtent: 3.9543032206542765, // ~5% of 60.0
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
      }
      else {
        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),
        );
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.inactive,
          pulledExtent: 2.6980688300546443, // ~5% of 60.0
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
      }
686
      expect(find.text('-1'), findsOneWidget);
Dan Field's avatar
Dan Field committed
687
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
688 689 690 691

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

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

708
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0);
709
        await tester.pump();
710
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
711

712
        mockHelper.refreshCompleter.complete(null);
713
        await tester.pump();
714 715 716 717 718 719
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
720 721 722 723

        // Let it start going away but not fully.
        await tester.pump(const Duration(milliseconds: 100));
        // The refresh indicator is still building.
724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(mockHelper.invocations, contains(matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: 90.13497854600749,
            refreshTriggerPullDistance: 100,  // default value.
            refreshIndicatorExtent: 60,  // default value.
          )));
          expect(
            tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
            moreOrLessEquals(90.13497854600749),
          );
        }
        else {
          expect(mockHelper.invocations, contains(matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: 91.31180913199277,
            refreshTriggerPullDistance: 100,  // default value.
            refreshIndicatorExtent: 60,  // default value.
          )));
          expect(
            tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
            moreOrLessEquals(91.311809131992776),
          );
        }
748 749 750

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

        // Instead, it's still in the done state because the sliver never
        // fully retracted.
756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(mockHelper.invocations, contains(matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: 97.71721346565732,
            refreshTriggerPullDistance: 100,  // default value.
            refreshIndicatorExtent: 60,  // default value.
          )));
        }
        else {
          expect(mockHelper.invocations, contains(matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: 147.3772721631821,
            refreshTriggerPullDistance: 100,  // default value.
            refreshIndicatorExtent: 60,  // default value.
          )));
        }
772 773 774 775 776 777

        // 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
778
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
779 780 781
        );

        // Start another drag. It's now in drag mode.
782
        await tester.drag(find.text('0'), const Offset(0.0, 40.0), touchSlopY: 0.0);
783
        await tester.pump();
784 785 786 787 788 789
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 40,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
790 791 792
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
793 794 795 796

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

        await tester.pumpWidget(
800 801
          CupertinoApp(
            home: CustomScrollView(
802
              slivers: <Widget>[
803
                CupertinoSliverRefreshControl(
804 805
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
806 807 808 809 810 811 812
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

813
        final TestGesture gesture = await tester.startGesture(Offset.zero);
814 815 816
        // Start a refresh.
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
817
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
818 819

        // Complete the task while held down.
820
        mockHelper.refreshCompleter.complete(null);
821
        await tester.pump();
822 823 824 825 826 827 828

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
829 830
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
831
          const Rect.fromLTRB(0.0, 150.0, 800.0, 350.0),
832 833 834 835 836 837 838 839
        );

        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
840
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
841
        );
842 843 844
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
845 846 847 848

    testWidgets(
      'sliver scrolled away when task completes properly removes itself',
      (WidgetTester tester) async {
849 850 851 852 853
        if (testListLength < 4) {
          // This test only makes sense when the list is long enough that
          // the indicator can be scrolled away while refreshing.
          return;
        }
854
        mockHelper.refreshIndicator = const Center(child: Text('-1'));
855 856

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

        // Start a refresh.
        await tester.drag(find.text('0'), const Offset(0.0, 150.0));
        await tester.pump();
873
        expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
874 875 876 877 878

        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.
879 880 881 882 883 884
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
885 886 887

        // Now the sliver is scrolled off screen.
        expect(
888
          tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
889 890 891
          moreOrLessEquals(-175.38461538461536),
        );
        expect(
892
          tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy,
893 894 895 896
          moreOrLessEquals(-115.38461538461536),
        );

        // Complete the task while scrolled away.
897
        mockHelper.refreshCompleter.complete(null);
898 899 900 901 902 903 904 905 906 907 908 909 910 911 912
        // 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();

913 914 915 916 917 918
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 4.615384615384642,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
919 920 921 922 923 924 925

        // 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
926
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
927
        );
928 929 930
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
931 932 933 934

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

        await tester.pumpWidget(
938 939
          CupertinoApp(
            home: CustomScrollView(
940 941
              slivers: <Widget>[
                buildAListOfStuff(),
942
                CupertinoSliverRefreshControl( // it's in the middle now.
943 944
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
945 946 947 948 949 950 951
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

952 953
        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.
954

955
        expect(mockHelper.invocations, isEmpty);
956 957 958
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
959 960 961 962

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

        await tester.pumpWidget(
966 967
          CupertinoApp(
            home: CustomScrollView(
968
              slivers: <Widget>[
969
                CupertinoSliverRefreshControl(
970
                  builder: mockHelper.builder,
971 972 973 974 975 976 977
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

978
        await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0);
979
        await tester.pump();
980 981 982 983 984 985

        expect(mockHelper.invocations.first, matchesBuilder(
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: 150.0,
          refreshTriggerPullDistance: 100.0, // Default value.
          refreshIndicatorExtent: 60.0, // Default value.
986 987 988
        ));

        await tester.pump(const Duration(milliseconds: 10));
989

990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(mockHelper.invocations.last, matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: moreOrLessEquals(148.36088180097366),
            refreshTriggerPullDistance: 100.0, // Default value.
            refreshIndicatorExtent: 60.0, // Default value.
          ));
        }
        else {
          expect(mockHelper.invocations.last, matchesBuilder(
            refreshState: RefreshIndicatorMode.done,
            pulledExtent: moreOrLessEquals(148.6463892921364),
            refreshTriggerPullDistance: 100.0, // Default value.
            refreshIndicatorExtent: 60.0, // Default value.
          ));
        }
1006 1007 1008 1009 1010

        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
1011
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
1012
        );
1013 1014 1015
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1016 1017 1018

    testWidgets('Should not crash when dragged', (WidgetTester tester) async {
      await tester.pumpWidget(
1019 1020
        CupertinoApp(
          home: CustomScrollView(
1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037
            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
1038
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1039 1040 1041 1042

    // 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(
1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057
      '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(),
              ],
            ),
1058
          ),
1059
        );
1060

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

1063 1064 1065
        // 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();
1066

1067 1068 1069 1070 1071 1072 1073
        expect(mockHelper.invocations.first, matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 50,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ));
        expect(mockHelper.invocations, hasLength(1));
1074

1075 1076 1077 1078 1079 1080 1081
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          initialFirstCellY + 50,
        );
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1082
  }
1083

1084
  void stateMachineTestGroup() {
1085 1086
    testWidgets('starts in inactive state', (WidgetTester tester) async {
      await tester.pumpWidget(
1087 1088
        CupertinoApp(
          home: CustomScrollView(
1089
            slivers: <Widget>[
1090
              CupertinoSliverRefreshControl(
1091
                builder: mockHelper.builder,
1092 1093 1094 1095 1096 1097 1098 1099
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      expect(
1100
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1101 1102
        RefreshIndicatorMode.inactive,
      );
Dan Field's avatar
Dan Field committed
1103
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1104 1105 1106

    testWidgets('goes to drag and returns to inactive in a small drag', (WidgetTester tester) async {
      await tester.pumpWidget(
1107 1108
        CupertinoApp(
          home: CustomScrollView(
1109
            slivers: <Widget>[
1110
              CupertinoSliverRefreshControl(
1111
                builder: mockHelper.builder,
1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

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

      expect(
1123
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1124 1125 1126 1127 1128 1129
        RefreshIndicatorMode.drag,
      );

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

      expect(
1130
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1131 1132
        RefreshIndicatorMode.inactive,
      );
Dan Field's avatar
Dan Field committed
1133
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1134 1135 1136

    testWidgets('goes to armed the frame it passes the threshold', (WidgetTester tester) async {
      await tester.pumpWidget(
1137 1138
        CupertinoApp(
          home: CustomScrollView(
1139
            slivers: <Widget>[
1140
              CupertinoSliverRefreshControl(
1141
                builder: mockHelper.builder,
1142 1143 1144 1145 1146 1147 1148 1149
                refreshTriggerPullDistance: 80.0,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

1150
      final TestGesture gesture = await tester.startGesture(Offset.zero);
1151 1152 1153
      await gesture.moveBy(const Offset(0.0, 79.0));
      await tester.pump();
      expect(
1154
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1155 1156
        RefreshIndicatorMode.drag,
      );
1157 1158 1159 1160 1161 1162
      if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
        await gesture.moveBy(const Offset(0.0, 20.0)); // Overscrolling, need to move more than 1px.
      }
      else {
        await gesture.moveBy(const Offset(0.0, 3.0)); // Overscrolling, need to move more than 1px.
      }
1163 1164
      await tester.pump();
      expect(
1165
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1166 1167
        RefreshIndicatorMode.armed,
      );
Dan Field's avatar
Dan Field committed
1168
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
1169 1170 1171 1172 1173

    testWidgets(
      'goes to refresh the frame it crossed back the refresh threshold',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1174 1175
          CupertinoApp(
            home: CustomScrollView(
1176
              slivers: <Widget>[
1177
                CupertinoSliverRefreshControl(
1178 1179
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1180 1181 1182 1183 1184 1185 1186 1187 1188
                  refreshTriggerPullDistance: 90.0,
                  refreshIndicatorExtent: 50.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1189
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1190 1191 1192
        await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it.
        await tester.pump();
        expect(
1193
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1194 1195 1196
          RefreshIndicatorMode.armed,
        );

1197 1198 1199 1200 1201 1202
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          await gesture.moveBy(const Offset(0.0, -310.0)); // Overscrolling, need to move more than -40.
        }
        else {
          await gesture.moveBy(const Offset(0.0, -80.0)); // Overscrolling, need to move more than -40.
        }
1203
        await tester.pump();
1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(49.469222222222214), // Below 50 now.
          );
        }
        else {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(49.775111111111116), // Below 50 now.
          );
        }
1216
        expect(
1217
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1218 1219
          RefreshIndicatorMode.refresh,
        );
1220 1221 1222
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1223 1224 1225 1226 1227

    testWidgets(
      'goes to done internally as soon as the task finishes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1228 1229
          CupertinoApp(
            home: CustomScrollView(
1230
              slivers: <Widget>[
1231
                CupertinoSliverRefreshControl(
1232 1233
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1234 1235 1236 1237 1238 1239 1240
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1241
        await tester.drag(find.text('0'), const Offset(0.0, 100.0), touchSlopY: 0.0);
1242 1243
        await tester.pump();
        expect(
1244
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1245 1246 1247 1248 1249 1250 1251
          RefreshIndicatorMode.armed,
        );
        // The sliver scroll offset correction is applied on the next frame.
        await tester.pump();

        await tester.pump(const Duration(seconds: 2));
        expect(
1252
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1253 1254 1255
          RefreshIndicatorMode.refresh,
        );
        expect(
1256
          tester.getRect(find.widgetWithText(SizedBox, '0')),
Dan Field's avatar
Dan Field committed
1257
          const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
1258 1259
        );

1260
        mockHelper.refreshCompleter.complete(null);
1261 1262 1263 1264
        // 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(
1265
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1266 1267
          RefreshIndicatorMode.done,
        );
1268 1269 1270
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1271 1272 1273 1274 1275

    testWidgets(
      'goes back to inactive when retracting back past 10% of arming distance',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1276 1277
          CupertinoApp(
            home: CustomScrollView(
1278
              slivers: <Widget>[
1279
                CupertinoSliverRefreshControl(
1280 1281
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1282 1283 1284 1285 1286 1287 1288
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1289
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1290 1291 1292
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1293
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1294 1295 1296
          RefreshIndicatorMode.armed,
        );

1297
        mockHelper.refreshCompleter.complete(null);
1298
        expect(
1299
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1300 1301 1302 1303 1304
          RefreshIndicatorMode.done,
        );
        await tester.pump();

        // Now back in overscroll mode.
1305 1306 1307 1308 1309 1310
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          await gesture.moveBy(const Offset(0.0, -590.0));
        }
        else {
          await gesture.moveBy(const Offset(0.0, -200.0));
        }
1311
        await tester.pump();
1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(25.916444444444423),
          );
        }
        else {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(27.944444444444457),
          );
        }
1324 1325
        // Need to bring it to 100 * 0.1 to reset to inactive.
        expect(
1326
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1327 1328 1329
          RefreshIndicatorMode.done,
        );

1330 1331 1332 1333 1334 1335
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          await gesture.moveBy(const Offset(0.0, -160.0));
        }
        else {
          await gesture.moveBy(const Offset(0.0, -35.0));
        }
1336
        await tester.pump();
1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(9.15133037440173),
          );
        }
        else {
          expect(
            tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
            moreOrLessEquals(9.313890708161875),
          );
        }
1349
        expect(
1350
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1351 1352
          RefreshIndicatorMode.inactive,
        );
1353 1354 1355
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1356 1357 1358 1359 1360

    testWidgets(
      'goes back to inactive if already scrolled away when task completes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
1361 1362
          CupertinoApp(
            home: CustomScrollView(
1363
              slivers: <Widget>[
1364
                CupertinoSliverRefreshControl(
1365 1366
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
1367 1368 1369 1370 1371 1372 1373
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

1374
        final TestGesture gesture = await tester.startGesture(Offset.zero);
1375 1376 1377
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1378
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1379 1380 1381 1382
          RefreshIndicatorMode.armed,
        );
        await tester.pump(); // Sliver scroll offset correction is applied one frame later.

1383 1384 1385 1386 1387 1388 1389 1390
        double indicatorDestinationPosition = -145.0332383665717;
        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
          await gesture.moveBy(const Offset(0.0, -600.0));
          indicatorDestinationPosition = -164.33475946989466;
        }
        else {
          await gesture.moveBy(const Offset(0.0, -300.0));
        }
1391 1392 1393
        await tester.pump();
        // The refresh indicator is offscreen now.
        expect(
1394
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1395
          moreOrLessEquals(indicatorDestinationPosition),
1396 1397
        );
        expect(
1398
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1399 1400 1401
          RefreshIndicatorMode.refresh,
        );

1402
        mockHelper.refreshCompleter.complete(null);
1403 1404 1405
        // The sliver layout extent is removed on next frame.
        await tester.pump();
        expect(
1406
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1407 1408 1409 1410
          RefreshIndicatorMode.inactive,
        );
        // Nothing moved.
        expect(
1411
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1412
          moreOrLessEquals(indicatorDestinationPosition),
1413 1414 1415 1416
        );
        await tester.pump(const Duration(seconds: 2));
        // Everything stayed as is.
        expect(
1417
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
1418
          moreOrLessEquals(indicatorDestinationPosition),
1419
        );
1420 1421 1422
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1423 1424 1425 1426

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

        await tester.pumpWidget(
1430 1431
          CupertinoApp(
            home: CustomScrollView(
1432
              slivers: <Widget>[
1433
                CupertinoSliverRefreshControl(
1434
                  builder: null,
1435
                  onRefresh: mockHelper.refreshTask,
1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446
                  refreshIndicatorExtent: 0.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        await tester.drag(find.text('0'), const Offset(0.0, 150.0));
        await tester.pump();
        expect(
1447
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
1448 1449 1450 1451 1452 1453 1454
          RefreshIndicatorMode.armed,
        );

        await tester.pump();
        await tester.pump(const Duration(seconds: 5));
        // In refresh mode but has no UI.
        expect(
1455
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1456 1457 1458 1459
          RefreshIndicatorMode.refresh,
        );
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
Dan Field's avatar
Dan Field committed
1460
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
1461 1462
        );

1463
        mockHelper.refreshCompleter.complete(null);
1464 1465 1466
        await tester.pump();
        // Goes to inactive right away since the sliver is already collapsed.
        expect(
1467
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
1468 1469
          RefreshIndicatorMode.inactive,
        );
1470 1471 1472
      },
      variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
    );
1473

1474
    testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async {
1475
      await tester.pumpWidget(
1476 1477
        CupertinoApp(
          home: Builder(
1478 1479 1480 1481 1482 1483 1484
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                10, 100, 10,
              );
            },
1485 1486 1487
          ),
        ),
      );
1488
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0);
1489 1490

      await tester.pumpWidget(
1491 1492
        CupertinoApp(
          home: Builder(
1493 1494 1495 1496 1497 1498 1499
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                26, 100, 10,
              );
            },
1500 1501 1502
          ),
        ),
      );
1503
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 26.0 / 100.0);
1504

1505
      await tester.pumpWidget(
1506 1507
        CupertinoApp(
          home: Builder(
1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                100, 100, 10,
              );
            },
          ),
        ),
      );
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 100.0 / 100.0);
1519
    });
1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539

    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);
    });
1540
  }
1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555

  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);
1556 1557 1558 1559 1560 1561

  testWidgets(
    'Does not crash when paintExtent > remainingPaintExtent',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/46871.
      await tester.pumpWidget(
1562 1563
        CupertinoApp(
          home: CustomScrollView(
1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578
            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
1579
      // geometry.paintExtent > constraints.maxPaintExtent
1580 1581 1582 1583
      await tester.dragFrom(const Offset(10, 10), const Offset(0, 500));
      await tester.pump();

      expect(tester.takeException(), isNull);
1584 1585
    },
  );
1586 1587
}

1588 1589 1590 1591 1592 1593
class FakeBuilder {
  Completer<void> refreshCompleter = Completer<void>.sync();
  final List<MockHelperInvocation> invocations = <MockHelperInvocation>[];

  Widget refreshIndicator = Container();

1594 1595 1596 1597 1598 1599
  Widget builder(
    BuildContext context,
    RefreshIndicatorMode refreshState,
    double pulledExtent,
    double refreshTriggerPullDistance,
    double refreshIndicatorExtent,
1600 1601 1602 1603 1604 1605 1606 1607 1608 1609
  ) {
    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');
    }
1610 1611 1612 1613 1614 1615
    invocations.add(BuilderInvocation(
      refreshState: refreshState,
      pulledExtent: pulledExtent,
      refreshTriggerPullDistance: refreshTriggerPullDistance,
      refreshIndicatorExtent: refreshIndicatorExtent,
    ));
1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636
    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({
1637 1638 1639 1640
    required this.refreshState,
    required this.pulledExtent,
    required this.refreshIndicatorExtent,
    required this.refreshTriggerPullDistance,
1641 1642 1643 1644 1645 1646 1647 1648 1649 1650
  });

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

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

1652
Matcher matchesBuilder({
1653 1654 1655 1656
  required RefreshIndicatorMode refreshState,
  required dynamic pulledExtent,
  required dynamic refreshTriggerPullDistance,
  required dynamic refreshIndicatorExtent,
1657 1658 1659 1660 1661 1662
}) {
  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);
1663
}