// Copyright 2019 The Flutter team. 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:math'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:scoped_model/scoped_model.dart'; import 'package:gallery/layout/adaptive.dart'; import 'package:gallery/layout/text_scale.dart'; import 'package:gallery/l10n/gallery_localizations.dart'; import 'package:gallery/studies/shrine/colors.dart'; import 'package:gallery/studies/shrine/model/app_state_model.dart'; import 'package:gallery/studies/shrine/model/product.dart'; import 'package:gallery/studies/shrine/page_status.dart'; import 'package:gallery/studies/shrine/shopping_cart.dart'; // These curves define the emphasized easing curve. const Cubic _accelerateCurve = Cubic(0.548, 0, 0.757, 0.464); const Cubic _decelerateCurve = Cubic(0.23, 0.94, 0.41, 1); // The time at which the accelerate and decelerate curves switch off const _peakVelocityTime = 0.248210; // Percent (as a decimal) of animation that should be completed at _peakVelocityTime const _peakVelocityProgress = 0.379146; // Radius of the shape on the top start of the sheet for mobile layouts. const _mobileCornerRadius = 24.0; // Radius of the shape on the top start and bottom start of the sheet for mobile layouts. const _desktopCornerRadius = 12.0; // Width for just the cart icon and no thumbnails. const _cartIconWidth = 64.0; // Height for just the cart icon and no thumbnails. const _cartIconHeight = 56.0; // Height of a thumbnail. const _defaultThumbnailHeight = 40.0; // Gap between thumbnails. const _thumbnailGap = 16.0; // Maximum number of thumbnails shown in the cart. const _maxThumbnailCount = 3; double _thumbnailHeight(BuildContext context) { return _defaultThumbnailHeight * reducedTextScale(context); } double _paddedThumbnailHeight(BuildContext context) { return _thumbnailHeight(context) + _thumbnailGap; } class ExpandingBottomSheet extends StatefulWidget { const ExpandingBottomSheet({ Key key, @required this.hideController, @required this.expandingController, }) : assert(hideController != null), assert(expandingController != null), super(key: key); final AnimationController hideController; final AnimationController expandingController; @override _ExpandingBottomSheetState createState() => _ExpandingBottomSheetState(); static _ExpandingBottomSheetState of(BuildContext context, {bool isNullOk = false}) { assert(isNullOk != null); assert(context != null); final _ExpandingBottomSheetState result = context.findAncestorStateOfType<_ExpandingBottomSheetState>(); if (isNullOk || result != null) { return result; } throw FlutterError( 'ExpandingBottomSheet.of() called with a context that does not contain a ExpandingBottomSheet.\n'); } } // Emphasized Easing is a motion curve that has an organic, exciting feeling. // It's very fast to begin with and then very slow to finish. Unlike standard // curves, like [Curves.fastOutSlowIn], it can't be expressed in a cubic bezier // curve formula. It's quintic, not cubic. But it _can_ be expressed as one // curve followed by another, which we do here. Animation _getEmphasizedEasingAnimation({ @required T begin, @required T peak, @required T end, @required bool isForward, @required Animation parent, }) { Curve firstCurve; Curve secondCurve; double firstWeight; double secondWeight; if (isForward) { firstCurve = _accelerateCurve; secondCurve = _decelerateCurve; firstWeight = _peakVelocityTime; secondWeight = 1 - _peakVelocityTime; } else { firstCurve = _decelerateCurve.flipped; secondCurve = _accelerateCurve.flipped; firstWeight = 1 - _peakVelocityTime; secondWeight = _peakVelocityTime; } return TweenSequence( [ TweenSequenceItem( weight: firstWeight, tween: Tween( begin: begin, end: peak, ).chain(CurveTween(curve: firstCurve)), ), TweenSequenceItem( weight: secondWeight, tween: Tween( begin: peak, end: end, ).chain(CurveTween(curve: secondCurve)), ), ], ).animate(parent); } // Calculates the value where two double Animations should be joined. Used by // callers of _getEmphasisedEasing(). double _getPeakPoint({double begin, double end}) { return begin + (end - begin) * _peakVelocityProgress; } class _ExpandingBottomSheetState extends State with TickerProviderStateMixin { final GlobalKey _expandingBottomSheetKey = GlobalKey(debugLabel: 'Expanding bottom sheet'); // The width of the Material, calculated by _widthFor() & based on the number // of products in the cart. 64.0 is the width when there are 0 products // (_kWidthForZeroProducts) double _width = _cartIconWidth; double _height = _cartIconHeight; // Controller for the opening and closing of the ExpandingBottomSheet AnimationController get _controller => widget.expandingController; // Animations for the opening and closing of the ExpandingBottomSheet Animation _widthAnimation; Animation _heightAnimation; Animation _thumbnailOpacityAnimation; Animation _cartOpacityAnimation; Animation _topStartShapeAnimation; Animation _bottomStartShapeAnimation; Animation _slideAnimation; Animation _gapAnimation; Animation _getWidthAnimation(double screenWidth) { if (_controller.status == AnimationStatus.forward) { // Opening animation return Tween(begin: _width, end: screenWidth).animate( CurvedAnimation( parent: _controller.view, curve: const Interval(0, 0.3, curve: Curves.fastOutSlowIn), ), ); } else { // Closing animation return _getEmphasizedEasingAnimation( begin: _width, peak: _getPeakPoint(begin: _width, end: screenWidth), end: screenWidth, isForward: false, parent: CurvedAnimation( parent: _controller.view, curve: const Interval(0, 0.87)), ); } } Animation _getHeightAnimation(double screenHeight) { if (_controller.status == AnimationStatus.forward) { // Opening animation return _getEmphasizedEasingAnimation( begin: _height, peak: _getPeakPoint(begin: _height, end: screenHeight), end: screenHeight, isForward: true, parent: _controller.view, ); } else { // Closing animation return Tween( begin: _height, end: screenHeight, ).animate( CurvedAnimation( parent: _controller.view, curve: const Interval(0.434, 1, curve: Curves.linear), // not used // only the reverseCurve will be used reverseCurve: Interval(0.434, 1, curve: Curves.fastOutSlowIn.flipped), ), ); } } Animation _getDesktopGapAnimation(double gapHeight) { final double _collapsedGapHeight = gapHeight; final double _expandedGapHeight = 0; if (_controller.status == AnimationStatus.forward) { // Opening animation return _getEmphasizedEasingAnimation( begin: _collapsedGapHeight, peak: _collapsedGapHeight + (_expandedGapHeight - _collapsedGapHeight) * _peakVelocityProgress, end: _expandedGapHeight, isForward: true, parent: _controller.view, ); } else { // Closing animation return Tween( begin: _collapsedGapHeight, end: _expandedGapHeight, ).animate( CurvedAnimation( parent: _controller.view, curve: const Interval(0.434, 1), // not used // only the reverseCurve will be used reverseCurve: Interval(0.434, 1, curve: Curves.fastOutSlowIn.flipped), ), ); } } // Animation of the top-start cut corner. It's cut when closed and not cut when open. Animation _getShapeTopStartAnimation(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final double cornerRadius = isDesktop ? _desktopCornerRadius : _mobileCornerRadius; if (_controller.status == AnimationStatus.forward) { return Tween(begin: cornerRadius, end: 0).animate( CurvedAnimation( parent: _controller.view, curve: const Interval(0, 0.3, curve: Curves.fastOutSlowIn), ), ); } else { return _getEmphasizedEasingAnimation( begin: cornerRadius, peak: _getPeakPoint(begin: cornerRadius, end: 0), end: 0, isForward: false, parent: _controller.view, ); } } // Animation of the bottom-start cut corner. It's cut when closed and not cut when open. Animation _getShapeBottomStartAnimation(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final double cornerRadius = isDesktop ? _desktopCornerRadius : 0; if (_controller.status == AnimationStatus.forward) { return Tween(begin: cornerRadius, end: 0).animate( CurvedAnimation( parent: _controller.view, curve: const Interval(0, 0.3, curve: Curves.fastOutSlowIn), ), ); } else { return _getEmphasizedEasingAnimation( begin: cornerRadius, peak: _getPeakPoint(begin: cornerRadius, end: 0), end: 0, isForward: false, parent: _controller.view, ); } } Animation _getThumbnailOpacityAnimation() { return Tween(begin: 1, end: 0).animate( CurvedAnimation( parent: _controller.view, curve: _controller.status == AnimationStatus.forward ? const Interval(0, 0.3) : const Interval(0.532, 0.766), ), ); } Animation _getCartOpacityAnimation() { return CurvedAnimation( parent: _controller.view, curve: _controller.status == AnimationStatus.forward ? const Interval(0.3, 0.6) : const Interval(0.766, 1), ); } // Returns the correct width of the ExpandingBottomSheet based on the number of // products and the text scaling options in the cart in the mobile layout. double _mobileWidthFor(int numProducts, BuildContext context) { final double cartThumbnailGap = numProducts > 0 ? 16 : 0; final double thumbnailsWidth = min(numProducts, _maxThumbnailCount) * _paddedThumbnailHeight(context); final double overflowNumberWidth = numProducts > _maxThumbnailCount ? 30 * cappedTextScale(context) : 0; return _cartIconWidth + cartThumbnailGap + thumbnailsWidth + overflowNumberWidth; } // Returns the correct height of the ExpandingBottomSheet based on the text scaling // options in the mobile layout. double _mobileHeightFor(BuildContext context) { return _paddedThumbnailHeight(context); } // Returns the correct width of the ExpandingBottomSheet based on the text scaling // options in the desktop layout. double _desktopWidthFor(BuildContext context) { return _paddedThumbnailHeight(context) + 8; } // Returns the correct height of the ExpandingBottomSheet based on the number of // products and the text scaling options in the cart in the desktop layout. double _desktopHeightFor(int numProducts, BuildContext context) { final double cartThumbnailGap = numProducts > 0 ? 8 : 0; final double thumbnailsHeight = min(numProducts, _maxThumbnailCount) * _paddedThumbnailHeight(context); final double overflowNumberHeight = numProducts > _maxThumbnailCount ? 28 * reducedTextScale(context) : 0; return _cartIconHeight + cartThumbnailGap + thumbnailsHeight + overflowNumberHeight; } // Returns true if the cart is open or opening and false otherwise. bool get _isOpen { final AnimationStatus status = _controller.status; return status == AnimationStatus.completed || status == AnimationStatus.forward; } // Opens the ExpandingBottomSheet if it's closed, otherwise does nothing. void open() { if (!_isOpen) { _controller.forward(); } } // Closes the ExpandingBottomSheet if it's open or opening, otherwise does nothing. void close() { if (_isOpen) { _controller.reverse(); } } // Changes the padding between the start edge of the Material and the cart icon // based on the number of products in the cart (padding increases when > 0 // products.) EdgeInsetsDirectional _horizontalCartPaddingFor(int numProducts) { return (numProducts == 0) ? const EdgeInsetsDirectional.only(start: 20, end: 8) : const EdgeInsetsDirectional.only(start: 32, end: 8); } // Changes the padding above and below the cart icon // based on the number of products in the cart (padding increases when > 0 // products.) EdgeInsets _verticalCartPaddingFor(int numProducts) { return (numProducts == 0) ? const EdgeInsets.only(top: 16, bottom: 16) : const EdgeInsets.only(top: 16, bottom: 24); } bool get _cartIsVisible => _thumbnailOpacityAnimation.value == 0; // We take 16 pts off of the bottom padding to ensure the collapsed shopping // cart is not too tall. double get _bottomSafeArea { return max(MediaQuery.of(context).viewPadding.bottom - 16, 0); } Widget _buildThumbnails(BuildContext context, int numProducts) { final bool isDesktop = isDisplayDesktop(context); Widget thumbnails; if (isDesktop) { thumbnails = Column( children: [ AnimatedPadding( padding: _verticalCartPaddingFor(numProducts), child: const Icon(Icons.shopping_cart), duration: const Duration(milliseconds: 225), ), Container( width: _width, height: min(numProducts, _maxThumbnailCount) * _paddedThumbnailHeight(context), child: ProductThumbnailRow(), ), ExtraProductsNumber(), ], ); } else { thumbnails = Column( children: [ Row( children: [ AnimatedPadding( padding: _horizontalCartPaddingFor(numProducts), child: const Icon(Icons.shopping_cart), duration: const Duration(milliseconds: 225), ), Container( // Accounts for the overflow number width: min(numProducts, _maxThumbnailCount) * _paddedThumbnailHeight(context) + (numProducts > 0 ? _thumbnailGap : 0), height: _height - _bottomSafeArea, padding: const EdgeInsets.symmetric(vertical: 8), child: ProductThumbnailRow(), ), ExtraProductsNumber(), ], ), ], ); } return ExcludeSemantics( child: Opacity( opacity: _thumbnailOpacityAnimation.value, child: thumbnails, ), ); } Widget _buildShoppingCartPage() { return Opacity( opacity: _cartOpacityAnimation.value, child: ShoppingCartPage(), ); } Widget _buildCart(BuildContext context) { // numProducts is the number of different products in the cart (does not // include multiples of the same product). final bool isDesktop = isDisplayDesktop(context); final AppStateModel model = ScopedModel.of(context); final int numProducts = model.productsInCart.keys.length; final int totalCartQuantity = model.totalCartQuantity; final Size screenSize = MediaQuery.of(context).size; final double screenWidth = screenSize.width; final double screenHeight = screenSize.height; final double expandedCartWidth = isDesktop ? (360 * cappedTextScale(context)).clamp(360, screenWidth).toDouble() : screenWidth; _width = isDesktop ? _desktopWidthFor(context) : _mobileWidthFor(numProducts, context); _widthAnimation = _getWidthAnimation(expandedCartWidth); _height = isDesktop ? _desktopHeightFor(numProducts, context) : _mobileHeightFor(context) + _bottomSafeArea; _heightAnimation = _getHeightAnimation(screenHeight); _topStartShapeAnimation = _getShapeTopStartAnimation(context); _bottomStartShapeAnimation = _getShapeBottomStartAnimation(context); _thumbnailOpacityAnimation = _getThumbnailOpacityAnimation(); _cartOpacityAnimation = _getCartOpacityAnimation(); _gapAnimation = isDesktop ? _getDesktopGapAnimation(116) : AlwaysStoppedAnimation(0); final Widget child = Container( width: _widthAnimation.value, height: _heightAnimation.value, child: Material( animationDuration: const Duration(milliseconds: 0), shape: BeveledRectangleBorder( borderRadius: BorderRadiusDirectional.only( topStart: Radius.circular(_topStartShapeAnimation.value), bottomStart: Radius.circular(_bottomStartShapeAnimation.value), ), ), elevation: 4, color: shrinePink50, child: _cartIsVisible ? _buildShoppingCartPage() : _buildThumbnails(context, numProducts), ), ); final Widget childWithInteraction = productPageIsVisible(context) ? Semantics( button: true, label: GalleryLocalizations.of(context) .shrineScreenReaderCart(totalCartQuantity), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: open, child: child, ), ) : child; return Padding( padding: EdgeInsets.only(top: _gapAnimation.value), child: childWithInteraction, ); } // Builder for the hide and reveal animation when the backdrop opens and closes Widget _buildSlideAnimation(BuildContext context, Widget child) { final bool isDesktop = isDisplayDesktop(context); if (isDesktop) { return child; } else { final double textDirectionScalar = Directionality.of(context) == TextDirection.ltr ? 1 : -1; _slideAnimation = _getEmphasizedEasingAnimation( begin: Offset(1 * textDirectionScalar, 0), peak: Offset(_peakVelocityProgress * textDirectionScalar, 0), end: const Offset(0, 0), isForward: widget.hideController.status == AnimationStatus.forward, parent: widget.hideController, ); return SlideTransition( position: _slideAnimation, child: child, ); } } @override Widget build(BuildContext context) { return AnimatedSize( key: _expandingBottomSheetKey, duration: const Duration(milliseconds: 225), curve: Curves.easeInOut, vsync: this, alignment: AlignmentDirectional.topStart, child: AnimatedBuilder( animation: widget.hideController, builder: (context, child) => AnimatedBuilder( animation: widget.expandingController, builder: (context, child) => ScopedModelDescendant( builder: (context, child, model) => _buildSlideAnimation(context, _buildCart(context)), ), ), ), ); } } class ProductThumbnailRow extends StatefulWidget { @override _ProductThumbnailRowState createState() => _ProductThumbnailRowState(); } class _ProductThumbnailRowState extends State { final GlobalKey _listKey = GlobalKey(); // _list represents what's currently on screen. If _internalList updates, // it will need to be updated to match it. _ListModel _list; // _internalList represents the list as it is updated by the AppStateModel. List _internalList; @override void initState() { super.initState(); _list = _ListModel( listKey: _listKey, initialItems: ScopedModel.of(context).productsInCart.keys.toList(), removedItemBuilder: _buildRemovedThumbnail, ); _internalList = List.from(_list.list); } Product _productWithId(int productId) { final AppStateModel model = ScopedModel.of(context); final Product product = model.getProductById(productId); assert(product != null); return product; } Widget _buildRemovedThumbnail( int item, BuildContext context, Animation animation) { return ProductThumbnail(animation, animation, _productWithId(item)); } Widget _buildThumbnail( BuildContext context, int index, Animation animation) { final Animation thumbnailSize = Tween(begin: 0.8, end: 1).animate( CurvedAnimation( curve: const Interval(0.33, 1, curve: Curves.easeIn), parent: animation, ), ); final Animation opacity = CurvedAnimation( curve: const Interval(0.33, 1, curve: Curves.linear), parent: animation, ); return ProductThumbnail( thumbnailSize, opacity, _productWithId(_list[index])); } // If the lists are the same length, assume nothing has changed. // If the internalList is shorter than the ListModel, an item has been removed. // If the internalList is longer, then an item has been added. void _updateLists() { // Update _internalList based on the model _internalList = ScopedModel.of(context).productsInCart.keys.toList(); final Set internalSet = Set.from(_internalList); final Set listSet = Set.from(_list.list); final Set difference = internalSet.difference(listSet); if (difference.isEmpty) { return; } for (int product in difference) { if (_internalList.length < _list.length) { _list.remove(product); } else if (_internalList.length > _list.length) { _list.add(product); } } while (_internalList.length != _list.length) { int index = 0; // Check bounds and that the list elements are the same while (_internalList.isNotEmpty && _list.length > 0 && index < _internalList.length && index < _list.length && _internalList[index] == _list[index]) { index++; } } } Widget _buildAnimatedList(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); return AnimatedList( key: _listKey, shrinkWrap: true, itemBuilder: _buildThumbnail, initialItemCount: _list.length, scrollDirection: isDesktop ? Axis.vertical : Axis.horizontal, physics: const NeverScrollableScrollPhysics(), // Cart shouldn't scroll ); } @override Widget build(BuildContext context) { _updateLists(); return ScopedModelDescendant( builder: (context, child, model) => _buildAnimatedList(context), ); } } class ExtraProductsNumber extends StatelessWidget { // Calculates the number to be displayed at the end of the row if there are // more than three products in the cart. This calculates overflow products, // including their duplicates (but not duplicates of products shown as // thumbnails). int _calculateOverflow(AppStateModel model) { final Map productMap = model.productsInCart; // List created to be able to access products by index instead of ID. // Order is guaranteed because productsInCart returns a LinkedHashMap. final List products = productMap.keys.toList(); int overflow = 0; final int numProducts = products.length; for (int i = _maxThumbnailCount; i < numProducts; i++) { overflow += productMap[products[i]]; } return overflow; } Widget _buildOverflow(AppStateModel model, BuildContext context) { if (model.productsInCart.length <= _maxThumbnailCount) { return Container(); } final int numOverflowProducts = _calculateOverflow(model); // Maximum of 99 so padding doesn't get messy. final int displayedOverflowProducts = numOverflowProducts <= 99 ? numOverflowProducts : 99; return Container( child: Text( '+$displayedOverflowProducts', style: Theme.of(context).primaryTextTheme.button, ), ); } @override Widget build(BuildContext context) { return ScopedModelDescendant( builder: (builder, child, model) => _buildOverflow(model, context), ); } } class ProductThumbnail extends StatelessWidget { const ProductThumbnail(this.animation, this.opacityAnimation, this.product); final Animation animation; final Animation opacityAnimation; final Product product; @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); return FadeTransition( opacity: opacityAnimation, child: ScaleTransition( scale: animation, child: Container( width: _thumbnailHeight(context), height: _thumbnailHeight(context), decoration: BoxDecoration( image: DecorationImage( image: ExactAssetImage( product.assetName, // asset name package: product.assetPackage, // asset package ), fit: BoxFit.cover, ), borderRadius: const BorderRadius.all(Radius.circular(10)), ), margin: isDesktop ? const EdgeInsetsDirectional.only(start: 12, end: 12, bottom: 16) : const EdgeInsetsDirectional.only(start: 16), ), ), ); } } // _ListModel manipulates an internal list and an AnimatedList class _ListModel { _ListModel({ @required this.listKey, @required this.removedItemBuilder, Iterable initialItems, }) : assert(listKey != null), assert(removedItemBuilder != null), _items = initialItems?.toList() ?? []; final GlobalKey listKey; final Widget Function(int, BuildContext, Animation) removedItemBuilder; final List _items; AnimatedListState get _animatedList => listKey.currentState; void add(int product) { _insert(_items.length, product); } void _insert(int index, int item) { _items.insert(index, item); _animatedList.insertItem(index, duration: const Duration(milliseconds: 225)); } void remove(int product) { final int index = _items.indexOf(product); if (index >= 0) { _removeAt(index); } } void _removeAt(int index) { final int removedItem = _items.removeAt(index); if (removedItem != null) { _animatedList.removeItem(index, (context, animation) { return removedItemBuilder(removedItem, context, animation); }); } } int get length => _items.length; int operator [](int index) => _items[index]; int indexOf(int item) => _items.indexOf(item); List get list => _items; }