// 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.

import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/src/physics/utils.dart' show nearEqual;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

import '../rendering/mock_canvas.dart';

const Color _kScrollbarColor = Color(0xFF123456);
const double _kThickness = 2.5;
const double _kMinThumbExtent = 18.0;
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);

ScrollbarPainter _buildPainter({
  TextDirection textDirection = TextDirection.ltr,
  EdgeInsets padding = EdgeInsets.zero,
  Color color = _kScrollbarColor,
  double thickness = _kThickness,
  double mainAxisMargin = 0.0,
  double crossAxisMargin = 0.0,
  Radius? radius,
  Radius? trackRadius,
  double minLength = _kMinThumbExtent,
  double? minOverscrollLength,
  ScrollbarOrientation? scrollbarOrientation,
  required ScrollMetrics scrollMetrics,
}) {
  return ScrollbarPainter(
    color: color,
    textDirection: textDirection,
    thickness: thickness,
    padding: padding,
    mainAxisMargin: mainAxisMargin,
    crossAxisMargin: crossAxisMargin,
    radius: radius,
    trackRadius: trackRadius,
    minLength: minLength,
    minOverscrollLength: minOverscrollLength ?? minLength,
    fadeoutOpacityAnimation: kAlwaysCompleteAnimation,
    scrollbarOrientation: scrollbarOrientation,
  )..update(scrollMetrics, scrollMetrics.axisDirection);
}

class _DrawRectOnceCanvas extends Fake implements Canvas {
  List<Rect> rects = <Rect>[];
  List<RRect> rrects = <RRect>[];

  @override
  void drawRect(Rect rect, Paint paint) {
    rects.add(rect);
  }

  @override
  void drawRRect(ui.RRect rrect, ui.Paint paint) {
    rrects.add(rrect);
  }

  @override
  void drawLine(Offset p1, Offset p2, Paint paint) {}
}

void main() {
  final _DrawRectOnceCanvas testCanvas = _DrawRectOnceCanvas();
  ScrollbarPainter painter;

  Rect captureRect() => testCanvas.rects.removeLast();
  RRect captureRRect() => testCanvas.rrects.removeLast();

  tearDown(() {
    testCanvas.rects.clear();
    testCanvas.rrects.clear();
  });

  final ScrollMetrics defaultMetrics = FixedScrollMetrics(
    minScrollExtent: 0,
    maxScrollExtent: 0,
    pixels: 0,
    viewportDimension: 100,
    axisDirection: AxisDirection.down,
  );

  test(
    'Scrollbar is not smaller than minLength with large scroll views, '
    'if minLength is small ',
    () {
      const double minLen = 3.5;
      const Size size = Size(600, 10);
      final ScrollMetrics metrics = defaultMetrics.copyWith(
        maxScrollExtent: 100000,
        viewportDimension: size.height,
      );

      // When overscroll.
      painter = _buildPainter(
        minLength: minLen,
        minOverscrollLength: minLen,
        scrollMetrics: metrics,
      );

      painter.paint(testCanvas, size);

      final Rect rect0 = captureRect();
      expect(rect0.top, 0);
      expect(rect0.left, size.width - _kThickness);
      expect(rect0.width, _kThickness);
      expect(rect0.height >= minLen, true);

      // When scroll normally.
      const double newPixels = 1.0;

      painter.update(metrics.copyWith(pixels: newPixels), metrics.axisDirection);

      painter.paint(testCanvas, size);

      final Rect rect1 = captureRect();
      expect(rect1.left, size.width - _kThickness);
      expect(rect1.width, _kThickness);
      expect(rect1.height >= minLen, true);
    },
  );

  test(
    'When scrolling normally (no overscrolling), the size of the scrollbar stays the same, '
    'and it scrolls evenly',
    () {
      const double viewportDimension = 23;
      const double maxExtent = 100;
      final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
        maxScrollExtent: maxExtent,
        viewportDimension: viewportDimension,
      );
      const Size size = Size(600, viewportDimension);
      const double minLen = 0;

      painter = _buildPainter(
        minLength: minLen,
        minOverscrollLength: minLen,
        scrollMetrics: defaultMetrics,
      );

      final List<ScrollMetrics> metricsList = <ScrollMetrics> [
        startingMetrics.copyWith(pixels: 0.01),
        ...List<ScrollMetrics>.generate(
          (maxExtent / viewportDimension).round(),
          (int index) => startingMetrics.copyWith(pixels: (index + 1) * viewportDimension),
        ).where((ScrollMetrics metrics) => !metrics.outOfRange),
        startingMetrics.copyWith(pixels: maxExtent - 0.01),
      ];

      late double lastCoefficient;
      for (final ScrollMetrics metrics in metricsList) {
        painter.update(metrics, metrics.axisDirection);
        painter.paint(testCanvas, size);

        final Rect rect = captureRect();
        final double newCoefficient = metrics.pixels/rect.top;
        lastCoefficient = newCoefficient;

        expect(rect.top >= 0, true);
        expect(rect.bottom <= maxExtent, true);
        expect(rect.left, size.width - _kThickness);
        expect(rect.width, _kThickness);
        expect(nearEqual(rect.height, viewportDimension * viewportDimension / (viewportDimension + maxExtent), 0.001), true);
        expect(nearEqual(lastCoefficient, newCoefficient, 0.001), true);
      }
    },
  );

  test(
    'mainAxisMargin is respected',
    () {
      const double viewportDimension = 23;
      const double maxExtent = 100;
      final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
        maxScrollExtent: maxExtent,
        viewportDimension: viewportDimension,
      );
      const Size size = Size(600, viewportDimension);
      const double minLen = 0;

      const List<double> margins = <double> [-10, 1, viewportDimension/2 - 0.01];
      for (final double margin in margins) {
        painter = _buildPainter(
          mainAxisMargin: margin,
          minLength: minLen,
          scrollMetrics: defaultMetrics,
        );

        // Overscroll to double.negativeInfinity (top).
        painter.update(
          startingMetrics.copyWith(pixels: double.negativeInfinity),
          startingMetrics.axisDirection,
        );

        painter.paint(testCanvas, size);
        expect(captureRect().top, margin);

        // Overscroll to double.infinity (down).
        painter.update(
          startingMetrics.copyWith(pixels: double.infinity),
          startingMetrics.axisDirection,
        );

        painter.paint(testCanvas, size);
        expect(size.height - captureRect().bottom, margin);
      }
    },
  );

  test(
    'crossAxisMargin & text direction are respected',
    () {
      const double viewportDimension = 23;
      const double maxExtent = 100;
      final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
        maxScrollExtent: maxExtent,
        viewportDimension: viewportDimension,
      );
      const Size size = Size(600, viewportDimension);
      const double margin = 4;

      for (final TextDirection textDirection in TextDirection.values) {
        painter = _buildPainter(
          crossAxisMargin: margin,
          scrollMetrics: startingMetrics,
          textDirection: textDirection,
        );

        for (final AxisDirection direction in AxisDirection.values) {
          painter.update(
            startingMetrics.copyWith(axisDirection: direction),
            direction,
          );

          painter.paint(testCanvas, size);
          final Rect rect = captureRect();

          switch (direction) {
            case AxisDirection.up:
            case AxisDirection.down:
              expect(
                margin,
                textDirection == TextDirection.ltr
                  ? size.width - rect.right
                  : rect.left,
              );
              break;
            case AxisDirection.left:
            case AxisDirection.right:
              expect(margin, size.height - rect.bottom);
              break;
          }
        }
      }
    },
  );

  test('scrollbarOrientation are respected', () {
    const double viewportDimension = 23;
    const double maxExtent = 100;
    final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
      maxScrollExtent: maxExtent,
      viewportDimension: viewportDimension,
    );
    const Size size = Size(600, viewportDimension);

    for (final ScrollbarOrientation scrollbarOrientation in ScrollbarOrientation.values) {
      final AxisDirection axisDirection;
      if (scrollbarOrientation == ScrollbarOrientation.left || scrollbarOrientation == ScrollbarOrientation.right) {
        axisDirection = AxisDirection.down;
      } else {
        axisDirection = AxisDirection.right;
      }

      painter = _buildPainter(
        scrollMetrics: startingMetrics,
        scrollbarOrientation: scrollbarOrientation,
      );

      painter.update(
        startingMetrics.copyWith(axisDirection: axisDirection),
        axisDirection
      );

      painter.paint(testCanvas, size);
      final Rect rect = captureRect();

      switch (scrollbarOrientation) {
        case ScrollbarOrientation.left:
          expect(rect.left, 0);
          expect(rect.top, 0);
          expect(rect.right, _kThickness);
          expect(rect.bottom, _kMinThumbExtent);
          break;
        case ScrollbarOrientation.right:
          expect(rect.left, 600 - _kThickness);
          expect(rect.top, 0);
          expect(rect.right, 600);
          expect(rect.bottom, _kMinThumbExtent);
          break;
        case ScrollbarOrientation.top:
          expect(rect.left, 0);
          expect(rect.top, 0);
          expect(rect.right, _kMinThumbExtent);
          expect(rect.bottom, _kThickness);
          break;
        case ScrollbarOrientation.bottom:
          expect(rect.left, 0);
          expect(rect.top, 23 - _kThickness);
          expect(rect.right, _kMinThumbExtent);
          expect(rect.bottom, 23);
          break;
      }
    }
  });

  test('scrollbarOrientation default values are correct', () {
    const double viewportDimension = 23;
    const double maxExtent = 100;
    final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
      maxScrollExtent: maxExtent,
      viewportDimension: viewportDimension,
    );
    const Size size = Size(600, viewportDimension);
    Rect rect;

    // Vertical scroll with TextDirection.ltr
    painter = _buildPainter(
      scrollMetrics: startingMetrics,
    );
    painter.update(
      startingMetrics.copyWith(axisDirection: AxisDirection.down),
      AxisDirection.down
    );
    painter.paint(testCanvas, size);
    rect = captureRect();
    expect(rect.left, 600 - _kThickness);
    expect(rect.top, 0);
    expect(rect.right, 600);
    expect(rect.bottom, _kMinThumbExtent);

    // Vertical scroll with TextDirection.rtl
    painter = _buildPainter(
      scrollMetrics: startingMetrics,
      textDirection: TextDirection.rtl,
    );
    painter.update(
      startingMetrics.copyWith(axisDirection: AxisDirection.down),
      AxisDirection.down
    );
    painter.paint(testCanvas, size);
    rect = captureRect();
    expect(rect.left, 0);
    expect(rect.top, 0);
    expect(rect.right, _kThickness);
    expect(rect.bottom, _kMinThumbExtent);

    // Horizontal scroll
    painter = _buildPainter(
      scrollMetrics: startingMetrics,
    );
    painter.update(
      startingMetrics.copyWith(axisDirection: AxisDirection.right),
      AxisDirection.right,
    );
    painter.paint(testCanvas, size);
    rect = captureRect();
    expect(rect.left, 0);
    expect(rect.top, 23 - _kThickness);
    expect(rect.right, _kMinThumbExtent);
    expect(rect.bottom, 23);
  });

  group('Padding works for all scroll directions', () {
    const EdgeInsets padding = EdgeInsets.fromLTRB(1, 2, 3, 4);
    const Size size = Size(60, 80);
    final ScrollMetrics metrics = defaultMetrics.copyWith(
      minScrollExtent: -100,
      maxScrollExtent: 240,
      axisDirection: AxisDirection.down,
    );

    final ScrollbarPainter painter = _buildPainter(
      padding: padding,
      scrollMetrics: metrics,
    );

    testWidgets('down', (WidgetTester tester) async {
      painter.update(
        metrics.copyWith(
          viewportDimension: size.height,
          pixels: double.negativeInfinity,
        ),
        AxisDirection.down,
      );

      // Top overscroll.
      painter.paint(testCanvas, size);
      final Rect rect0 = captureRect();
      expect(rect0.top, padding.top);
      expect(size.width - rect0.right, padding.right);

      // Bottom overscroll.
      painter.update(
        metrics.copyWith(
          viewportDimension: size.height,
          pixels: double.infinity,
        ),
        AxisDirection.down,
      );

      painter.paint(testCanvas, size);
      final Rect rect1 = captureRect();
      expect(size.height - rect1.bottom, padding.bottom);
      expect(size.width - rect1.right, padding.right);
    });

    testWidgets('up', (WidgetTester tester) async {
      painter.update(
        metrics.copyWith(
          viewportDimension: size.height,
          pixels: double.infinity,
          axisDirection: AxisDirection.up,
        ),
        AxisDirection.up,
      );

      // Top overscroll.
      painter.paint(testCanvas, size);
      final Rect rect0 = captureRect();
      expect(rect0.top, padding.top);
      expect(size.width - rect0.right, padding.right);

      // Bottom overscroll.
      painter.update(
        metrics.copyWith(
          viewportDimension: size.height,
          pixels: double.negativeInfinity,
          axisDirection: AxisDirection.up,
        ),
        AxisDirection.up,
      );

      painter.paint(testCanvas, size);
      final Rect rect1 = captureRect();
      expect(size.height - rect1.bottom, padding.bottom);
      expect(size.width - rect1.right, padding.right);
    });

    testWidgets('left', (WidgetTester tester) async {
      painter.update(
        metrics.copyWith(
          viewportDimension: size.width,
          pixels: double.negativeInfinity,
          axisDirection: AxisDirection.left,
        ),
        AxisDirection.left,
      );

      // Right overscroll.
      painter.paint(testCanvas, size);
      final Rect rect0 = captureRect();
      expect(size.height - rect0.bottom, padding.bottom);
      expect(size.width - rect0.right, padding.right);

      // Left overscroll.
      painter.update(
        metrics.copyWith(
          viewportDimension: size.width,
          pixels: double.infinity,
          axisDirection: AxisDirection.left,
        ),
        AxisDirection.left,
      );

      painter.paint(testCanvas, size);
      final Rect rect1 = captureRect();
      expect(size.height - rect1.bottom, padding.bottom);
      expect(rect1.left, padding.left);
    });

    testWidgets('right', (WidgetTester tester) async {
      painter.update(
        metrics.copyWith(
          viewportDimension: size.width,
          pixels: double.infinity,
          axisDirection: AxisDirection.right,
        ),
        AxisDirection.right,
      );

      // Right overscroll.
      painter.paint(testCanvas, size);
      final Rect rect0 = captureRect();
      expect(size.height - rect0.bottom, padding.bottom);
      expect(size.width - rect0.right, padding.right);

      // Left overscroll.
      painter.update(
        metrics.copyWith(
          viewportDimension: size.width,
          pixels: double.negativeInfinity,
          axisDirection: AxisDirection.right,
        ),
        AxisDirection.right,
      );

      painter.paint(testCanvas, size);
      final Rect rect1 = captureRect();
      expect(size.height - rect1.bottom, padding.bottom);
      expect(rect1.left, padding.left);
    });
  });

  testWidgets('thumb resizes gradually on overscroll', (WidgetTester tester) async {
    const EdgeInsets padding = EdgeInsets.fromLTRB(1, 2, 3, 4);
    const Size size = Size(60, 300);
    final double scrollExtent = size.height * 10;
    final ScrollMetrics metrics = defaultMetrics.copyWith(
      minScrollExtent: 0,
      maxScrollExtent: scrollExtent,
      axisDirection: AxisDirection.down,
      viewportDimension: size.height,
    );

    const double minOverscrollLength = 8.0;
    final ScrollbarPainter painter = _buildPainter(
      padding: padding,
      scrollMetrics: metrics,
      minLength: 36.0,
      minOverscrollLength: 8.0,
    );

    // No overscroll gives a full sized thumb.
    painter.update(
      metrics.copyWith(
        pixels: 0.0,
      ),
      AxisDirection.down,
    );
    painter.paint(testCanvas, size);
    final double fullThumbExtent = captureRect().height;
    expect(fullThumbExtent, greaterThan(_kMinThumbExtent));

    // Scrolling to the middle also gives a full sized thumb.
    painter.update(
      metrics.copyWith(
        pixels: scrollExtent / 2,
      ),
      AxisDirection.down,
    );
    painter.paint(testCanvas, size);
    expect(captureRect().height, moreOrLessEquals(fullThumbExtent, epsilon: 1e-6));

    // Scrolling just to the very end also gives a full sized thumb.
    painter.update(
      metrics.copyWith(
        pixels: scrollExtent,
      ),
      AxisDirection.down,
    );
    painter.paint(testCanvas, size);
    expect(captureRect().height, moreOrLessEquals(fullThumbExtent, epsilon: 1e-6));

    // Scrolling just past the end shrinks the thumb slightly.
    painter.update(
      metrics.copyWith(
        pixels: scrollExtent * 1.001,
      ),
      AxisDirection.down,
    );
    painter.paint(testCanvas, size);
    expect(captureRect().height, moreOrLessEquals(fullThumbExtent, epsilon: 2.0));

    // Scrolling way past the end shrinks the thumb to minimum.
    painter.update(
      metrics.copyWith(
        pixels: double.infinity,
      ),
      AxisDirection.down,
    );
    painter.paint(testCanvas, size);
    expect(captureRect().height, minOverscrollLength);
  });

  test('should scroll towards the right direction',
    () {
      const Size size = Size(60, 80);
      const double maxScrollExtent = 240;
      const double minScrollExtent = -100;
      final ScrollMetrics startingMetrics = defaultMetrics.copyWith(
        minScrollExtent: minScrollExtent,
        maxScrollExtent: maxScrollExtent,
        axisDirection: AxisDirection.down,
        viewportDimension: size.height,
      );

      for (final double minLength in <double>[_kMinThumbExtent, double.infinity]) {
        // Disregard `minLength` and `minOverscrollLength` to keep
        // scroll direction correct, if needed
        painter = _buildPainter(
          minLength: minLength,
          minOverscrollLength: minLength,
          scrollMetrics: startingMetrics,
        );

        final Iterable<ScrollMetrics> metricsList = Iterable<ScrollMetrics>.generate(
          9999,
          (int index) => startingMetrics.copyWith(pixels: minScrollExtent + index * size.height / 3),
        )
        .takeWhile((ScrollMetrics metrics) => !metrics.outOfRange);

        Rect? previousRect;

        for (final ScrollMetrics metrics in metricsList) {
          painter.update(metrics, metrics.axisDirection);
          painter.paint(testCanvas, size);
          final Rect rect = captureRect();
          if (previousRect != null) {
            if (rect.height == size.height) {
              // Size of the scrollbar is too large for the view port
              expect(previousRect.top <= rect.top, true);
              expect(previousRect.bottom <= rect.bottom, true);
            } else {
              // The scrollbar can fit in the view port.
              expect(previousRect.top < rect.top, true);
              expect(previousRect.bottom < rect.bottom, true);
            }
          }

          previousRect = rect;
        }
      }
    },
  );

  test('trackRadius and radius is respected', () {
    const double minLen = 3.5;
    const Size size = Size(600, 10);
    final ScrollMetrics metrics = defaultMetrics.copyWith(
      maxScrollExtent: 100000,
      viewportDimension: size.height,
    );

    painter = _buildPainter(
      trackRadius: const Radius.circular(2.0),
      radius: const Radius.circular(3.0),
      minLength: minLen,
      minOverscrollLength: minLen,
      scrollMetrics: metrics,
    );

    painter.paint(testCanvas, size);

    final RRect thumbRRect = captureRRect(); // thumb
    expect(thumbRRect.blRadius, const Radius.circular(3.0));
    expect(thumbRRect.brRadius, const Radius.circular(3.0));
    expect(thumbRRect.tlRadius, const Radius.circular(3.0));
    expect(thumbRRect.trRadius, const Radius.circular(3.0));

    final RRect trackRRect = captureRRect(); // track
    expect(trackRRect.blRadius, const Radius.circular(2.0));
    expect(trackRRect.brRadius, const Radius.circular(2.0));
    expect(trackRRect.tlRadius, const Radius.circular(2.0));
    expect(trackRRect.trRadius, const Radius.circular(2.0));
  });

  testWidgets('ScrollbarPainter asserts if no TextDirection has been provided', (WidgetTester tester) async {
    final ScrollbarPainter painter = ScrollbarPainter(
      color: _kScrollbarColor,
      fadeoutOpacityAnimation: kAlwaysCompleteAnimation,
    );
    const Size size = Size(60, 80);
    final ScrollMetrics scrollMetrics = defaultMetrics.copyWith(
      maxScrollExtent: 100000,
      viewportDimension: size.height,
    );
    painter.update(scrollMetrics, scrollMetrics.axisDirection);
    // Try to paint the scrollbar
    try {
      painter.paint(testCanvas, size);
    } on AssertionError catch (error) {
      expect(error.message, 'A TextDirection must be provided before a Scrollbar can be painted.');
    }
  });

  testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            isAlwaysShown: true,
            controller: scrollController,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(width: 1000.0, height: 1000.0),
            ),
          ),
        ),
      ),
    );

    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Tap on the track area below the thumb.
    await tester.tapAt(const Offset(796.0, 550.0));
    await tester.pumpAndSettle();

    expect(scrollController.offset, 400.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 240.0, 800.0, 600.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Tap on the track area above the thumb.
    await tester.tapAt(const Offset(796.0, 50.0));
    await tester.pumpAndSettle();

    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
    await tester.pumpWidget(
      const Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: MediaQueryData(),
          child: RawScrollbar(
            child: SingleChildScrollView(
              child: SizedBox(width: 4000.0, height: 4000.0),
            ),
          ),
        ),
      ),
    );
    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
    await gesture.moveBy(const Offset(0.0, -20.0));
    await tester.pump();
    // Scrollbar fully showing
    await tester.pump(const Duration(milliseconds: 500));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    await tester.pump(const Duration(seconds: 3));
    await tester.pump(const Duration(seconds: 3));
    // Still there.
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    await gesture.up();
    await tester.pump(_kScrollbarTimeToFade);
    await tester.pump(_kScrollbarFadeDuration * 0.5);

    // Opacity going down now.
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x4fbcbcbc),
        ),
    );
  });

  testWidgets('Scrollbar does not fade away while hovering', (WidgetTester tester) async {
    await tester.pumpWidget(
      const Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: MediaQueryData(),
          child: RawScrollbar(
            child: SingleChildScrollView(
              child: SizedBox(width: 4000.0, height: 4000.0),
            ),
          ),
        ),
      ),
    );
    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
    await gesture.moveBy(const Offset(0.0, -20.0));
    await tester.pump();
    // Scrollbar fully showing
    await tester.pump(const Duration(milliseconds: 500));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
    // Hover over the thumb to prevent the scrollbar from fading out.
    testPointer.hover(const Offset(790.0, 5.0));
    await gesture.up();
    await tester.pump(const Duration(seconds: 3));

    // Still there.
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar will fade back in when hovering over known track area', (WidgetTester tester) async {
    await tester.pumpWidget(
      const Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: MediaQueryData(),
          child: RawScrollbar(
            child: SingleChildScrollView(
              child: SizedBox(width: 4000.0, height: 4000.0),
            ),
          ),
        ),
      ),
    );
    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
    await gesture.moveBy(const Offset(0.0, -20.0));
    await tester.pump();
    // Scrollbar fully showing
    await tester.pump(const Duration(milliseconds: 500));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );
    await gesture.up();
    await tester.pump(_kScrollbarTimeToFade);
    await tester.pump(_kScrollbarFadeDuration * 0.5);

    // Scrollbar is fading out
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x4fbcbcbc),
        ),
    );

    // Hover over scrollbar with mouse to bring opacity back up
    final TestGesture mouseGesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
    await mouseGesture.addPointer();
    addTearDown(mouseGesture.removePointer);
    await mouseGesture.moveTo(const Offset(794.0, 5.0));
    await tester.pumpAndSettle();
    // Scrollbar should be visible
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar will show on hover without needing to scroll first for metrics', (WidgetTester tester) async {
    await tester.pumpWidget(
      const Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: MediaQueryData(),
          child: RawScrollbar(
            child: SingleChildScrollView(
              child: SizedBox(width: 4000.0, height: 4000.0),
            ),
          ),
        ),
      ),
    );
    await tester.pump();

    // Hover over scrollbar with mouse. Even though we have not scrolled, the
    // ScrollMetricsNotification will have informed the Scrollbar's hit testing.
    final TestGesture mouseGesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
    await mouseGesture.addPointer();
    addTearDown(mouseGesture.removePointer);
    await mouseGesture.moveTo(const Offset(794.0, 5.0));
    await tester.pumpAndSettle();
    // Scrollbar should have appeared in response to hover event.
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar thumb can be dragged', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Drag the thumb down to scroll down.
    const double scrollAmount = 10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    // The view has scrolled more than it would have by a swipe gesture of the
    // same distance.
    expect(scrollController.offset, greaterThan(scrollAmount * 2));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar thumb cannot be dragged into overscroll if the physics do not allow', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Try to drag the thumb into overscroll.
    const double scrollAmount = -10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    // The physics should not have allowed us to enter overscroll.
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('Scrollbar thumb cannot be dragged into overscroll if the platform does not allow it', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: ScrollConfiguration(
            // Don't apply a scrollbar automatically for this test.
            behavior: const ScrollBehavior().copyWith(
              scrollbars: false,
              physics: const AlwaysScrollableScrollPhysics(),
            ),
            child: PrimaryScrollController(
              controller: scrollController,
              child: RawScrollbar(
                isAlwaysShown: true,
                controller: scrollController,
                child: const SingleChildScrollView(
                  primary: true,
                  child: SizedBox(width: 4000.0, height: 4000.0),
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Try to drag the thumb into overscroll.
    const double scrollAmount = -10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    // The platform drag handling should not have allowed us to enter overscroll.
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();
  }, variant: const TargetPlatformVariant(<TargetPlatform>{
    TargetPlatform.macOS,
    TargetPlatform.linux,
    TargetPlatform.windows,
    TargetPlatform.fuchsia,
  }));

  testWidgets('Scrollbar thumb can be dragged into overscroll if the platform allows it', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: ScrollConfiguration(
            // Don't apply a scrollbar automatically for this test.
            behavior: const ScrollBehavior().copyWith(
              scrollbars: false,
              physics: const AlwaysScrollableScrollPhysics(),
            ),
            child: PrimaryScrollController(
              controller: scrollController,
              child: RawScrollbar(
                isAlwaysShown: true,
                controller: scrollController,
                child: const SingleChildScrollView(
                  child: SizedBox(width: 4000.0, height: 4000.0),
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Try to drag the thumb into overscroll.
    const double scrollAmount = -10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    // The platform drag handling should have allowed us to enter overscroll.
    expect(scrollController.offset, lessThan(-66.0));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          // The size of the scrollbar thumb shrinks when overscrolling
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 80.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();
  }, variant: const TargetPlatformVariant(<TargetPlatform>{
    TargetPlatform.android,
    TargetPlatform.iOS,
  }));

  // Regression test for https://github.com/flutter/flutter/issues/66444
  testWidgets("RawScrollbar doesn't show when scroll the inner scrollable widget", (WidgetTester tester) async {
    final GlobalKey key1 = GlobalKey();
    final GlobalKey key2 = GlobalKey();
    final GlobalKey outerKey = GlobalKey();
    final GlobalKey innerKey = GlobalKey();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            key: key2,
            thumbColor: const Color(0x11111111),
            child: SingleChildScrollView(
              key: outerKey,
              child: SizedBox(
                height: 1000.0,
                width: double.infinity,
                child: Column(
                  children: <Widget>[
                    RawScrollbar(
                      key: key1,
                      thumbColor: const Color(0x22222222),
                      child: SizedBox(
                        height: 300.0,
                        width: double.infinity,
                        child: SingleChildScrollView(
                          key: innerKey,
                          child: const SizedBox(
                            key: Key('Inner scrollable'),
                            height: 1000.0,
                            width: double.infinity,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );

    // Drag the inner scrollable widget.
    await tester.drag(find.byKey(innerKey), const Offset(0.0, -25.0));
    await tester.pump();
    // Scrollbar fully showing.
    await tester.pump(const Duration(milliseconds: 500));

    expect(
      tester.renderObject(find.byKey(key2)),
      paintsExactlyCountTimes(#drawRect, 2), // Each bar will call [drawRect] twice.
    );

    expect(
      tester.renderObject(find.byKey(key1)),
      paintsExactlyCountTimes(#drawRect, 2),
    );
  });

  testWidgets('Scrollbar hit test area adjusts for PointerDeviceKind', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Drag the scrollbar just outside of the painted thumb with touch input.
    // The hit test area is padded to meet the minimum interactive size.
    const double scrollAmount = 10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(790.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    // The scrollbar moved by scrollAmount, and the scrollOffset moved forward.
    expect(scrollController.offset, greaterThan(0.0));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Move back to reset.
    await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // The same should not be possible with a mouse since it is more precise,
    // the padding it not necessary.
    final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
    await gesture.addPointer();
    await gesture.down(const Offset(790.0, 45.0));
    await tester.pump();
    await gesture.moveTo(const Offset(790.0, 55.0));
    await gesture.up();
    await tester.pumpAndSettle();
    // The scrollbar/scrollable should not have moved.
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('hit test', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/99324
    final ScrollController scrollController = ScrollController();
    bool onTap = false;
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              trackVisibility: true,
              thumbVisibility: true,
              controller: scrollController,
              child: SingleChildScrollView(
                child: GestureDetector(
                  onTap: () => onTap = true,
                  child: const SizedBox(
                    width: 4000.0,
                    height: 4000.0,
                    child: ColoredBox(color: Color(0x00000000)),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(onTap, false);

    // Tap on track area.
    await tester.tapAt(const Offset(795.0, 550.0));
    await tester.pumpAndSettle();
    expect(onTap, false);

    // Tap on thumb area.
    await tester.tapAt(const Offset(795.0, 10.0));
    await tester.pumpAndSettle();
    expect(onTap, false);

    // Tap on content area.
    await tester.tapAt(const Offset(400.0, 300.0));
    await tester.pumpAndSettle();
    expect(onTap, true);
  });

  testWidgets('RawScrollbar.thumbVisibility asserts that a ScrollPosition is attached', (WidgetTester tester) async {
    final FlutterExceptionHandler? handler = FlutterError.onError;
    FlutterErrorDetails? error;
    FlutterError.onError = (FlutterErrorDetails details) {
      error = details;
    };

    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            thumbVisibility: true,
            controller: ScrollController(),
            thumbColor: const Color(0x11111111),
            child: const SingleChildScrollView(
              child: SizedBox(
                height: 1000.0,
                width: 50.0,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(error, isNotNull);
    final AssertionError exception = error!.exception as AssertionError;
    expect(
      exception.message,
      contains("The Scrollbar's ScrollController has no ScrollPosition attached."),
    );

    FlutterError.onError = handler;
  });

  testWidgets('RawScrollbar.isAlwaysShown asserts that a ScrollPosition is attached', (WidgetTester tester) async {
    final FlutterExceptionHandler? handler = FlutterError.onError;
    FlutterErrorDetails? error;
    FlutterError.onError = (FlutterErrorDetails details) {
      error = details;
    };

    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            isAlwaysShown: true,
            controller: ScrollController(),
            thumbColor: const Color(0x11111111),
            child: const SingleChildScrollView(
              child: SizedBox(
                height: 1000.0,
                width: 50.0,
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(error, isNotNull);
    final AssertionError exception = error!.exception as AssertionError;
    expect(
      exception.message,
      contains("The Scrollbar's ScrollController has no ScrollPosition attached."),
    );

    FlutterError.onError = handler;
  });

  testWidgets('Interactive scrollbars should have a valid scroll controller', (WidgetTester tester) async {
    final ScrollController primaryScrollController = ScrollController();
    final ScrollController scrollController = ScrollController();

    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: primaryScrollController,
              child: RawScrollbar(
                child: SingleChildScrollView(
                controller: scrollController,
                child: const SizedBox(
                  height: 1000.0,
                  width: 1000.0,
                ),
              ),
            ),
          ),
        ),
      ),
    );

    await tester.pumpAndSettle();

    AssertionError? exception = tester.takeException() as AssertionError?;
    // The scrollbar is not visible and cannot be interacted with, so no assertion.
    expect(exception, isNull);
    // Scroll to trigger the scrollbar to come into view.
    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
    await gesture.moveBy(const Offset(0.0, -20.0));
    exception = tester.takeException() as AssertionError;
    expect(exception, isAssertionError);
    expect(
      exception.message,
      contains("The Scrollbar's ScrollController has no ScrollPosition attached."),
    );
  });

  testWidgets('Simultaneous dragging and pointer scrolling does not cause a crash', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/70105
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x00000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x00000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66bcbcbc),
        ),
    );

    // Drag the thumb down to scroll down.
    const double scrollAmount = 10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();

    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x00000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x00000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          // Drag color
          color: const Color(0x66bcbcbc),
        ),
    );

    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();
    expect(scrollController.offset, greaterThan(10.0));
    final double previousOffset = scrollController.offset;
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x00000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x00000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
          color: const Color(0x66bcbcbc),
        ),
    );

    // Execute a pointer scroll while dragging (drag gesture has not come up yet)
    final TestPointer pointer = TestPointer(1, ui.PointerDeviceKind.mouse);
    pointer.hover(const Offset(798.0, 15.0));
    await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0)));
    await tester.pumpAndSettle();

    if (!kIsWeb) {
      // Scrolling while holding the drag on the scrollbar and still hovered over
      // the scrollbar should not have changed the scroll offset.
      expect(pointer.location, const Offset(798.0, 15.0));
      expect(scrollController.offset, previousOffset);
      expect(
        find.byType(RawScrollbar),
        paints
          ..rect(
            rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
            color: const Color(0x00000000),
          )
          ..line(
            p1: const Offset(794.0, 0.0),
            p2: const Offset(794.0, 600.0),
            strokeWidth: 1.0,
            color: const Color(0x00000000),
          )
          ..rect(
            rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
            color: const Color(0x66bcbcbc),
          ),
      );
    } else {
      expect(pointer.location, const Offset(798.0, 15.0));
      expect(scrollController.offset, previousOffset + 20.0);
    }

    // Drag is still being held, move pointer to be hovering over another area
    // of the scrollable (not over the scrollbar) and execute another pointer scroll
    pointer.hover(tester.getCenter(find.byType(SingleChildScrollView)));
    await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0)));
    await tester.pumpAndSettle();
    // Scrolling while holding the drag on the scrollbar changed the offset
    expect(pointer.location, const Offset(400.0, 300.0));
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x00000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x00000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66bcbcbc),
        ),
    );

    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x00000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x00000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66bcbcbc),
        ),
    );
  });

  testWidgets('Scrollbar thumb can be dragged in reverse', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                reverse: true,
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 510.0, 800.0, 600.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Drag the thumb up to scroll up.
    const double scrollAmount = 10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 550.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    // The view has scrolled more than it would have by a swipe gesture of the
    // same distance.
    expect(scrollController.offset, greaterThan(scrollAmount * 2));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 500.0, 800.0, 590.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('ScrollbarPainter asserts if scrollbarOrientation is used with wrong axisDirection', (WidgetTester tester) async {
    final ScrollbarPainter painter = ScrollbarPainter(
      color: _kScrollbarColor,
      fadeoutOpacityAnimation: kAlwaysCompleteAnimation,
      textDirection: TextDirection.ltr,
      scrollbarOrientation: ScrollbarOrientation.left,
    );
    const Size size = Size(60, 80);
    final ScrollMetrics scrollMetrics = defaultMetrics.copyWith(
      maxScrollExtent: 100,
      viewportDimension: size.height,
      axisDirection: AxisDirection.right,
    );
    painter.update(scrollMetrics, scrollMetrics.axisDirection);

    expect(() => painter.paint(testCanvas, size), throwsA(isA<AssertionError>()));
  });

  testWidgets('RawScrollbar mainAxisMargin property works properly', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            mainAxisMargin: 10,
            isAlwaysShown: true,
            controller: scrollController,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(width: 1000.0, height: 1000.0),
            ),
          ),
        ),
      ),
    );

    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 358.0))
    );
  });

  testWidgets('shape property of RawScrollbar can draw a BeveledRectangleBorder', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            shape: const BeveledRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(8.0))
            ),
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(height: 1000.0),
            ),
          ),
        )));
    await tester.pumpAndSettle();
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..path(
          includes: const <Offset>[
            Offset(797.0, 0.0),
            Offset(797.0, 18.0),
          ],
          excludes: const <Offset>[
            Offset(796.0, 0.0),
            Offset(798.0, 0.0),
          ],
        ),
    );
  });

  testWidgets('minThumbLength property of RawScrollbar is respected', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            minThumbLength: 21,
            minOverscrollLength: 8,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(width: 1000.0, height: 50000.0),
            ),
          ),
        )));
    await tester.pumpAndSettle();
    expect(
        find.byType(RawScrollbar),
        paints
          ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) // track
          ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 21.0))); // thumb
  });

  testWidgets('shape property of RawScrollbar can draw a CircleBorder', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            shape: const CircleBorder(side: BorderSide(width: 2.0)),
            thickness: 36.0,
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(height: 1000.0, width: 1000),
            ),
          ),
        )));
    await tester.pumpAndSettle();

    expect(
      find.byType(RawScrollbar),
      paints
        ..path(
          includes: const <Offset>[
            Offset(782.0, 180.0),
            Offset(782.0, 180.0 - 18.0),
            Offset(782.0 + 18.0, 180),
            Offset(782.0, 180.0 + 18.0),
            Offset(782.0 - 18.0, 180),
          ],
        )
        ..circle(x: 782.0, y: 180.0, radius: 17.0, strokeWidth: 2.0)
    );
  });

  testWidgets('crossAxisMargin property of RawScrollbar is respected', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            crossAxisMargin: 30,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(width: 1000.0, height: 1000.0),
            ),
          ),
        )));
    await tester.pumpAndSettle();
    expect(
        find.byType(RawScrollbar),
        paints
          ..rect(rect: const Rect.fromLTRB(734.0, 0.0, 800.0, 600.0))
          ..rect(rect: const Rect.fromLTRB(764.0, 0.0, 770.0, 360.0)));
  });

  testWidgets('shape property of RawScrollbar can draw a RoundedRectangleBorder', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            thickness: 20,
            shape: const RoundedRectangleBorder(borderRadius: BorderRadius.only(topLeft: Radius.circular(8))),
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(height: 1000.0, width: 1000.0),
            ),
          ),
        )));
    await tester.pumpAndSettle();
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 600.0))
        ..path(
          includes: const <Offset>[
            Offset(800.0, 0.0),
          ],
          excludes: const <Offset>[
            Offset(780.0, 0.0),
          ],
        ),
    );
  });

  testWidgets('minOverscrollLength property of RawScrollbar is respected', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            isAlwaysShown: true,
            minOverscrollLength: 8.0,
            minThumbLength: 36.0,
            child: SingleChildScrollView(
              physics: const BouncingScrollPhysics(),
              controller: scrollController,
              child: const SizedBox(height: 10000),
            )
          ),
        )
      )
    );
    await tester.pumpAndSettle();
    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(RawScrollbar)));
    await gesture.moveBy(const Offset(0, 1000));
    await tester.pump();
    expect(
        find.byType(RawScrollbar),
        paints
          ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
          ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 8.0)));
  });

  testWidgets('not passing any shape or radius to RawScrollbar will draw the usual rectangular thumb', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(height: 1000.0),
            ),
          ),
        )));
    await tester.pumpAndSettle();

    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0))
    );
  });

  testWidgets('The bar can show or hide when the viewport size change', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    Widget buildFrame(double height) {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
                controller: scrollController,
                child: SizedBox(width: double.infinity, height: height)
            ),
          ),
        ),
      );
    }
    await tester.pumpWidget(buildFrame(600.0));
    await tester.pumpAndSettle();
    expect(find.byType(RawScrollbar), isNot(paints..rect())); // Not shown.

    await tester.pumpWidget(buildFrame(600.1));
    await tester.pumpAndSettle();
    expect(find.byType(RawScrollbar), paints..rect()); // Show the bar.

    await tester.pumpWidget(buildFrame(600.0));
    await tester.pumpAndSettle();
    expect(find.byType(RawScrollbar), isNot(paints..rect())); // Hide the bar.
  });

  testWidgets('The bar can show or hide when the window size change', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    Widget buildFrame() {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(
                  width: double.infinity,
                  height: 600.0,
                ),
              ),
            ),
          ),
        ),
      );
    }
    tester.binding.window.physicalSizeTestValue = const Size(800.0, 600.0);
    tester.binding.window.devicePixelRatioTestValue = 1;
    addTearDown(tester.binding.window.clearPhysicalSizeTestValue);
    addTearDown(tester.binding.window.clearDevicePixelRatioTestValue);

    await tester.pumpWidget(buildFrame());
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(find.byType(RawScrollbar), isNot(paints..rect())); // Not shown.

    tester.binding.window.physicalSizeTestValue = const Size(800.0, 599.0);
    await tester.pumpAndSettle();
    expect(find.byType(RawScrollbar), paints..rect()..rect()); // Show the bar.

    tester.binding.window.physicalSizeTestValue = const Size(800.0, 600.0);
    await tester.pumpAndSettle();
    expect(find.byType(RawScrollbar), isNot(paints..rect())); // Not shown.
  });

  testWidgets('Scrollbar will not flip axes based on notification is there is a scroll controller', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/87697
    final ScrollController verticalScrollController = ScrollController();
    final ScrollController horizontalScrollController = ScrollController();
    Widget buildFrame() {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            isAlwaysShown: true,
            controller: verticalScrollController,
            // This scrollbar will receive scroll notifications from both nested
            // scroll views of opposite axes, but should stay on the vertical
            // axis that its scroll controller is associated with.
            notificationPredicate: (ScrollNotification notification) => notification.depth <= 1,
            child: SingleChildScrollView(
              controller: verticalScrollController,
              child: SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                controller: horizontalScrollController,
                child: const SizedBox(
                  width: 1000.0,
                  height: 1000.0,
                ),
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildFrame());
    await tester.pumpAndSettle();
    expect(verticalScrollController.offset, 0.0);
    expect(horizontalScrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Move the horizontal scroll view. The vertical scrollbar should not flip.
    horizontalScrollController.jumpTo(10.0);
    expect(verticalScrollController.offset, 0.0);
    expect(horizontalScrollController.offset, 10.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('notificationPredicate depth test.', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    final List<int> depths = <int>[];
    Widget buildFrame() {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            notificationPredicate: (ScrollNotification notification) {
              depths.add(notification.depth);
              return notification.depth == 0;
            },
            controller: scrollController,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SingleChildScrollView(),
            ),
          ),
        ),
      );
    }
    await tester.pumpWidget(buildFrame());
    await tester.pumpAndSettle();

    // `notificationPredicate` should be called twice with different `depth`
    // because there are two scrollable widgets.
    expect(depths.length, 2);
    expect(depths[0], 1);
    expect(depths[1], 0);
  });

  // Regression test for https://github.com/flutter/flutter/issues/92262
  testWidgets('Do not crash when resize from scrollable to non-scrollable.', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    Widget buildFrame(double height) {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: RawScrollbar(
            controller: scrollController,
            interactive: true,
            isAlwaysShown: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: Container(
                width: 100.0,
                height: height,
                color: const Color(0xFF000000),
              ),
            ),
          ),
        ),
      );
    }
    await tester.pumpWidget(buildFrame(700.0));
    await tester.pumpAndSettle();

    await tester.pumpWidget(buildFrame(600.0));
    await tester.pumpAndSettle();

    // Try to drag the thumb.
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(798.0, 5.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, 5.0));
    await tester.pumpAndSettle();
  });

  testWidgets('Scrollbar thumb can be dragged when the scrollable widget has a negative minScrollExtent - desktop', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/95840

    final ScrollController scrollController = ScrollController();
    final UniqueKey uniqueKey = UniqueKey();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: ScrollConfiguration(
            behavior: const ScrollBehavior().copyWith(
              scrollbars: false,
            ),
            child: PrimaryScrollController(
              controller: scrollController,
              child: RawScrollbar(
                isAlwaysShown: true,
                controller: scrollController,
                child: CustomScrollView(
                  primary: true,
                  center: uniqueKey,
                  slivers: <Widget>[
                    SliverToBoxAdapter(
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                    SliverToBoxAdapter(
                      key: uniqueKey,
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                    SliverToBoxAdapter(
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 200.0, 800.0, 400.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Drag the thumb up to scroll up.
    const double scrollAmount = -10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 300.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    // The view has scrolled more than it would have by a swipe gesture of the
    // same distance.
    expect(scrollController.offset, lessThan(scrollAmount * 2));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 190.0, 800.0, 390.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  }, variant: TargetPlatformVariant.desktop());

  testWidgets('Scrollbar thumb can be dragged when the scrollable widget has a negative minScrollExtent - mobile', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/95840

    final ScrollController scrollController = ScrollController();
    final UniqueKey uniqueKey = UniqueKey();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: ScrollConfiguration(
            behavior: const ScrollBehavior().copyWith(
              scrollbars: false,
            ),
            child: PrimaryScrollController(
              controller: scrollController,
              child: RawScrollbar(
                isAlwaysShown: true,
                controller: scrollController,
                child: CustomScrollView(
                  center: uniqueKey,
                  slivers: <Widget>[
                    SliverToBoxAdapter(
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                    SliverToBoxAdapter(
                      key: uniqueKey,
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                    SliverToBoxAdapter(
                      child: Container(
                        height: 600.0,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 200.0, 800.0, 400.0),
          color: const Color(0x66BCBCBC),
        ),
    );

    // Drag the thumb up to scroll up.
    const double scrollAmount = -10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 300.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    // The view has scrolled more than it would have by a swipe gesture of the
    // same distance.
    expect(scrollController.offset, lessThan(scrollAmount * 2));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 190.0, 800.0, 390.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  }, variant: TargetPlatformVariant.mobile());

  test('ScrollbarPainter.shouldRepaint returns true when any of the properties changes', () {
    ScrollbarPainter createPainter({
      Color color = const Color(0xFF000000),
      Animation<double> fadeoutOpacityAnimation = kAlwaysCompleteAnimation,
      Color trackColor = const Color(0x00000000),
      Color trackBorderColor = const Color(0x00000000),
      TextDirection textDirection = TextDirection.ltr,
      double thickness = _kThickness,
      EdgeInsets padding = EdgeInsets.zero,
      double mainAxisMargin = 0.0,
      double crossAxisMargin = 0.0,
      Radius? radius,
      Radius? trackRadius,
      OutlinedBorder? shape,
      double minLength = _kMinThumbExtent,
      double? minOverscrollLength,
      ScrollbarOrientation scrollbarOrientation = ScrollbarOrientation.top,
    }) {
      return ScrollbarPainter(
        color: color,
        fadeoutOpacityAnimation: fadeoutOpacityAnimation,
        trackColor: trackColor,
        trackBorderColor: trackBorderColor,
        textDirection: textDirection,
        thickness: thickness,
        padding: padding,
        mainAxisMargin: mainAxisMargin,
        crossAxisMargin: crossAxisMargin,
        radius: radius,
        trackRadius: trackRadius,
        shape: shape,
        minLength: minLength,
        minOverscrollLength: minOverscrollLength,
        scrollbarOrientation: scrollbarOrientation,
      );
    }
    final ScrollbarPainter painter = createPainter();
    expect(painter.shouldRepaint(createPainter()), false);
    expect(painter.shouldRepaint(createPainter(color: const Color(0xFFFFFFFF))), true);
    expect(painter.shouldRepaint(createPainter(fadeoutOpacityAnimation: kAlwaysDismissedAnimation)), true);
    expect(painter.shouldRepaint(createPainter(trackColor: const Color(0xFFFFFFFF))), true);
    expect(painter.shouldRepaint(createPainter(trackBorderColor: const Color(0xFFFFFFFF))), true);
    expect(painter.shouldRepaint(createPainter(textDirection: TextDirection.rtl)), true);
    expect(painter.shouldRepaint(createPainter(thickness: _kThickness + 1.0)), true);
    expect(painter.shouldRepaint(createPainter(padding: const EdgeInsets.all(1.0))), true);
    expect(painter.shouldRepaint(createPainter(mainAxisMargin: 1.0)), true);
    expect(painter.shouldRepaint(createPainter(crossAxisMargin: 1.0)), true);
    expect(painter.shouldRepaint(createPainter(radius: const Radius.circular(1.0))), true);
    expect(painter.shouldRepaint(createPainter(trackRadius: const Radius.circular(1.0))), true);
    expect(painter.shouldRepaint(createPainter(shape: const CircleBorder(side: BorderSide(width: 2.0)))), true);
    expect(painter.shouldRepaint(createPainter(minLength: _kMinThumbExtent + 1.0)), true);
    expect(painter.shouldRepaint(createPainter(minOverscrollLength: 1.0)), true);
    expect(painter.shouldRepaint(createPainter(scrollbarOrientation: ScrollbarOrientation.bottom)), true);
  });

  testWidgets('Scrollbar track can be drawn', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: true,
              trackVisibility: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0x08000000),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0x1a000000),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('RawScrollbar correctly assigns colors', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: true,
              thumbColor: const Color(0xFFF44336),
              trackVisibility: true,
              trackColor: const Color(0xFF2196F3),
              trackBorderColor: const Color(0xFFFFEB3B),
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
          color: const Color(0xFF2196F3),
        )
        ..line(
          p1: const Offset(794.0, 0.0),
          p2: const Offset(794.0, 600.0),
          strokeWidth: 1.0,
          color: const Color(0xFFFFEB3B),
        )
        ..rect(
          rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
          color: const Color(0xFFF44336),
        ),
    );
  });

  testWidgets('trackRadius and radius properties of RawScrollbar can draw RoundedRectangularRect', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: true,
              trackVisibility: true,
              trackRadius: const Radius.circular(1.0),
              radius: const Radius.circular(2.0),
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);
    expect(
      find.byType(RawScrollbar),
      paints
        ..rrect(
          rrect: RRect.fromLTRBR(794.0, 0.0, 800.0, 600.0, const Radius.circular(1.0)),
          color: const Color(0x08000000),
        )
        ..rrect(
          rrect: RRect.fromLTRBR(794.0, 0.0, 800.0, 90.0, const Radius.circular(2.0)),
          color: const Color(0x66bcbcbc),
        )
    );
  });

  testWidgets('Scrollbar asserts that a visible track has a visible thumb', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    Widget buildApp() {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: false,
              trackVisibility: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      );
    }
    expect(() => tester.pumpWidget(buildApp()), throwsAssertionError);
  });

  testWidgets('Skip the ScrollPosition check if the bar was unmounted', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/103939
    final ScrollController scrollController = ScrollController();
    Widget buildApp(bool buildBar) {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: MediaQueryData(
            invertColors: buildBar, // Trigger a post frame check before unmount.
          ),
          child: PrimaryScrollController(
            controller: scrollController,
            child: LayoutBuilder(
              builder: (BuildContext context, BoxConstraints constraints) {
                Widget content = const SingleChildScrollView(
                  child: SizedBox(width: 4000.0, height: 4000.0),
                );
                if (buildBar) {
                  content = RawScrollbar(
                    thumbVisibility: true,
                    child: content,
                  );
                }
                return content;
              },
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildApp(true));

    await tester.pumpWidget(buildApp(false));

    // Go without throw.
  });

  testWidgets('Track offset respects MediaQuery padding', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/106834
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(
            padding: EdgeInsets.all(50.0),
          ),
          child: RawScrollbar(
            controller: scrollController,
            minThumbLength: 21,
            minOverscrollLength: 8,
            thumbVisibility: true,
            child: SingleChildScrollView(
              controller: scrollController,
              child: const SizedBox(width: 1000.0, height: 50000.0),
            ),
          ),
        )
      )
    );
    await tester.pumpAndSettle();
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(744.0, 50.0, 750.0, 550.0)) // track
        ..rect(rect: const Rect.fromLTRB(744.0, 50.0, 750.0, 71.0)) // thumb
    ); // thumb
  });

  testWidgets('RawScrollbar.padding replaces MediaQueryData.padding', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
        Directionality(
            textDirection: TextDirection.ltr,
            child: MediaQuery(
              data: const MediaQueryData(
                padding: EdgeInsets.all(50.0),
              ),
              child: RawScrollbar(
                controller: scrollController,
                minThumbLength: 21,
                minOverscrollLength: 8,
                thumbVisibility: true,
                padding: const EdgeInsets.all(100),
                child: SingleChildScrollView(
                  controller: scrollController,
                  child: const SizedBox(width: 1000.0, height: 50000.0),
                ),
              ),
            )
        )
    );
    await tester.pumpAndSettle();
    expect(
        find.byType(RawScrollbar),
        paints
          ..rect(rect: const Rect.fromLTRB(694.0, 100.0, 700.0, 500.0)) // track
          ..rect(rect: const Rect.fromLTRB(694.0, 100.0, 700.0, 121.0)) // thumb
    ); // thumb
  });

  testWidgets('Scrollbar respect the NeverScrollableScrollPhysics physics', (WidgetTester tester) async {
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                physics: NeverScrollableScrollPhysics(),
                child: SizedBox(width: 4000.0, height: 4000.0),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);

    // Drag the thumb down to scroll down.
    const double scrollAmount = 10.0;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    expect(scrollController.offset, 0.0);

    // Tap on the track area below the thumb.
    await tester.tapAt(const Offset(797.0, 550.0));
    await tester.pumpAndSettle();

    expect(scrollController.offset, 0.0);
  });

  testWidgets('The thumb should follow the pointer when the scroll metrics changed during dragging', (WidgetTester tester) async {
    // Regressing test for https://github.com/flutter/flutter/issues/112072
    final ScrollController scrollController = ScrollController();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              isAlwaysShown: true,
              controller: scrollController,
              child: CustomScrollView(
                controller: scrollController,
                // cacheExtent: double.maxFinite,
                slivers: <Widget>[
                  SliverList(
                    delegate: SliverChildBuilderDelegate(
                      (BuildContext context, int index) {
                        final double height;
                        if (index < 10) {
                          height = 100;
                        } else {
                          height = 500;
                        }
                        return SizedBox(
                          height: height,
                          child: Text('$index'),
                        );
                      },
                      childCount: 100,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);

    // Drag the thumb down to scroll down.
    const double scrollAmount = 100;
    final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 5.0));
    await tester.pumpAndSettle();
    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
    await tester.pumpAndSettle();

    await dragScrollbarGesture.up();
    await tester.pumpAndSettle();

    // The view has scrolled more than it would have by a swipe gesture of the
    // same distance.
    expect(scrollController.offset, greaterThan((100.0 * 10 + 500.0 * 90) / 3));
    expect(
      find.byType(RawScrollbar),
      paints
        ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
        ..rect(
          rect: const Rect.fromLTRB(794.0, 200.0, 800.0, 218.0),
          color: const Color(0x66BCBCBC),
        ),
    );
  });

  testWidgets('The bar support mouse wheel event', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/pull/109659
    final ScrollController scrollController = ScrollController();
    Widget buildFrame() {
      return Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: PrimaryScrollController(
            controller: scrollController,
            child: RawScrollbar(
              thumbVisibility: true,
              controller: scrollController,
              child: const SingleChildScrollView(
                primary: true,
                child: SizedBox(
                  width: double.infinity,
                  height: 1200.0,
                ),
              ),
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildFrame());
    await tester.pumpAndSettle();
    expect(scrollController.offset, 0.0);

    // Execute a pointer scroll hover on the scroll bar
    final TestPointer pointer = TestPointer(1, ui.PointerDeviceKind.mouse);
    pointer.hover(const Offset(798.0, 15.0));
    await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0)));
    await tester.pumpAndSettle();

    expect(scrollController.offset, 30.0);

    // Execute a pointer scroll outside the scroll bar
    pointer.hover(const Offset(198.0, 15.0));
    await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 70.0)));
    await tester.pumpAndSettle();

    expect(scrollController.offset, 100.0);
  }, variant: TargetPlatformVariant.all());
}