// Copyright 2016 The Chromium 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:async';

import 'package:flutter/widgets.dart';

import 'theme.dart';
import 'progress_indicator.dart';

// The over-scroll distance that moves the indicator to its maximum
// displacement, as a percentage of the scrollable's container extent.
const double _kDragContainerExtentPercentage = 0.25;

// How much the scroll's drag gesture can overshoot the RefreshIndicator's
// displacement; max displacement = _kDragSizeFactorLimit * displacement.
const double _kDragSizeFactorLimit = 1.5;

// How far the indicator must be dragged to trigger the refresh callback.
const double _kDragThresholdFactor = 0.75;

// When the scroll ends, the duration of the refresh indicator's animation
// to the RefreshIndicator's displacment.
const Duration _kIndicatorSnapDuration = const Duration(milliseconds: 150);

// The duration of the ScaleTransition that starts when the refresh action
// has completed.
const Duration _kIndicatorScaleDuration = const Duration(milliseconds: 200);

/// The signature for a function that's called when the user has dragged the
/// refresh indicator far enough to demonstrate that they want the app to
/// refresh. The returned Future must complete when the refresh operation
/// is finished.
typedef Future<Null> RefreshCallback();

/// Where the refresh indicator appears: top for over-scrolls at the
/// start of the scrollable, bottom for over-scrolls at the end.
enum RefreshIndicatorLocation {
  /// The refresh indicator should appear at the top of the scrollable.
  top,

  /// The refresh indicator should appear at the bottom of the scrollable.
  bottom,
}

/// A widget that supports the Material "swipe to refresh" idiom.
///
/// When the child's vertical Scrollable descendant overscrolls, an
/// animated circular progress indicator is faded into view. When the scroll
/// ends, if the indicator has been dragged far enough for it to become
/// completely opaque, the refresh callback is called. The callback is
/// expected to udpate the scrollback and then complete the Future it
/// returns. The refresh indicator disappears after the callback's
/// Future has completed.
///
/// See also:
///
///  * <https://www.google.com/design/spec/patterns/swipe-to-refresh.html>
class RefreshIndicator extends StatefulWidget {
  /// Creates a refresh indicator.
  ///
  /// The [refresh] and [child] arguments must be non-null. The default
  /// [displacement] is 40.0 logical pixels.
  RefreshIndicator({
    Key key,
    this.scrollableKey,
    this.child,
    this.displacement: 40.0,
    this.refresh
  }) : super(key: key) {
    assert(child != null);
    assert(refresh != null);
  }

  /// Identifies the [Scrollable] descendant of child that will cause the
  /// refresh indicator to appear. Can be null if there's only one
  /// [Scrollable] descendant.
  final Key scrollableKey;

  /// The distance from the child's top or bottom edge to where the refresh indicator
  /// will settle. During the drag that exposes the refresh indicator, its actual
  /// displacement may significantly exceed this value.
  final double displacement;

  /// A function that's called when the user has dragged the refresh indicator
  /// far enough to demonstrate that they want the app to refresh. The returned
  /// Future must complete when the refresh operation is finished.
  final RefreshCallback refresh;

  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
  final Widget child;

  @override
  _RefreshIndicatorState createState() => new _RefreshIndicatorState();
}

class _RefreshIndicatorState extends State<RefreshIndicator> {
  final AnimationController _sizeController = new AnimationController();
  final AnimationController _scaleController = new AnimationController();
  Animation<double> _sizeFactor;
  Animation<double> _scaleFactor;
  Animation<Color> _valueColor;

  double _scrollOffset;
  double _containerExtent;
  double _minScrollOffset;
  double _maxScrollOffset;
  RefreshIndicatorLocation _location = RefreshIndicatorLocation.top;

  @override
  void initState() {
    super.initState();
    _sizeFactor = new Tween<double>(begin: 0.0, end: _kDragSizeFactorLimit).animate(_sizeController);
    _scaleFactor = new Tween<double>(begin: 1.0, end: 0.0).animate(_scaleController);

    final ThemeData theme = Theme.of(context);

    // Fully opaque when we've reached config.displacement.
    _valueColor = new ColorTween(
      begin: theme.primaryColor.withOpacity(0.0),
      end: theme.primaryColor.withOpacity(1.0)
    )
    .animate(new CurvedAnimation(
      parent: _sizeController,
      curve: new Interval(0.0, 1.0 / _kDragSizeFactorLimit)
    ));
  }

  @override
  void dispose() {
    _sizeController.dispose();
    _scaleController.dispose();
    super.dispose();
  }

  void _updateState(ScrollableState scrollable) {
    final Axis axis = scrollable.config.scrollDirection;
    if (axis != Axis.vertical || scrollable.scrollBehavior is! ExtentScrollBehavior)
      return;
    final ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior;
    _scrollOffset = scrollable.scrollOffset;
    _containerExtent = scrollBehavior.containerExtent;
    _minScrollOffset = scrollBehavior.minScrollOffset;
    _maxScrollOffset = scrollBehavior.maxScrollOffset;
  }

  void _onScrollStarted(ScrollableState scrollable) {
    _updateState(scrollable);
    _scaleController.value = 0.0;
    _sizeController.value = 0.0;
  }

  RefreshIndicatorLocation get _locationForScrollOffset {
    return _scrollOffset < _minScrollOffset
      ? RefreshIndicatorLocation.top
      : RefreshIndicatorLocation.bottom;
  }

  void _onScrollUpdated(ScrollableState scrollable) {
    final double value = scrollable.scrollOffset;
    if ((value < _minScrollOffset || value > _maxScrollOffset) &&
        ((value - _scrollOffset).abs() > kPixelScrollTolerance.distance)) {
      final double overScroll = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset;
      final double newValue = overScroll / (_containerExtent * _kDragContainerExtentPercentage);
      if (newValue > _sizeController.value) {
        _sizeController.value = newValue;
        if (_location != _locationForScrollOffset) {
          setState(() {
            _location = _locationForScrollOffset;
          });
        }
      }
    }
    _updateState(scrollable);
  }

  Future<Null> _doOnScrollEnded(ScrollableState scrollable) async {
    if (_valueColor.value.alpha == 0xFF) {
      await _sizeController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration);
      await config.refresh();
    }
    return _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
  }

  void _onScrollEnded(ScrollableState scrollable) {
    _doOnScrollEnded(scrollable);
  }

  bool _handleScrollNotification(ScrollNotification notification) {
    if (config.scrollableKey == null || config.scrollableKey == notification.scrollable.config.key) {
      final ScrollableState scrollable = notification.scrollable;
      if (scrollable.config.scrollDirection != Axis.vertical)
        return false;
      switch(notification.kind) {
        case ScrollNotificationKind.started:
          _onScrollStarted(scrollable);
          break;
        case ScrollNotificationKind.updated:
          _onScrollUpdated(scrollable);
          break;
        case ScrollNotificationKind.ended:
          _onScrollEnded(scrollable);
          break;
      }
    }
    return false;
  }

  @override
  Widget build(BuildContext context) {
    final bool isAtTop = _location == RefreshIndicatorLocation.top;
    return new NotificationListener<ScrollNotification>(
      onNotification: _handleScrollNotification,
      child: new Stack(
        children: <Widget>[
          new ClampOverscrolls(
            child: config.child,
            value: true
          ),
          new Positioned(
            top: isAtTop ? 0.0 : null,
            bottom: isAtTop ? null : 0.0,
            left: 0.0,
            right: 0.0,
            child: new SizeTransition(
              axisAlignment: isAtTop ? 1.0 : 0.0,
              sizeFactor: _sizeFactor,
              child: new Container(
                padding: isAtTop
                  ? new EdgeInsets.only(top: config.displacement)
                  : new EdgeInsets.only(bottom: config.displacement),
                child: new Align(
                  alignment: isAtTop ? FractionalOffset.bottomCenter : FractionalOffset.topCenter,
                  child: new ScaleTransition(
                    scale: _scaleFactor,
                    child: new RefreshProgressIndicator(
                      value: null,
                      valueColor: _valueColor
                    )
                  )
                )
              )
            )
          )
        ]
      )
    );
  }
}