// Copyright 2015 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 'dart:math' as math;

import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

import 'app_bar.dart';
import 'colors.dart';
import 'debug.dart';
import 'icon.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';

typedef void _TabLayoutChanged(Size size, List<double> widths);

// See https://material.google.com/components/tabs.html#tabs-specs
const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
const double _kTabIndicatorHeight = 2.0;
const double _kMinTabWidth = 72.0;
const double _kMaxTabWidth = 264.0;
const EdgeInsets _kTabLabelPadding = const EdgeInsets.symmetric(horizontal: 12.0);
const double _kTabBarScrollDrag = 0.025;
const Duration _kTabBarScroll = const Duration(milliseconds: 200);

// Curves for the leading and trailing edge of the selected tab indicator.
const Curve _kTabIndicatorLeadingCurve = Curves.easeOut;
const Curve _kTabIndicatorTrailingCurve = Curves.easeIn;

// The additional factor of 5 is to further increase sensitivity to swipe
// gestures and was determined "experimentally".
final double _kMinFlingVelocity = kPixelScrollTolerance.velocity / 5.0;

class _TabBarParentData extends ContainerBoxParentDataMixin<RenderBox> { }

class _RenderTabBar extends RenderBox with
    ContainerRenderObjectMixin<RenderBox, _TabBarParentData>,
    RenderBoxContainerDefaultsMixin<RenderBox, _TabBarParentData> {

  _RenderTabBar(this.onLayoutChanged);

  int _selectedIndex;
  int get selectedIndex => _selectedIndex;
  set selectedIndex(int value) {
    if (_selectedIndex != value) {
      _selectedIndex = value;
      markNeedsPaint();
    }
  }

  Color _indicatorColor;
  Color get indicatorColor => _indicatorColor;
  set indicatorColor(Color value) {
    if (_indicatorColor != value) {
      _indicatorColor = value;
      markNeedsPaint();
    }
  }

  Rect _indicatorRect;
  Rect get indicatorRect => _indicatorRect;
  set indicatorRect(Rect value) {
    if (_indicatorRect != value) {
      _indicatorRect = value;
      markNeedsPaint();
    }
  }

  bool _textAndIcons;
  bool get textAndIcons => _textAndIcons;
  set textAndIcons(bool value) {
    if (_textAndIcons != value) {
      _textAndIcons = value;
      markNeedsLayout();
    }
  }

  bool _isScrollable;
  bool get isScrollable => _isScrollable;
  set isScrollable(bool value) {
    if (_isScrollable != value) {
      _isScrollable = value;
      markNeedsLayout();
    }
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! _TabBarParentData)
      child.parentData = new _TabBarParentData();
  }

  @override
  double computeMinIntrinsicWidth(double height) {
    double maxWidth = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      maxWidth = math.max(maxWidth, child.getMinIntrinsicWidth(height));
      final _TabBarParentData childParentData = child.parentData;
      child = childParentData.nextSibling;
    }
    return isScrollable ? maxWidth : maxWidth * childCount;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    double maxWidth = 0.0;
    double totalWidth = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      double childWidth = child.getMaxIntrinsicWidth(height);
      maxWidth = math.max(maxWidth, childWidth);
      totalWidth += childWidth;
      final _TabBarParentData childParentData = child.parentData;
      child = childParentData.nextSibling;
    }
    return isScrollable ? totalWidth : maxWidth * childCount;
  }

  double get _tabHeight => textAndIcons ? _kTextAndIconTabHeight : _kTabHeight;
  double get _tabBarHeight => _tabHeight + _kTabIndicatorHeight;

  @override
  double computeMinIntrinsicHeight(double width) => _tabBarHeight;

  @override
  double computeMaxIntrinsicHeight(double width) => _tabBarHeight;

  void layoutFixedWidthTabs() {
    double tabWidth = size.width / childCount;
    BoxConstraints tabConstraints =
      new BoxConstraints.tightFor(width: tabWidth, height: _tabHeight);
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints);
      final _TabBarParentData childParentData = child.parentData;
      childParentData.offset = new Offset(x, 0.0);
      x += tabWidth;
      child = childParentData.nextSibling;
    }
  }

  double layoutScrollableTabs() {
    BoxConstraints tabConstraints = new BoxConstraints(
      minWidth: _kMinTabWidth,
      maxWidth: _kMaxTabWidth,
      minHeight: _tabHeight,
      maxHeight: _tabHeight
    );
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints, parentUsesSize: true);
      final _TabBarParentData childParentData = child.parentData;
      childParentData.offset = new Offset(x, 0.0);
      x += child.size.width;
      child = childParentData.nextSibling;
    }
    return x;
  }

  Size layoutSize;
  List<double> layoutWidths;
  _TabLayoutChanged onLayoutChanged;

  void reportLayoutChangedIfNeeded() {
    assert(onLayoutChanged != null);
    List<double> widths = new List<double>(childCount);
    if (!isScrollable && childCount > 0) {
      double tabWidth = size.width / childCount;
      widths.fillRange(0, widths.length, tabWidth);
    } else if (isScrollable) {
      RenderBox child = firstChild;
      int childIndex = 0;
      while (child != null) {
        widths[childIndex++] = child.size.width;
        final _TabBarParentData childParentData = child.parentData;
        child = childParentData.nextSibling;
      }
      assert(childIndex == widths.length);
    }
    if (size != layoutSize || widths != layoutWidths) {
      layoutSize = size;
      layoutWidths = widths;
      onLayoutChanged(layoutSize, layoutWidths);
    }
  }

  @override
  void performLayout() {
    assert(constraints is BoxConstraints);
    if (childCount == 0)
      return;

    if (isScrollable) {
      double tabBarWidth = layoutScrollableTabs();
      size = constraints.constrain(new Size(tabBarWidth, _tabBarHeight));
    } else {
      size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight));
      layoutFixedWidthTabs();
    }

    if (onLayoutChanged != null)
      reportLayoutChangedIfNeeded();
  }

  @override
  bool hitTestChildren(HitTestResult result, { Point position }) {
    return defaultHitTestChildren(result, position: position);
  }

  void _paintIndicator(Canvas canvas, RenderBox selectedTab, Offset offset) {
    if (indicatorColor == null)
      return;

    if (indicatorRect != null) {
      canvas.drawRect(indicatorRect.shift(offset), new Paint()..color = indicatorColor);
      return;
    }

    final Size size = new Size(selectedTab.size.width, _kTabIndicatorHeight);
    final _TabBarParentData selectedTabParentData = selectedTab.parentData;
    final Point point = new Point(
      selectedTabParentData.offset.dx,
      _tabBarHeight - _kTabIndicatorHeight
    );
    canvas.drawRect((point + offset) & size, new Paint()..color = indicatorColor);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    int index = 0;
    RenderBox child = firstChild;
    while (child != null) {
      final _TabBarParentData childParentData = child.parentData;
      context.paintChild(child, childParentData.offset + offset);
      if (index++ == selectedIndex)
        _paintIndicator(context.canvas, child, offset);
      child = childParentData.nextSibling;
    }
  }
}

class _TabBarWrapper extends MultiChildRenderObjectWidget {
  _TabBarWrapper({
    Key key,
    List<Widget> children,
    this.selectedIndex,
    this.indicatorColor,
    this.indicatorRect,
    this.textAndIcons,
    this.isScrollable: false,
    this.onLayoutChanged
  }) : super(key: key, children: children);

  final int selectedIndex;
  final Color indicatorColor;
  final Rect indicatorRect;
  final bool textAndIcons;
  final bool isScrollable;
  final _TabLayoutChanged onLayoutChanged;

  @override
  _RenderTabBar createRenderObject(BuildContext context) {
    _RenderTabBar result = new _RenderTabBar(onLayoutChanged);
    updateRenderObject(context, result);
    return result;
  }

  @override
  void updateRenderObject(BuildContext context, _RenderTabBar renderObject) {
    renderObject
      ..selectedIndex = selectedIndex
      ..indicatorColor = indicatorColor
      ..indicatorRect = indicatorRect
      ..textAndIcons = textAndIcons
      ..isScrollable = isScrollable
      ..onLayoutChanged = onLayoutChanged;
  }
}

/// Signature for building icons for [TabLabel]s.
///
/// Used by [TabLabel.iconBuilder].
typedef Widget TabLabelIconBuilder(BuildContext context, Color color);

/// Each TabBar tab can display either a title [text], an icon, or both. An icon
/// can be specified by either the [icon] or [iconBuilder] parameters. In either
/// case the icon will occupy a 24x24 box above the title text. If iconBuilder
/// is specified its color parameter is the color that an ordinary icon would
/// have been drawn with. The color reflects that tab's selection state.
class TabLabel {
  /// Creates a tab label description.
  ///
  /// At least one of [text], [icon], or [iconBuilder] must be non-null.
  const TabLabel({ this.text, this.icon, this.iconBuilder });

  /// The text to display as the label of the tab.
  final String text;

  /// The icon to display as the label of the tab.
  ///
  /// The size and color of the icon is configured automatically using an
  /// [IconTheme] and therefore does not need to be explicitly given in the
  /// icon widget.
  ///
  /// See [Icon], [ImageIcon].
  final Widget icon;

  /// Called if [icon] is null to build an icon as a label for this tab.
  ///
  /// The color argument to this builder is the color that an ordinary icon
  /// would have been drawn with. The color reflects that tab's selection state.
  ///
  /// Return value must be non-null.
  final TabLabelIconBuilder iconBuilder;

  /// Whether this label has any text (specified using [text]).
  bool get hasText => text != null;

  /// Whether this label has an icon (specified either using [icon] or [iconBuilder]).
  bool get hasIcon => icon != null || iconBuilder != null;
}

class _Tab extends StatelessWidget {
  _Tab({
    Key key,
    this.onSelected,
    this.label,
    this.color
  }) : super(key: key) {
    assert(label.hasText || label.hasIcon);
  }

  final VoidCallback onSelected;
  final TabLabel label;
  final Color color;

  Widget _buildLabelText() {
    assert(label.text != null);
    TextStyle style = new TextStyle(color: color);
    return new Text(
      label.text,
      style: style,
      softWrap: false,
      overflow: TextOverflow.fade
    );
  }

  Widget _buildLabelIcon(BuildContext context) {
    assert(label.hasIcon);
    if (label.icon != null) {
      return new IconTheme.merge(
        context: context,
        data: new IconThemeData(
          color: color,
          size: 24.0
        ),
        child: label.icon
      );
    } else {
      return new SizedBox(
        width: 24.0,
        height: 24.0,
        child: label.iconBuilder(context, color)
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
    Widget labelContent;
    if (!label.hasIcon) {
      labelContent = _buildLabelText();
    } else if (!label.hasText) {
      labelContent = _buildLabelIcon(context);
    } else {
      labelContent = new Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          new Container(
            child: _buildLabelIcon(context),
            margin: const EdgeInsets.only(bottom: 10.0)
          ),
          _buildLabelText()
        ]
      );
    }

    Container centeredLabel = new Container(
      child: new Center(child: labelContent, widthFactor: 1.0, heightFactor: 1.0),
      constraints: new BoxConstraints(minWidth: _kMinTabWidth),
      padding: _kTabLabelPadding
    );

    return new InkWell(
      onTap: onSelected,
      child: centeredLabel
    );
  }

  @override
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('$label');
  }
}

class _TabsScrollBehavior extends BoundedBehavior {
  _TabsScrollBehavior();

  @override
  bool isScrollable = true;

  @override
  Simulation createScrollSimulation(double position, double velocity) {
    if (!isScrollable)
      return null;

    double velocityPerSecond = velocity * 1000.0;
    return new BoundedFrictionSimulation(
      _kTabBarScrollDrag, position, velocityPerSecond, minScrollOffset, maxScrollOffset
    );
  }

  @override
  double applyCurve(double scrollOffset, double scrollDelta) {
    return (isScrollable) ? super.applyCurve(scrollOffset, scrollDelta) : 0.0;
  }
}

/// An abstract interface through which [TabBarSelection] reports changes.
abstract class TabBarSelectionAnimationListener {
  /// Called when the status of the [TabBarSelection] animation changes.
  void handleStatusChange(AnimationStatus status);

  /// Called on each animation frame when the [TabBarSelection] animation ticks.
  void handleProgressChange();

  /// Called when the [TabBarSelection] is deactivated.
  ///
  /// Implementations typically drop their reference to the [TabBarSelection]
  /// during this callback.
  void handleSelectionDeactivate();
}

/// Coordinates the tab selection between a [TabBar] and a [TabBarView].
///
/// Place a [TabBarSelection] widget in the tree such that it is a common
/// ancestor of both the [TabBar] and the [TabBarView]. Both the [TabBar] and
/// the [TabBarView] can alter which tab is selected. They coodinate by
/// listening to the selection value stored in a common ancestor
/// [TabBarSelection] selection widget.
class TabBarSelection<T> extends StatefulWidget {
  /// Creates a tab bar selection.
  ///
  /// The values argument must be non-null, non-empty, and each value must be
  /// unique. The value argument must either be null or contained in the values
  /// argument. The child argument must be non-null.
  TabBarSelection({
    Key key,
    this.value,
    @required this.values,
    this.onChanged,
    @required this.child
  }) : super(key: key)  {
    assert(values != null && values.length > 0);
    assert(new Set<T>.from(values).length == values.length);
    assert(value == null ? true : values.where((T e) => e == value).length == 1);
    assert(child != null);
  }

  /// The current value of the selection.
  final T value;

  /// The list of possible values that the selection can obtain.
  List<T> values;

  /// Called when the value of the selection should change.
  ///
  /// The tab bar selection passes the new value to the callback but does not
  /// actually change state until the parent widget rebuilds the tab bar
  /// selection with the new value.
  ///
  /// If null, the tab bar selection cannot change value.
  final ValueChanged<T> onChanged;

  /// The widget below this widget in the tree.
  final Widget child;

  @override
  TabBarSelectionState<T> createState() => new TabBarSelectionState<T>();

  /// The state from the closest instance of this class that encloses the given context.
  ///
  /// Typical usage is as follows:
  ///
  /// ```dart
  /// TabBarSelectionState<Foo> tabState = TabBarSelection.of/*<Foo>*/(context);
  /// ```
  static TabBarSelectionState<dynamic/*=T*/> of/*<T>*/(BuildContext context) {
    return context.ancestorStateOfType(new TypeMatcher<TabBarSelectionState<dynamic/*=T*/>>());
  }

  @override
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('current tab: $value');
    description.add('available tabs: $values');
  }
}

/// State for a [TabBarSelection] widget.
///
/// Subclasses of [TabBarSelection] typically use [State] objects that extend
/// this class.
class TabBarSelectionState<T> extends State<TabBarSelection<T>> with SingleTickerProviderStateMixin {

  // Both the TabBar and TabBarView classes access _controller because they
  // alternately drive selection progress between tabs.
  AnimationController _controller;

  /// An animation that updates as the selected tab changes.
  Animation<double> get animation => _controller.view;

  final Map<T, int> _valueToIndex = new Map<T, int>();

  @override
  void initState() {
    super.initState();

    _controller = new AnimationController(
      duration: _kTabBarScroll,
      value: 1.0,
      vsync: this,
    );

    _value = config.value ?? PageStorage.of(context)?.readState(context) ?? values.first;

    // If the selection's values have changed since the selected value was saved with
    // PageStorage.writeState() then use the default.
    if (!values.contains(_value))
      _value = values.first;

    _previousValue = _value;
    _initValueToIndex();
  }

  @override
  void didUpdateConfig(TabBarSelection<T> oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (values != oldConfig.values)
      _initValueToIndex();
  }

  void _initValueToIndex() {
    _valueToIndex.clear();
    int index = 0;
    for (T value in values)
      _valueToIndex[value] = index++;
  }

  void _writeValue() {
    PageStorage.of(context)?.writeState(context, _value);
  }

  /// The list of possible values that the selection can obtain.
  List<T> get values => config.values;

  /// The previously selected value.
  ///
  /// When the tab selection changes, the tab selection animates from the
  /// previously selected value to the new value.
  T get previousValue => _previousValue;
  T _previousValue;

  /// Whether the tab selection is in the process of animating from one value to
  /// another.
  // TODO(abarth): Try computing this value from _controller.state so we don't
  // need to keep a separate bool in sync.
  bool get valueIsChanging => _valueIsChanging;
  bool _valueIsChanging = false;

  /// The index of a given value in [values].
  ///
  /// Runs in constant time.
  int indexOf(T tabValue) => _valueToIndex[tabValue];

  /// The index of the currently selected value.
  int get index => _valueToIndex[value];

  /// The index of the previoulsy selected value.
  int get previousIndex => indexOf(_previousValue);

  /// The currently selected value.
  ///
  /// Writing to this field will cause the tab selection to animate from the
  /// previous value to the new value.
  T get value => _value;
  T _value;
  set value(T newValue) {
    if (newValue == _value)
      return;
    _previousValue = _value;
    _value = newValue;
    _writeValue();
    _valueIsChanging = true;

    // If the selected value change was triggered by a drag gesture, the current
    // value of _controller.value will reflect where the gesture ended. While
    // the drag was underway the controller's value indicates where the indicator
    // and TabBarView scrollPositions are vis the indices of the two tabs adjacent
    // to the selected one. So 0.5 means the drag didn't move at all, 0.0 means the
    // drag extended to the beginning of the tab on the left and 1.0 likewise for
    // the tab on the right. That is unless the index of the selected value was 0
    // or values.length - 1. In those cases the controller's value just moves between
    // the selected tab and the adjacent one. So: convert the controller's value
    // here to reflect the fact that we're now moving between (just) the previous
    // and current selection index.

    double value;
    if (_controller.status == AnimationStatus.completed)
      value = 0.0;
    else if (_previousValue == values.first)
      value = _controller.value;
    else if (_previousValue == values.last)
      value = 1.0 - _controller.value;
    else if (previousIndex < index)
      value = (_controller.value - 0.5) * 2.0;
    else
      value = 1.0 - _controller.value * 2.0;

    _controller
      ..value = value
      ..forward().then((_) {
        // TODO(abarth): Consider using a status listener and checking for
        // AnimationStatus.completed.
        if (_controller.value == 1.0) {
          if (config.onChanged != null)
            config.onChanged(_value);
          _valueIsChanging = false;
        }
      });
  }

  final List<TabBarSelectionAnimationListener> _animationListeners = <TabBarSelectionAnimationListener>[];

  /// Calls listener methods every time the value or status of the selection animation changes.
  ///
  /// Listeners can be removed with [removeAnimationListener].
  void addAnimationListener(TabBarSelectionAnimationListener listener) {
    _animationListeners.add(listener);
    _controller
      ..addStatusListener(listener.handleStatusChange)
      ..addListener(listener.handleProgressChange);
  }

  /// Stop calling listener methods every time the value or status of the animation changes.
  ///
  /// Listeners can be added with [addAnimationListener].
  void removeAnimationListener(TabBarSelectionAnimationListener listener) {
    _animationListeners.remove(listener);
    _controller
      ..removeStatusListener(listener.handleStatusChange)
      ..removeListener(listener.handleProgressChange);
  }

  @override
  void deactivate() {
    _controller.stop();
    for (TabBarSelectionAnimationListener listener in _animationListeners.toList()) {
      listener.handleSelectionDeactivate();
      removeAnimationListener(listener);
    }
    assert(_animationListeners.isEmpty);
    _writeValue();
    super.deactivate();
  }

  @override
  Widget build(BuildContext context) {
    return config.child;
  }
}

// Used when the user is dragging the TabBar or the TabBarView left or right.
// Dragging from the selected tab to the left varies t between 0.5 and 0.0.
// Dragging towards the tab on the right varies t between 0.5 and 1.0.
class _TabIndicatorTween extends Tween<Rect> {
  _TabIndicatorTween({ Rect begin, this.middle, Rect end }) : super(begin: begin, end: end);

  final Rect middle;

  @override
  Rect lerp(double t) {
    return t <= 0.5
      ? Rect.lerp(begin, middle, t * 2.0)
      : Rect.lerp(middle, end, (t - 0.5) * 2.0);
    }
}

/// A widget that displays a horizontal row of tabs, one per label.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
///  * [TabBarSelection]
///  * [TabBarView]
///  * [AppBar.tabBar]
///  * <https://material.google.com/components/tabs.html>
class TabBar<T> extends Scrollable implements AppBarBottomWidget {
  /// Creates a widget that displays a horizontal row of tabs, one per label.
  ///
  /// The [labels] argument must not be null.
  TabBar({
    Key key,
    @required this.labels,
    this.isScrollable: false,
    this.indicatorColor,
    this.labelColor
  }) : super(key: key, scrollDirection: Axis.horizontal) {
    assert(labels != null);
  }

  /// The labels to display in the tabs.
  ///
  /// The [TabBarSelection.values] are used as keys for this map to determine
  /// which tab label is selected.
  final Map<T, TabLabel> labels;

  /// Whether this tab bar can be scrolled horizontally.
  ///
  /// If [isScrollable] is true then each tab is as wide as needed for its label
  /// and the entire [TabBar] is scrollable. Otherwise each tab gets an equal
  /// share of the available space.
  final bool isScrollable;

  /// The color of the line that appears below the selected tab. If this parameter
  /// is null then the value of the Theme's indicatorColor property is used.
  final Color indicatorColor;

  /// The color of selected tab labels. Unselected tab labels are rendered
  /// with the same color rendered at 70% opacity. If this parameter is null then
  /// the color of the theme's body2 text color is used.
  final Color labelColor;

  /// The height of the tab labels and indicator.
  @override
  double get bottomHeight {
    for (TabLabel label in labels.values) {
      if (label.hasText && label.hasIcon)
        return _kTextAndIconTabHeight + _kTabIndicatorHeight;
    }
    return _kTabHeight + _kTabIndicatorHeight;
  }

  @override
  _TabBarState<T> createState() => new _TabBarState<T>();
}

class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelectionAnimationListener {
  TabBarSelectionState<T> _selection;
  bool _valueIsChanging = false;
  int _lastSelectedIndex = -1;

  void _initSelection(TabBarSelectionState<T> newSelection) {
    if (_selection == newSelection)
      return;
    _selection?.removeAnimationListener(this);
    _selection = newSelection;
    _selection?.addAnimationListener(this);
    if (_selection != null)
      _lastSelectedIndex = _selection.index;
  }

  @override
  void didUpdateConfig(TabBar<T> oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (config.isScrollable != oldConfig.isScrollable) {
      scrollBehavior.isScrollable = config.isScrollable;
      if (!config.isScrollable)
        scrollTo(0.0);
    }
  }

  @override
  void dispose() {
    _selection?.removeAnimationListener(this);
    super.dispose();
  }

  @override
  void handleSelectionDeactivate() {
    _selection = null;
  }

  // Initialize _indicatorTween for interactive dragging between the tab on the left
  // and the tab on the right. In this case _selection.animation.value is 0.5 when
  // the indicator is below the selected tab, 0.0 when it's under the left tab, and 1.0
  // when it's under the tab on the right.
  void _initIndicatorTweenForDrag() {
    assert(!_valueIsChanging);
    int index = _selection.index;
    int beginIndex = math.max(0, index - 1);
    int endIndex = math.min(config.labels.length - 1, index + 1);
    if (beginIndex == index || endIndex == index) {
      _indicatorTween = new RectTween(
        begin: _tabIndicatorRect(beginIndex),
        end: _tabIndicatorRect(endIndex)
      );
    } else {
      _indicatorTween = new _TabIndicatorTween(
        begin: _tabIndicatorRect(beginIndex),
        middle: _tabIndicatorRect(index),
        end: _tabIndicatorRect(endIndex)
      );
    }
  }

  // Initialize _indicatorTween for animating the selected tab indicator from the
  // previously selected tab to the newly selected one. In this case
  // _selection.animation.value is 0.0 when the indicator is below the previously
  // selected tab, and 1.0 when it's under the newly selected one.
  void _initIndicatorTweenForAnimation() {
    assert(_valueIsChanging);
    _indicatorTween = new RectTween(
      begin: _indicatorRect ?? _tabIndicatorRect(_selection.previousIndex),
      end: _tabIndicatorRect(_selection.index)
    );
  }

  @override
  void handleStatusChange(AnimationStatus status) {
    if (config.labels.length == 0)
      return;

    if (_valueIsChanging && status == AnimationStatus.completed) {
      _valueIsChanging = false;
      setState(() {
        _initIndicatorTweenForDrag();
        _indicatorRect = _tabIndicatorRect(_selection.index);
      });
    }
  }

  @override
  void handleProgressChange() {
    if (config.labels.length == 0 || _selection == null)
      return;

    if (_lastSelectedIndex != _selection.index) {
      _valueIsChanging = true;
      if (config.isScrollable)
        scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
      _initIndicatorTweenForAnimation();
      _lastSelectedIndex = _selection.index;
    } else if (_indicatorTween == null) {
      _initIndicatorTweenForDrag();
    }

    Rect oldRect = _indicatorRect;
    double t = _selection.animation.value;

    // When _valueIsChanging is false, we're animating based on drag gesture and
    // want linear selected tab indicator motion. When _valueIsChanging is true,
    // a ticker is driving the selection change and we want to curve the animation.
    // In this case the leading and trailing edges of the move at different rates.
    // The easiest way to do this is to lerp 2 rects, and piece them together into 1.
    if (!_valueIsChanging) {
      _indicatorRect = _indicatorTween.lerp(t);
    } else {
      Rect leftRect, rightRect;
      if (_selection.index > _selection.previousIndex) {
        // Moving to the right - right edge is leading.
        rightRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
        leftRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
      } else {
        // Moving to the left - left edge is leading.
        leftRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
        rightRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
      }
      _indicatorRect = new Rect.fromLTRB(
        leftRect.left, leftRect.top, rightRect.right, rightRect.bottom
      );
    }
    if (oldRect != _indicatorRect)
      setState(() { /* The indicator rect has changed. */ });
  }

  Size _viewportSize = Size.zero;
  Size _tabBarSize;
  List<double> _tabWidths;
  Rect _indicatorRect;
  Tween<Rect> _indicatorTween;

  Rect _tabRect(int tabIndex) {
    assert(_tabBarSize != null);
    assert(_tabWidths != null);
    assert(tabIndex >= 0 && tabIndex < _tabWidths.length);
    double tabLeft = 0.0;
    if (tabIndex > 0)
      tabLeft = _tabWidths.take(tabIndex).reduce((double sum, double width) => sum + width);
    final double tabTop = 0.0;
    final double tabBottom = _tabBarSize.height - _kTabIndicatorHeight;
    final double tabRight = tabLeft + _tabWidths[tabIndex];
    return new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
  }

  Rect _tabIndicatorRect(int tabIndex) {
    Rect r = _tabRect(tabIndex);
    return new Rect.fromLTRB(r.left, r.bottom, r.right, r.bottom + _kTabIndicatorHeight);
  }

  @override
  ExtentScrollBehavior createScrollBehavior() {
    return new _TabsScrollBehavior()
      ..isScrollable = config.isScrollable;
  }

  @override
  _TabsScrollBehavior get scrollBehavior => super.scrollBehavior;

  double _centeredTabScrollOffset(int tabIndex) {
    double viewportWidth = scrollBehavior.containerExtent;
    Rect tabRect = _tabRect(tabIndex);
    return (tabRect.left + tabRect.width / 2.0 - viewportWidth / 2.0)
      .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
  }

  void _handleTabSelected(int tabIndex) {
    if (_selection != null && tabIndex != _selection.index)
      setState(() {
        _selection.value = _selection.values[tabIndex];
      });
  }

  Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) {
    Color labelColor = color;
    if (_selection != null) {
      final bool isSelectedTab = tabIndex == _selection.index;
      final bool isPreviouslySelectedTab = tabIndex == _selection.previousIndex;
      labelColor = isSelectedTab ? selectedColor : color;
      if (_selection.valueIsChanging) {
        if (isSelectedTab)
          labelColor = Color.lerp(color, selectedColor, _selection.animation.value);
        else if (isPreviouslySelectedTab)
          labelColor = Color.lerp(selectedColor, color, _selection.animation.value);
      }
    }
    return new _Tab(
      onSelected: () { _handleTabSelected(tabIndex); },
      label: label,
      color: labelColor
    );
  }

  void _updateScrollBehavior() {
    didUpdateScrollBehavior(scrollBehavior.updateExtents(
      containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width,
      contentExtent: _tabWidths.reduce((double sum, double width) => sum + width),
      scrollOffset: scrollOffset
    ));
  }

  void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
    // This is bad. We should use a LayoutBuilder or CustomMultiChildLayout or some such.
    // As designed today, tabs are always lagging one frame behind, taking two frames
    // to handle a layout change.
    _tabBarSize = tabBarSize;
    _tabWidths = tabWidths;
    _indicatorRect = _selection != null ? _tabIndicatorRect(_selection.index) : Rect.zero;
    _updateScrollBehavior();
    SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
      if (mounted) {
        setState(() {
          // the changes were made at layout time
          // TODO(ianh): remove this setState: https://github.com/flutter/flutter/issues/5749
        });
      }
    });
  }

  Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
    // We make various state changes here but don't have to do so in a
    // setState() callback because we are called during layout and all
    // we're updating is the new offset, which we are providing to the
    // render object via our return value.
    _viewportSize = dimensions.containerSize;
    _updateScrollBehavior();
    if (config.isScrollable && _selection != null)
      scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
    return scrollOffsetToPixelDelta(scrollOffset);
  }

  @override
  Widget buildContent(BuildContext context) {
    TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
    _initSelection(newSelection);

    assert(config.labels.isNotEmpty);
    assert(Material.of(context) != null);

    ThemeData themeData = Theme.of(context);
    Color backgroundColor = Material.of(context).color;
    Color indicatorColor = config.indicatorColor ?? themeData.indicatorColor;
    if (indicatorColor == backgroundColor) {
      // ThemeData tries to avoid this by having indicatorColor avoid being the
      // primaryColor. However, it's possible that the tab bar is on a
      // Material that isn't the primaryColor. In that case, if the indicator
      // color ends up clashing, then this overrides it. When that happens,
      // automatic transitions of the theme will likely look ugly as the
      // indicator color suddenly snaps to white at one end, but it's not clear
      // how to avoid that any further.
      indicatorColor = Colors.white;
    }

    final TextStyle textStyle = themeData.primaryTextTheme.body2;
    final Color selectedLabelColor = config.labelColor ?? themeData.primaryTextTheme.body2.color;
    final Color labelColor = selectedLabelColor.withAlpha(0xB2); // 70% alpha

    List<Widget> tabs = <Widget>[];
    bool textAndIcons = false;
    int tabIndex = 0;
    for (TabLabel label in config.labels.values) {
      tabs.add(_toTab(label, tabIndex++, labelColor, selectedLabelColor));
      if (label.hasText && label.hasIcon)
        textAndIcons = true;
    }

    Widget contents = new DefaultTextStyle(
      style: textStyle,
      child: new _TabBarWrapper(
        children: tabs,
        selectedIndex: _selection?.index,
        indicatorColor: indicatorColor,
        indicatorRect: _indicatorRect,
        textAndIcons: textAndIcons,
        isScrollable: config.isScrollable,
        onLayoutChanged: _layoutChanged
      )
    );

    if (config.isScrollable) {
      return new Viewport(
        mainAxis: Axis.horizontal,
        paintOffset: scrollOffsetToPixelDelta(scrollOffset),
        onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
        child: contents
      );
    }

    return contents;
  }
}

/// A widget that displays the contents of a tab.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
///
///  * [TabBarSelection]
///  * [TabBar]
///  * <https://material.google.com/components/tabs.html>
class TabBarView<T> extends PageableList {
  /// Creates a widget that displays the contents of a tab.
  ///
  /// The [children] argument must not be null and must not be empty.
  TabBarView({
    Key key,
    @required List<Widget> children
  }) : super(
    key: key,
    scrollDirection: Axis.horizontal,
    children: children
  ) {
    assert(children != null);
    assert(children.length > 1);
  }

  @override
  _TabBarViewState<T> createState() => new _TabBarViewState<T>();
}

class _TabBarViewState<T> extends PageableListState<TabBarView<T>> implements TabBarSelectionAnimationListener {

  TabBarSelectionState<T> _selection;
  List<Widget> _items;

  int get _tabCount => config.children.length;

  BoundedBehavior _boundedBehavior;

  @override
  ExtentScrollBehavior get scrollBehavior {
    _boundedBehavior ??= new BoundedBehavior(platform: platform);
    return _boundedBehavior;
  }

  @override
  TargetPlatform get platform => Theme.of(context).platform;

  void _initSelection(TabBarSelectionState<T> newSelection) {
    if (_selection == newSelection)
      return;
    _selection?.removeAnimationListener(this);
    _selection = newSelection;
    _selection?.addAnimationListener(this);
    if (_selection != null)
      _updateItemsAndScrollBehavior();
  }

  @override
  void didUpdateConfig(TabBarView<T> oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (_selection != null && config.children != oldConfig.children)
      _updateItemsForSelectedIndex(_selection.index);
  }

  @override
  void dispose() {
    _selection?.removeAnimationListener(this);
    super.dispose();
  }

  @override
  void handleSelectionDeactivate() {
    _selection = null;
  }

  void _updateItemsFromChildren(int first, int second, [int third]) {
    List<Widget> widgets = config.children;
    _items = <Widget>[
      new KeyedSubtree.wrap(widgets[first], first),
      new KeyedSubtree.wrap(widgets[second], second),
    ];
    if (third != null)
      _items.add(new KeyedSubtree.wrap(widgets[third], third));
  }

  void _updateItemsForSelectedIndex(int selectedIndex) {
    if (selectedIndex == 0) {
      _updateItemsFromChildren(0, 1);
    } else if (selectedIndex == _tabCount - 1) {
      _updateItemsFromChildren(selectedIndex - 1, selectedIndex);
    } else {
      _updateItemsFromChildren(selectedIndex - 1, selectedIndex, selectedIndex + 1);
    }
  }

  void _updateScrollBehaviorForSelectedIndex(int selectedIndex) {
    if (selectedIndex == 0) {
      didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 0.0));
    } else if (selectedIndex == _tabCount - 1) {
      didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 1.0));
    } else {
      didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 3.0, containerExtent: 1.0, scrollOffset: 1.0));
    }
  }

  void _updateItemsAndScrollBehavior() {
    assert(_selection != null);
    final int selectedIndex = _selection.index;
    assert(selectedIndex != null);
    _updateItemsForSelectedIndex(selectedIndex);
    _updateScrollBehaviorForSelectedIndex(selectedIndex);
  }

  @override
  void handleStatusChange(AnimationStatus status) {
  }

  @override
  void handleProgressChange() {
    if (_selection == null || !_selection.valueIsChanging)
      return;
    // The TabBar is driving the TabBarSelection animation.

    final Animation<double> animation = _selection.animation;

    if (animation.status == AnimationStatus.completed) {
      _updateItemsAndScrollBehavior();
      return;
    }

    if (animation.status != AnimationStatus.forward)
      return;

    final int selectedIndex = _selection.index;
    final int previousSelectedIndex = _selection.previousIndex;

    if (selectedIndex < previousSelectedIndex) {
      _updateItemsFromChildren(selectedIndex, previousSelectedIndex);
      scrollTo(new CurveTween(curve: Curves.fastOutSlowIn.flipped).evaluate(new ReverseAnimation(animation)));
    } else {
      _updateItemsFromChildren(previousSelectedIndex, selectedIndex);
      scrollTo(new CurveTween(curve: Curves.fastOutSlowIn).evaluate(animation));
    }
  }

  @override
  void dispatchOnScroll() {
    if (_selection == null || _selection.valueIsChanging)
      return;
    // This class is driving the TabBarSelection's animation.

    final AnimationController controller = _selection._controller;

    if (_selection.index == 0 || _selection.index == _tabCount - 1)
      controller.value = scrollOffset;
    else
      controller.value = scrollOffset / 2.0;
  }

  @override
  Future<Null> fling(double scrollVelocity) {
    if (_selection == null || _selection.valueIsChanging)
      return new Future<Null>.value();

    if (scrollVelocity.abs() > _kMinFlingVelocity) {
      final int selectionDelta = scrollVelocity.sign.truncate();
      final int targetIndex = (_selection.index + selectionDelta).clamp(0, _tabCount - 1);
      if (_selection.index != targetIndex) {
        _selection.value = _selection.values[targetIndex];
        return new Future<Null>.value();
      }
    }

    final int selectionIndex = _selection.index;
    final int settleIndex = snapScrollOffset(scrollOffset).toInt();
    if (selectionIndex > 0 && settleIndex != 1) {
      final int targetIndex = (selectionIndex + (settleIndex == 2 ? 1 : -1)).clamp(0, _tabCount - 1);
      _selection.value = _selection.values[targetIndex];
      return new Future<Null>.value();
    } else if (selectionIndex == 0 && settleIndex == 1) {
      _selection.value = _selection.values[1];
      return new Future<Null>.value();
    }
    return settleScrollOffset();
  }

  @override
  Widget buildContent(BuildContext context) {
    TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
    _initSelection(newSelection);
    return new PageViewport(
      itemsWrap: config.itemsWrap,
      mainAxis: config.scrollDirection,
      startOffset: scrollOffset,
      children: _items
    );
  }
}

/// A widget that displays a visual indicator of which tab is selected.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
///
///  * [TabBarSelection]
///  * [TabBarView]
class TabPageSelector<T> extends StatelessWidget {
  /// Creates a widget that displays a visual indicator of which tab is selected.
  ///
  /// Requires one of its ancestors to be a [TabBarSelection] widget to enable
  /// saving and monitoring the selected tab.
  const TabPageSelector({ Key key }) : super(key: key);

  Widget _buildTabIndicator(TabBarSelectionState<T> selection, T tab, Animation<double> animation, ColorTween selectedColor, ColorTween previousColor) {
    Color background;
    if (selection.valueIsChanging) {
      // The selection's animation is animating from previousValue to value.
      if (selection.value == tab)
        background = selectedColor.evaluate(animation);
      else if (selection.previousValue == tab)
        background = previousColor.evaluate(animation);
      else
        background = selectedColor.begin;
    } else {
      background = selection.value == tab ? selectedColor.end : selectedColor.begin;
    }
    return new Container(
      width: 12.0,
      height: 12.0,
      margin: new EdgeInsets.all(4.0),
      decoration: new BoxDecoration(
        backgroundColor: background,
        border: new Border.all(color: selectedColor.end),
        shape: BoxShape.circle
      )
    );
  }

  @override
  Widget build(BuildContext context) {
    final TabBarSelectionState<T> selection = TabBarSelection.of(context);
    final Color color = Theme.of(context).accentColor;
    final ColorTween selectedColor = new ColorTween(begin: Colors.transparent, end: color);
    final ColorTween previousColor = new ColorTween(begin: color, end: Colors.transparent);
    Animation<double> animation = new CurvedAnimation(parent: selection.animation, curve: Curves.fastOutSlowIn);
    return new AnimatedBuilder(
      animation: animation,
      builder: (BuildContext context, Widget child) {
        return new Semantics(
          label: 'Page ${selection.index + 1} of ${selection.values.length}',
          child: new Row(
            children: selection.values.map((T tab) => _buildTabIndicator(selection, tab, animation, selectedColor, previousColor)).toList(),
            mainAxisSize: MainAxisSize.min
          )
        );
      }
    );
  }
}
