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

/// @docImport 'pinned_header_sliver.dart';
/// @docImport 'scroll_view.dart';
/// @docImport 'sliver_persistent_header.dart';
/// @docImport 'sliver_resizing_header.dart';
library;

import 'dart:math' as math;

import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';

import 'framework.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
import 'ticker_provider.dart';

/// Specifies how a partially visible [SliverFloatingHeader] animates
/// into a view when a user scroll gesture ends.
///
/// During a user scroll gesture the header and the rest of the scrollable
/// content move in sync. If the header is partially visible when the
/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
/// content as it expands until it's completely visible, or if the
/// content should scroll out of the way as the header expands.
enum FloatingHeaderSnapMode {
  /// At the end of a user scroll gesture, the [SliverFloatingHeader] will
  /// expand over the scrollable's content.
  overlay,

  /// At the end of a user scroll gesture, the [SliverFloatingHeader] will
  /// expand and the scrollable's content will continue to scroll out
  /// of the way.
  scroll,
}

/// A sliver that shows its [child] when the user scrolls forward and hides it
/// when the user scrolls backwards.
///
/// This sliver is preferable to the general purpose [SliverPersistentHeader]
/// for its relatively narrow use case because there's no need to create a
/// [SliverPersistentHeaderDelegate] or to predict the header's size.
///
/// {@tool dartpad}
/// This example shows how to create a SliverFloatingHeader.
///
/// ** See code in examples/api/lib/widgets/sliver/sliver_floating_header.0.dart **
/// {@end-tool}
///
/// See also:
///
///  * [PinnedHeaderSliver] - which just pins the header at the top
///    of the [CustomScrollView].
///  * [SliverResizingHeader] - which similarly pins the header at the top
///    of the [CustomScrollView] but reacts to scrolling by resizing the header
///    between its minimum and maximum extent limits.
///  * [SliverPersistentHeader] - a general purpose header that can be
///    configured as a pinned, resizing, or floating header.
class SliverFloatingHeader extends StatefulWidget {
  /// Create a floating header sliver that animates into view when the user
  /// scrolls forward, and disappears the user starts scrolling in the
  /// opposite direction.
  const SliverFloatingHeader({super.key, this.animationStyle, this.snapMode, required this.child});

  /// Non null properties override the default durations (300ms) and
  /// curves (Curves.easeInOut) for subsequent header animations.
  ///
  /// The reverse duration and curve apply to the animation that hides the header.
  final AnimationStyle? animationStyle;

  /// Specifies how a partially visible [SliverFloatingHeader] animates
  /// into a view when a user scroll gesture ends.
  ///
  /// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
  /// modify an animation in progress, just subsequent animations.
  final FloatingHeaderSnapMode? snapMode;

  /// The widget contained by this sliver.
  final Widget child;

  @override
  State<SliverFloatingHeader> createState() => _SliverFloatingHeaderState();
}

class _SliverFloatingHeaderState extends State<SliverFloatingHeader>
    with SingleTickerProviderStateMixin {
  ScrollPosition? position;

  @override
  Widget build(BuildContext context) {
    return _SliverFloatingHeader(
      vsync: this,
      animationStyle: widget.animationStyle,
      snapMode: widget.snapMode,
      child: _SnapTrigger(widget.child),
    );
  }
}

class _SnapTrigger extends StatefulWidget {
  const _SnapTrigger(this.child);

  final Widget child;

  @override
  _SnapTriggerState createState() => _SnapTriggerState();
}

class _SnapTriggerState extends State<_SnapTrigger> {
  ScrollPosition? position;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (position != null) {
      position!.isScrollingNotifier.removeListener(isScrollingListener);
    }
    position = Scrollable.maybeOf(context)?.position;
    if (position != null) {
      position!.isScrollingNotifier.addListener(isScrollingListener);
    }
  }

  @override
  void dispose() {
    if (position != null) {
      position!.isScrollingNotifier.removeListener(isScrollingListener);
    }
    super.dispose();
  }

  // Called when the sliver starts or ends scrolling.
  void isScrollingListener() {
    assert(position != null);
    final _RenderSliverFloatingHeader? renderer = context
        .findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>();
    renderer?.isScrollingUpdate(position!);
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
  const _SliverFloatingHeader({this.vsync, this.animationStyle, this.snapMode, super.child});

  final TickerProvider? vsync;
  final AnimationStyle? animationStyle;
  final FloatingHeaderSnapMode? snapMode;

  @override
  _RenderSliverFloatingHeader createRenderObject(BuildContext context) {
    return _RenderSliverFloatingHeader(
      vsync: vsync,
      animationStyle: animationStyle,
      snapMode: snapMode,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
    renderObject
      ..vsync = vsync
      ..animationStyle = animationStyle
      ..snapMode = snapMode;
  }
}

class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
  _RenderSliverFloatingHeader({TickerProvider? vsync, this.animationStyle, this.snapMode})
    : _vsync = vsync;

  late Animation<double> snapAnimation;
  AnimationController? snapController;
  double? lastScrollOffset;

  // The distance from the start of the header to the start of the viewport. When the
  // header is showing it varies between 0 (completely visible) and childExtent (not visible
  // because it's just above the viewport's starting edge). It's used to compute the
  // header's paintExtent which defines where the header will appear - see paint().
  late double effectiveScrollOffset;

  TickerProvider? get vsync => _vsync;
  TickerProvider? _vsync;
  set vsync(TickerProvider? value) {
    if (value == _vsync) {
      return;
    }
    _vsync = value;
    if (value == null) {
      snapController?.dispose();
      snapController = null;
    } else {
      snapController?.resync(value);
    }
  }

  AnimationStyle? animationStyle;

  FloatingHeaderSnapMode? snapMode;

  // Called each time the position's isScrollingNotifier indicates that user scrolling has
  // stopped or started, i.e. if the sliver "is scrolling".
  void isScrollingUpdate(ScrollPosition position) {
    if (position.isScrollingNotifier.value) {
      snapController?.stop();
    } else {
      final ScrollDirection direction = position.userScrollDirection;
      final bool headerIsPartiallyVisible = switch (direction) {
        ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible
        ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible
        _ => true,
      };
      if (headerIsPartiallyVisible) {
        snapController ??= AnimationController(vsync: vsync!)
          ..addListener(() {
            if (effectiveScrollOffset != snapAnimation.value) {
              effectiveScrollOffset = snapAnimation.value;
              markNeedsLayout();
            }
          });
        snapController!.duration = switch (direction) {
          ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300),
          _ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300),
        };
        snapAnimation = snapController!.drive(
          Tween<double>(
            begin: effectiveScrollOffset,
            end: switch (direction) {
              ScrollDirection.forward => 0,
              _ => childExtent,
            },
          ).chain(
            CurveTween(
              curve: switch (direction) {
                ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut,
                _ => animationStyle?.reverseCurve ?? Curves.easeInOut,
              },
            ),
          ),
        );
        snapController!.forward(from: 0.0);
      }
    }
  }

  double get childExtent {
    if (child == null) {
      return 0.0;
    }
    assert(child!.hasSize);
    return switch (constraints.axis) {
      Axis.vertical => child!.size.height,
      Axis.horizontal => child!.size.width,
    };
  }

  @override
  void detach() {
    snapController?.dispose();
    snapController = null; // lazily recreated if we're reattached.
    super.detach();
  }

  // True if the header has been laid at at least once (lastScrollOffset != null) and either:
  // - We're scrolling forward: constraints.scrollOffset < lastScrollOffset
  // - The header's already partially visible: effectiveScrollOffset < childExtent
  // Scrolling forwards (towards the scrollable's start) is the trigger that causes the
  // header to be shown.
  bool get floatingHeaderNeedsToBeUpdated {
    return lastScrollOffset != null &&
        (constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent);
  }

  @override
  void performLayout() {
    if (!floatingHeaderNeedsToBeUpdated) {
      effectiveScrollOffset = constraints.scrollOffset;
    } else {
      double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing
      if (constraints.userScrollDirection == ScrollDirection.forward) {
        if (effectiveScrollOffset > childExtent) {
          effectiveScrollOffset =
              childExtent; // The header is now just above the start edge of viewport.
        }
      } else {
        // delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
        delta = clampDouble(delta, -double.infinity, 0);
      }
      effectiveScrollOffset = clampDouble(
        effectiveScrollOffset - delta,
        0.0,
        constraints.scrollOffset,
      );
    }

    child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    final double paintExtent = childExtent - effectiveScrollOffset;
    final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
      FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
      FloatingHeaderSnapMode.scroll => paintExtent,
    };
    geometry = SliverGeometry(
      paintOrigin: math.min(constraints.overlap, 0.0),
      scrollExtent: childExtent,
      paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),
      layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent),
      maxPaintExtent: childExtent,
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
    );

    lastScrollOffset = constraints.scrollOffset;
  }

  @override
  double childMainAxisPosition(covariant RenderObject child) {
    return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent);
  }

  @override
  void applyPaintTransform(RenderObject child, Matrix4 transform) {
    assert(child == this.child);
    applyPaintTransformForBoxChild(child as RenderBox, transform);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      offset += switch (applyGrowthDirectionToAxisDirection(
        constraints.axisDirection,
        constraints.growthDirection,
      )) {
        AxisDirection.up => Offset(
          0.0,
          geometry!.paintExtent - childMainAxisPosition(child!) - childExtent,
        ),
        AxisDirection.left => Offset(
          geometry!.paintExtent - childMainAxisPosition(child!) - childExtent,
          0.0,
        ),
        AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0),
        AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)),
      };
      context.paintChild(child!, offset);
    }
  }
}
