// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@TestOn('!chrome')
import 'dart:async';

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

void main() {
  late FakeBuilder mockHelper;

  setUp(() {
    mockHelper = FakeBuilder();
  });

  int testListLength = 10;
  SliverList buildAListOfStuff() {
    return SliverList(
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
          return SizedBox(
            height: 200.0,
            child: Center(child: Text(index.toString())),
          );
        },
        childCount: testListLength,
      ),
    );
  }

  void uiTestGroup() {
    testWidgets("doesn't invoke anything without user interaction", (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      expect(mockHelper.invocations, isEmpty);

      expect(
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
        Offset.zero,
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets('calls the indicator builder when starting to overscroll', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

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

      // The function is referenced once while passing into CupertinoSliverRefreshControl
      // and is called.
      expect(mockHelper.invocations.first, matchesBuilder(
        refreshState: RefreshIndicatorMode.drag,
        pulledExtent: 50,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      ));
      expect(mockHelper.invocations, hasLength(1));

      expect(
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
        const Offset(0.0, 50.0),
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      "don't call the builder if overscroll doesn't move slivers like on Android",
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

        expect(mockHelper.invocations, isEmpty);

        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
          Offset.zero,
        );
    }, variant: TargetPlatformVariant.only(TargetPlatform.android));

    testWidgets('let the builder update as canceled drag scrolls away', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      // 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();
      await tester.pump(const Duration(milliseconds: 20));
      await tester.pump(const Duration(milliseconds: 20));
      await tester.pump(const Duration(seconds: 3));

      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 50,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(48.36801747187993),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(44.63031931875867),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
      ]));
      // The builder isn't called again when the sliver completely goes away.
      expect(mockHelper.invocations, hasLength(3));

      expect(
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')),
        Offset.zero,
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

      SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
        platformCallLog.add(methodCall);
      });

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      final TestGesture gesture = await tester.startGesture(Offset.zero);
      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();

      expect(mockHelper.invocations, containsAllInOrder(<void>[
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 99,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
        matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: moreOrLessEquals(86.78169),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
        matchesBuilder(
          refreshState: RefreshIndicatorMode.armed,
          pulledExtent: moreOrLessEquals(105.80452021305739),
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        ),
      ]));
      // The refresh callback is triggered after the frame.
      expect(mockHelper.invocations.last, const RefreshTaskInvocation());
      expect(mockHelper.invocations, hasLength(4));

      expect(
        platformCallLog.last,
        isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'),
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      'refreshing task keeps the sliver expanded forever until done',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

        expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
          matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: 150,
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
          ),
          equals(const RefreshTaskInvocation()),
          matchesBuilder(
            refreshState: RefreshIndicatorMode.armed,
            pulledExtent: moreOrLessEquals(127.10396988577114),
            refreshTriggerPullDistance: 100, // Default value.
            refreshIndicatorExtent: 60, // Default value.
          ),
        ]));

        // Reaches refresh state and sliver's at 60.0 in height after a while.
        await tester.pump(const Duration(seconds: 1));

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

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

        mockHelper.refreshCompleter.complete(null);
        await tester.pump();

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshIndicatorExtent: 60, // Default value.
          refreshTriggerPullDistance: 100, // Default value.
        )));
        expect(mockHelper.invocations, hasLength(5));
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        runZonedGuarded(
          () async {
            mockHelper.refreshCompleter = Completer<void>.sync();
            await tester.pumpWidget(
              Directionality(
                textDirection: TextDirection.ltr,
                child: CustomScrollView(
                  slivers: <Widget>[
                    CupertinoSliverRefreshControl(
                      builder: mockHelper.builder,
                      onRefresh: mockHelper.refreshTask,
                    ),
                    buildAListOfStuff(),
                  ],
                ),
              ),
            );

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

            expect(mockHelper.invocations, containsAllInOrder(<Matcher>[
             matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: 150,
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
              ),
              equals(const RefreshTaskInvocation()),
              matchesBuilder(
                refreshState: RefreshIndicatorMode.armed,
                pulledExtent: moreOrLessEquals(127.10396988577114),
                refreshIndicatorExtent: 60, // Default value.
                refreshTriggerPullDistance: 100, // Default value.
              ),
            ]));

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

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

            mockHelper.refreshCompleter.completeError(error);
            await tester.pump();

            expect(mockHelper.invocations, contains(matchesBuilder(
              refreshState: RefreshIndicatorMode.done,
              pulledExtent: 60,
              refreshIndicatorExtent: 60, // Default value.
              refreshTriggerPullDistance: 100, // Default value.
            )));
            expect(mockHelper.invocations, hasLength(5));
          },
          (Object e, StackTrace stack) {
            expect(e, error);
            expect(errorCount, 0);
            errorCount++;
          },
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

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

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));

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

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

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

      // Now the sliver is scrolled off screen.
      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),
      );

      // Scroll the top of the refresh indicator back to overscroll, it will
      // snap to the size of the refresh indicator and stay there.
      await tester.drag(find.text('1'), const Offset(0.0, 200.0), warnIfMissed: false); // hits the list
      await tester.pump();
      await tester.pump(const Duration(seconds: 2));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
      await tester.pump();
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
      );
      expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));

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

      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
      );

      mockHelper.refreshCompleter.complete(null);
      await tester.pump();
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.done,
        pulledExtent: 60,
        refreshIndicatorExtent: 60, // Default value.
        refreshTriggerPullDistance: 100, // Default value.
      )));

      await tester.pump(const Duration(seconds: 5));
      expect(find.text('-1'), findsNothing);
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
                onRefresh: mockHelper.refreshTask,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0);
      await tester.pump();
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.armed,
        pulledExtent: 150,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0),
      );
      expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));

      // 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));
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.refresh,
        pulledExtent: 60,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
      expect(
        tester.getRect(find.widgetWithText(Center, '-1')),
        const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0),
      );
      expect(
        tester.getRect(find.widgetWithText(Center, '0')),
        const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
      );

      mockHelper.refreshCompleter.complete(null);
      await tester.pump();

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

      // 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),
      );
      expect(mockHelper.invocations, contains(matchesBuilder(
        refreshState: RefreshIndicatorMode.inactive,
        pulledExtent: 2.6980688300546443, // ~5% of 60.0
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      )));
      expect(find.text('-1'), findsOneWidget);
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

        mockHelper.refreshCompleter.complete(null);
        await tester.pump();
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));

        // Let it start going away but not fully.
        await tester.pump(const Duration(milliseconds: 100));
        // The refresh indicator is still building.
        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),
        );

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

        // Instead, it's still in the done state because the sliver never
        // fully retracted.
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 147.3772721631821,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));

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

        // Start another drag. It's now in drag mode.
        await tester.drag(find.text('0'), const Offset(0.0, 40.0), touchSlopY: 0.0);
        await tester.pump();
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.drag,
          pulledExtent: 40,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

        // Complete the task while held down.
        mockHelper.refreshCompleter.complete(null);
        await tester.pump();

        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 150.0, // Still overscrolled here.
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
          const Rect.fromLTRB(0.0, 150.0, 800.0, 350.0),
        );

        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')),
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

        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.
        expect(mockHelper.invocations, contains(matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: 60,
          refreshTriggerPullDistance: 100,  // default value.
          refreshIndicatorExtent: 60,  // default value.
        )));

        // Now the sliver is scrolled off screen.
        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),
        );

        // Complete the task while scrolled away.
        mockHelper.refreshCompleter.complete(null);
        // 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();

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

        // 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')),
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                buildAListOfStuff(),
                CupertinoSliverRefreshControl( // it's in the middle now.
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        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.

        expect(mockHelper.invocations, isEmpty);
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

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

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

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

        expect(mockHelper.invocations.last, matchesBuilder(
          refreshState: RefreshIndicatorMode.done,
          pulledExtent: moreOrLessEquals(148.6463892921364,),
          refreshTriggerPullDistance: 100.0, // Default value.
          refreshIndicatorExtent: 60.0, // Default value.
        ));

        await tester.pump(const Duration(seconds: 5));
        expect(find.text('-1'), findsNothing);
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets('Should not crash when dragged', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            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);
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    // 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(
        '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(),
            ],
          ),
        ),
      );

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

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

      expect(mockHelper.invocations.first, matchesBuilder(
        refreshState: RefreshIndicatorMode.drag,
        pulledExtent: 50,
        refreshTriggerPullDistance: 100,  // default value.
        refreshIndicatorExtent: 60,  // default value.
      ));
      expect(mockHelper.invocations, hasLength(1));

      expect(
        tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
        initialFirstCellY + 50
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
  }

  void stateMachineTestGroup() {
    testWidgets('starts in inactive state', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      expect(
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
        RefreshIndicatorMode.inactive,
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets('goes to drag and returns to inactive in a small drag', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

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

      expect(
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
        RefreshIndicatorMode.drag,
      );

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

      expect(
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
        RefreshIndicatorMode.inactive,
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets('goes to armed the frame it passes the threshold', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            slivers: <Widget>[
              CupertinoSliverRefreshControl(
                builder: mockHelper.builder,
                refreshTriggerPullDistance: 80.0,
              ),
              buildAListOfStuff(),
            ],
          ),
        ),
      );

      final TestGesture gesture = await tester.startGesture(Offset.zero);
      await gesture.moveBy(const Offset(0.0, 79.0));
      await tester.pump();
      expect(
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
        RefreshIndicatorMode.drag,
      );

      await gesture.moveBy(const Offset(0.0, 3.0)); // Overscrolling, need to move more than 1px.
      await tester.pump();
      expect(
        CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
        RefreshIndicatorMode.armed,
      );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      'goes to refresh the frame it crossed back the refresh threshold',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                  refreshTriggerPullDistance: 90.0,
                  refreshIndicatorExtent: 50.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        final TestGesture gesture = await tester.startGesture(Offset.zero);
        await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it.
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.armed,
        );

        await gesture.moveBy(const Offset(0.0, -80.0)); // Overscrolling, need to move more than -40.
        await tester.pump();
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(49.775111111111116), // Below 50 now.
        );
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.refresh,
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      'goes to done internally as soon as the task finishes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        await tester.drag(find.text('0'), const Offset(0.0, 100.0), touchSlopY: 0.0);
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.armed,
        );
        // The sliver scroll offset correction is applied on the next frame.
        await tester.pump();

        await tester.pump(const Duration(seconds: 2));
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.refresh,
        );
        expect(
          tester.getRect(find.widgetWithText(SizedBox, '0')),
          const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0),
        );

        mockHelper.refreshCompleter.complete(null);
        // 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(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.done,
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      'goes back to inactive when retracting back past 10% of arming distance',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        final TestGesture gesture = await tester.startGesture(Offset.zero);
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.armed,
        );

        mockHelper.refreshCompleter.complete(null);
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.done,
        );
        await tester.pump();

        // Now back in overscroll mode.
        await gesture.moveBy(const Offset(0.0, -200.0));
        await tester.pump();
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(27.944444444444457),
        );
        // Need to bring it to 100 * 0.1 to reset to inactive.
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.done,
        );

        await gesture.moveBy(const Offset(0.0, -35.0));
        await tester.pump();
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(9.313890708161875),
        );
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.inactive,
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

    testWidgets(
      'goes back to inactive if already scrolled away when task completes',
      (WidgetTester tester) async {
        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: mockHelper.builder,
                  onRefresh: mockHelper.refreshTask,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        final TestGesture gesture = await tester.startGesture(Offset.zero);
        await gesture.moveBy(const Offset(0.0, 150.0));
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          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(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(-145.0332383665717),
        );
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
          RefreshIndicatorMode.refresh,
        );

        mockHelper.refreshCompleter.complete(null);
        // The sliver layout extent is removed on next frame.
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
          RefreshIndicatorMode.inactive,
        );
        // Nothing moved.
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(-145.0332383665717),
        );
        await tester.pump(const Duration(seconds: 2));
        // Everything stayed as is.
        expect(
          tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy,
          moreOrLessEquals(-145.0332383665717),
        );
    }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));

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

        await tester.pumpWidget(
          Directionality(
            textDirection: TextDirection.ltr,
            child: CustomScrollView(
              slivers: <Widget>[
                CupertinoSliverRefreshControl(
                  builder: null,
                  onRefresh: mockHelper.refreshTask,
                  refreshIndicatorExtent: 0.0,
                ),
                buildAListOfStuff(),
              ],
            ),
          ),
        );

        await tester.drag(find.text('0'), const Offset(0.0, 150.0));
        await tester.pump();
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))),
          RefreshIndicatorMode.armed,
        );

        await tester.pump();
        await tester.pump(const Duration(seconds: 5));
        // In refresh mode but has no UI.
        expect(
          CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))),
          RefreshIndicatorMode.refresh,
        );
        expect(
          tester.getRect(find.widgetWithText(Center, '0')),
          const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0),
        );

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

    testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: Builder(
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                10, 100, 10,
              );
            },
          ),
        ),
      );
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0);

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: Builder(
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                26, 100, 10,
              );
            },
          ),
        ),
      );
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 26.0 / 100.0);

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: Builder(
            builder: (BuildContext context) {
              return CupertinoSliverRefreshControl.buildRefreshIndicator(
                context,
                RefreshIndicatorMode.drag,
                100, 100, 10,
              );
            },
          ),
        ),
      );
      expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 100.0 / 100.0);
    });
  }

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

  testWidgets(
    'Does not crash when paintExtent > remainingPaintExtent',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/46871.
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: CustomScrollView(
            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
      // geometry.paintExtent > constraints.maxPaintExtent
      await tester.dragFrom(const Offset(10, 10), const Offset(0, 500));
      await tester.pump();

      expect(tester.takeException(), isNull);
  });
}

class FakeBuilder {
  Completer<void> refreshCompleter = Completer<void>.sync();
  final List<MockHelperInvocation> invocations = <MockHelperInvocation>[];

  Widget refreshIndicator = Container();

  Widget builder(
    BuildContext context,
    RefreshIndicatorMode refreshState,
    double pulledExtent,
    double refreshTriggerPullDistance,
    double refreshIndicatorExtent,
  ) {
    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');
    }
    invocations.add(
      BuilderInvocation(
        refreshState: refreshState,
        pulledExtent: pulledExtent,
        refreshTriggerPullDistance: refreshTriggerPullDistance,
        refreshIndicatorExtent: refreshIndicatorExtent,
      )
    );
    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({
    required this.refreshState,
    required this.pulledExtent,
    required this.refreshIndicatorExtent,
    required this.refreshTriggerPullDistance,
  });

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

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

Matcher matchesBuilder({
  required RefreshIndicatorMode refreshState,
  required dynamic pulledExtent,
  required dynamic refreshTriggerPullDistance,
  required dynamic refreshIndicatorExtent,
}) {
  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);
}
