From 4b4d5fef9c9320ced6346e3fa905011b926a5dbd Mon Sep 17 00:00:00 2001 From: Will Larche Date: Thu, 27 Sep 2018 11:36:30 -0400 Subject: [PATCH] [Shrine] New Shrine with expanding cart bottom sheet from experimenal (#18) --- shrine/lib/app.dart | 44 +- shrine/lib/backdrop.dart | 114 +++- shrine/lib/category_menu_page.dart | 9 +- shrine/lib/expanding_bottom_sheet.dart | 652 +++++++++++++++++++ shrine/lib/home.dart | 34 +- shrine/lib/model/app_state_model.dart | 2 +- shrine/lib/model/products_repository.dart | 9 +- shrine/lib/shopping_cart.dart | 11 +- shrine/lib/supplemental/asymmetric_view.dart | 1 + shrine/lib/supplemental/product_card.dart | 67 +- shrine/lib/supplemental/product_columns.dart | 2 +- 11 files changed, 857 insertions(+), 88 deletions(-) create mode 100644 shrine/lib/expanding_bottom_sheet.dart diff --git a/shrine/lib/app.dart b/shrine/lib/app.dart index fba7c015d..e2a6b216a 100644 --- a/shrine/lib/app.dart +++ b/shrine/lib/app.dart @@ -19,6 +19,7 @@ import 'category_menu_page.dart'; import 'colors.dart'; import 'home.dart'; import 'login.dart'; +import 'expanding_bottom_sheet.dart'; import 'supplemental/cut_corners_border.dart'; class ShrineApp extends StatefulWidget { @@ -26,16 +27,35 @@ class ShrineApp extends StatefulWidget { _ShrineAppState createState() => _ShrineAppState(); } -class _ShrineAppState extends State { +class _ShrineAppState extends State + with SingleTickerProviderStateMixin { + // Controller to coordinate both the opening/closing of backdrop and sliding + // of expanding bottom sheet. + AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 450), + value: 1.0, + ); + } + @override Widget build(BuildContext context) { return MaterialApp( title: 'Shrine', - home: Backdrop( - frontLayer: HomePage(), - backLayer: CategoryMenuPage(), - frontTitle: Text('SHRINE'), - backTitle: Text('MENU'), + home: HomePage( + backdrop: Backdrop( + frontLayer: ProductPage(), + backLayer: CategoryMenuPage(onCategoryTap: () => _controller.forward()), + frontTitle: Text('SHRINE'), + backTitle: Text('MENU'), + controller: _controller, + ), + expandingBottomSheet: ExpandingBottomSheet(hideController: _controller), ), initialRoute: '/login', onGenerateRoute: _getRoute, @@ -72,13 +92,9 @@ ThemeData _buildShrineTheme() { cardColor: kShrineBackgroundWhite, textSelectionColor: kShrinePink100, errorColor: kShrineErrorRed, - buttonTheme: ButtonThemeData( - textTheme: ButtonTextTheme.accent, - ), + buttonTheme: ButtonThemeData(textTheme: ButtonTextTheme.accent), primaryIconTheme: base.iconTheme.copyWith(color: kShrineBrown900), - inputDecorationTheme: InputDecorationTheme( - border: CutCornersBorder(), - ), + inputDecorationTheme: InputDecorationTheme(border: CutCornersBorder()), textTheme: _buildShrineTextTheme(base.textTheme), primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme), accentTextTheme: _buildShrineTextTheme(base.accentTextTheme), @@ -101,6 +117,10 @@ TextTheme _buildShrineTextTheme(TextTheme base) { fontWeight: FontWeight.w500, fontSize: 16.0, ), + button: base.button.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14.0, + ), ) .apply( fontFamily: 'Rubik', diff --git a/shrine/lib/backdrop.dart b/shrine/lib/backdrop.dart index 730707962..0511cd8a3 100644 --- a/shrine/lib/backdrop.dart +++ b/shrine/lib/backdrop.dart @@ -16,9 +16,11 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'login.dart'; -import 'shopping_cart.dart'; -const double _kFlingVelocity = 2.0; +const Cubic _kAccelerateCurve = Cubic(0.548, 0.0, 0.757, 0.464); +const Cubic _kDecelerateCurve = Cubic(0.23, 0.94, 0.41, 1.0); +const double _kPeakVelocityTime = 0.248210; +const double _kPeakVelocityProgress = 0.379146; class _FrontLayer extends StatelessWidget { const _FrontLayer({ @@ -74,7 +76,10 @@ class _BackdropTitle extends AnimatedWidget { @override Widget build(BuildContext context) { - final Animation animation = this.listenable; + final Animation animation = CurvedAnimation( + parent: this.listenable, + curve: Interval(0.0, 0.78), + ); return DefaultTextStyle( style: Theme.of(context).primaryTextTheme.title, @@ -150,16 +155,19 @@ class Backdrop extends StatefulWidget { final Widget backLayer; final Widget frontTitle; final Widget backTitle; + final AnimationController controller; const Backdrop({ @required this.frontLayer, @required this.backLayer, @required this.frontTitle, @required this.backTitle, + @required this.controller, }) : assert(frontLayer != null), assert(backLayer != null), assert(frontTitle != null), - assert(backTitle != null); + assert(backTitle != null), + assert(controller != null); @override _BackdropState createState() => _BackdropState(); @@ -169,15 +177,12 @@ class _BackdropState extends State with SingleTickerProviderStateMixin { final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop'); AnimationController _controller; + Animation _layerAnimation; @override void initState() { super.initState(); - _controller = AnimationController( - duration: Duration(milliseconds: 300), - value: 1.0, - vsync: this, - ); + _controller = widget.controller; } @override @@ -193,8 +198,73 @@ class _BackdropState extends State } void _toggleBackdropLayerVisibility() { - _controller.fling( - velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity); + // Call setState here to update layerAnimation if that's necessary + setState(() { + _frontLayerVisible ? _controller.reverse() : _controller.forward(); + }); + } + + // _layerAnimation animates the front layer between open and close. + // _getLayerAnimation adjusts the values in the TweenSequence so the + // curve and timing are correct in both directions. + Animation _getLayerAnimation(Size layerSize, double layerTop) { + Curve firstCurve; // Curve for first TweenSequenceItem + Curve secondCurve; // Curve for second TweenSequenceItem + double firstWeight; // Weight of first TweenSequenceItem + double secondWeight; // Weight of second TweenSequenceItem + Animation animation; // Animation on which TweenSequence runs + + if (_frontLayerVisible) { + firstCurve = _kAccelerateCurve; + secondCurve = _kDecelerateCurve; + firstWeight = _kPeakVelocityTime; + secondWeight = 1.0 - _kPeakVelocityTime; + animation = CurvedAnimation( + parent: _controller.view, + curve: Interval(0.0, 0.78), + ); + } else { + // These values are only used when the controller runs from t=1.0 to t=0.0 + firstCurve = _kDecelerateCurve.flipped; + secondCurve = _kAccelerateCurve.flipped; + firstWeight = 1.0 - _kPeakVelocityTime; + secondWeight = _kPeakVelocityTime; + animation = _controller.view; + } + + return TweenSequence( + >[ + TweenSequenceItem( + tween: RelativeRectTween( + begin: RelativeRect.fromLTRB( + 0.0, + layerTop, + 0.0, + layerTop - layerSize.height, + ), + end: RelativeRect.fromLTRB( + 0.0, + layerTop * _kPeakVelocityProgress, + 0.0, + (layerTop - layerSize.height) * _kPeakVelocityProgress, + ), + ).chain(CurveTween(curve: firstCurve)), + weight: firstWeight, + ), + TweenSequenceItem( + tween: RelativeRectTween( + begin: RelativeRect.fromLTRB( + 0.0, + layerTop * _kPeakVelocityProgress, + 0.0, + (layerTop - layerSize.height) * _kPeakVelocityProgress, + ), + end: RelativeRect.fill, + ).chain(CurveTween(curve: secondCurve)), + weight: secondWeight, + ), + ], + ).animate(animation); } Widget _buildStack(BuildContext context, BoxConstraints constraints) { @@ -202,18 +272,14 @@ class _BackdropState extends State final Size layerSize = constraints.biggest; final double layerTop = layerSize.height - layerTitleHeight; - Animation layerAnimation = RelativeRectTween( - begin: RelativeRect.fromLTRB( - 0.0, layerTop, 0.0, layerTop - layerSize.height), - end: RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0), - ).animate(_controller.view); + _layerAnimation = _getLayerAnimation(layerSize, layerTop); return Stack( key: _backdropKey, children: [ widget.backLayer, PositionedTransition( - rect: layerAnimation, + rect: _layerAnimation, child: _FrontLayer( onTap: _toggleBackdropLayerVisibility, child: widget.frontLayer, @@ -237,17 +303,7 @@ class _BackdropState extends State ), actions: [ IconButton( - icon: const Icon(Icons.shopping_cart), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) => ShoppingCartPage()), - ); - }, - ), - IconButton( - icon: const Icon(Icons.search), + icon: const Icon(Icons.search, semanticLabel: 'login'), onPressed: () { Navigator.push( context, @@ -256,7 +312,7 @@ class _BackdropState extends State }, ), IconButton( - icon: const Icon(Icons.tune), + icon: const Icon(Icons.tune, semanticLabel: 'login'), onPressed: () { Navigator.push( context, diff --git a/shrine/lib/category_menu_page.dart b/shrine/lib/category_menu_page.dart index 37d40880d..00de4aaa7 100644 --- a/shrine/lib/category_menu_page.dart +++ b/shrine/lib/category_menu_page.dart @@ -21,10 +21,12 @@ import 'model/product.dart'; class CategoryMenuPage extends StatelessWidget { final List _categories = Category.values; + final VoidCallback onCategoryTap; const CategoryMenuPage({ Key key, - }); + this.onCategoryTap, + }) : super(key: key); Widget _buildCategory(Category category, BuildContext context) { final categoryString = @@ -32,7 +34,10 @@ class CategoryMenuPage extends StatelessWidget { final ThemeData theme = Theme.of(context); return ScopedModelDescendant( builder: (context, child, model) => GestureDetector( - onTap: () => model.setCategory(category), + onTap: () { + model.setCategory(category); + if (onCategoryTap != null) onCategoryTap(); + }, child: model.selectedCategory == category ? Column( children: [ diff --git a/shrine/lib/expanding_bottom_sheet.dart b/shrine/lib/expanding_bottom_sheet.dart new file mode 100644 index 000000000..c909eaa45 --- /dev/null +++ b/shrine/lib/expanding_bottom_sheet.dart @@ -0,0 +1,652 @@ +// Copyright 2018-present the Flutter authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import 'package:scoped_model/scoped_model.dart'; + +import 'colors.dart'; +import 'model/app_state_model.dart'; +import 'model/product.dart'; +import 'shopping_cart.dart'; + +// These curves define the emphasized easing curve. +const Cubic _kAccelerateCurve = const Cubic(0.548, 0.0, 0.757, 0.464); +const Cubic _kDecelerateCurve = const Cubic(0.23, 0.94, 0.41, 1.0); +// The time at which the accelerate and decelerate curves switch off +const double _kPeakVelocityTime = 0.248210; +// Percent (as a decimal) of animation that should be completed at _peakVelocityTime +const double _kPeakVelocityProgress = 0.379146; +const double _kCartHeight = 56.0; +// Radius of the shape on the top left of the sheet. +const double _kCornerRadius = 24.0; +// Width for just the cart icon and no thumbnails. +const double _kWidthForCartIcon = 64.0; + +class ExpandingBottomSheet extends StatefulWidget { + const ExpandingBottomSheet({Key key, @required this.hideController}) + : assert(hideController != null), + super(key: key); + + final AnimationController hideController; + + @override + _ExpandingBottomSheetState createState() => _ExpandingBottomSheetState(); + + static _ExpandingBottomSheetState of(BuildContext context, + {bool isNullOk: false}) { + assert(isNullOk != null); + assert(context != null); + final _ExpandingBottomSheetState result = context + .ancestorStateOfType(const TypeMatcher<_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 = _kAccelerateCurve; + secondCurve = _kDecelerateCurve; + firstWeight = _kPeakVelocityTime; + secondWeight = 1.0 - _kPeakVelocityTime; + } else { + firstCurve = _kDecelerateCurve.flipped; + secondCurve = _kAccelerateCurve.flipped; + firstWeight = 1.0 - _kPeakVelocityTime; + secondWeight = _kPeakVelocityTime; + } + + 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) * _kPeakVelocityProgress; +} + +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 = _kWidthForCartIcon; + // Controller for the opening and closing of the ExpandingBottomSheet + AnimationController _controller; + // Animations for the opening and closing of the ExpandingBottomSheet + Animation _widthAnimation; + Animation _heightAnimation; + Animation _thumbnailOpacityAnimation; + Animation _cartOpacityAnimation; + Animation _shapeAnimation; + Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Animation _getWidthAnimation(double screenWidth) { + if (_controller.status == AnimationStatus.forward) { + // Opening animation + return Tween(begin: _width, end: screenWidth).animate( + CurvedAnimation( + parent: _controller.view, + curve: Interval(0.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: Interval(0.0, 0.87)), + ); + } + } + + Animation _getHeightAnimation(double screenHeight) { + if (_controller.status == AnimationStatus.forward) { + // Opening animation + + return _getEmphasizedEasingAnimation( + begin: _kCartHeight, + peak: _kCartHeight + (screenHeight - _kCartHeight) * _kPeakVelocityProgress, + end: screenHeight, + isForward: true, + parent: _controller.view, + ); + } else { + // Closing animation + return Tween( + begin: _kCartHeight, + end: screenHeight, + ).animate( + CurvedAnimation( + parent: _controller.view, + curve: Interval(0.434, 1.0, curve: Curves.linear), // not used + // only the reverseCurve will be used + reverseCurve: Interval(0.434, 1.0, curve: Curves.fastOutSlowIn.flipped), + ), + ); + } + } + + // Animation of the cut corner. It's cut when closed and not cut when open. + Animation _getShapeAnimation() { + if (_controller.status == AnimationStatus.forward) { + return Tween(begin: _kCornerRadius, end: 0.0).animate( + CurvedAnimation( + parent: _controller.view, + curve: Interval(0.0, 0.3, curve: Curves.fastOutSlowIn), + ), + ); + } else { + return _getEmphasizedEasingAnimation( + begin: _kCornerRadius, + peak: _getPeakPoint(begin: _kCornerRadius, end: 0.0), + end: 0.0, + isForward: false, + parent: _controller.view, + ); + } + } + + Animation _getThumbnailOpacityAnimation() { + return Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller.view, + curve: _controller.status == AnimationStatus.forward + ? Interval(0.0, 0.3) + : Interval(0.532, 0.766), + ), + ); + } + + Animation _getCartOpacityAnimation() { + return CurvedAnimation( + parent: _controller.view, + curve: _controller.status == AnimationStatus.forward + ? Interval(0.3, 0.6) + : Interval(0.766, 1.0), + ); + } + + // Returns the correct width of the ExpandingBottomSheet based on the number of + // products in the cart. + double _widthFor(int numProducts) { + switch (numProducts) { + case 0: + return _kWidthForCartIcon; + break; + case 1: + return 136.0; + break; + case 2: + return 192.0; + break; + case 3: + return 248.0; + break; + default: + return 278.0; + } + } + + // 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 _cartPaddingFor(int numProducts) { + if (numProducts == 0) { + return EdgeInsetsDirectional.only(start: 20.0, end: 8.0); + } else { + return EdgeInsetsDirectional.only(start: 32.0, end: 8.0); + } + } + + bool get _cartIsVisible => _thumbnailOpacityAnimation.value == 0.0; + + Widget _buildThumbnails(int numProducts) { + return ExcludeSemantics( + child: Opacity( + opacity: _thumbnailOpacityAnimation.value, + child: Column(children: [ + Row(children: [ + AnimatedPadding( + padding: _cartPaddingFor(numProducts), + child: Icon(Icons.shopping_cart), + duration: Duration(milliseconds: 225), + ), + Container( + // Accounts for the overflow number + width: numProducts > 3 ? _width - 94.0 : _width - 64.0, + height: _kCartHeight, + padding: EdgeInsets.symmetric(vertical: 8.0), + child: ProductThumbnailRow(), + ), + ExtraProductsNumber() + ]), + ]), + ), + ); + } + + Widget _buildShoppingCartPage() { + return Opacity( + opacity: _cartOpacityAnimation.value, + child: ShoppingCartPage(), + ); + } + + Widget _buildCart(BuildContext context, Widget child) { + // numProducts is the number of different products in the cart (does not + // include multiples of the same product). + 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; + + _width = _widthFor(numProducts); + _widthAnimation = _getWidthAnimation(screenWidth); + _heightAnimation = _getHeightAnimation(screenHeight); + _shapeAnimation = _getShapeAnimation(); + _thumbnailOpacityAnimation = _getThumbnailOpacityAnimation(); + _cartOpacityAnimation = _getCartOpacityAnimation(); + + return Semantics( + button: true, + value: 'Shopping cart, $totalCartQuantity items', + child: Container( + width: _widthAnimation.value, + height: _heightAnimation.value, + child: Material( + animationDuration: Duration(milliseconds: 0), + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(_shapeAnimation.value), + ), + ), + elevation: 4.0, + color: kShrinePink50, + child: _cartIsVisible + ? _buildShoppingCartPage() + : _buildThumbnails(numProducts), + ), + ), + ); + } + + // Builder for the hide and reveal animation when the backdrop opens and closes + Widget _buildSlideAnimation(BuildContext context, Widget child) { + _slideAnimation = _getEmphasizedEasingAnimation( + begin: Offset(1.0, 0.0), + peak: Offset(_kPeakVelocityProgress, 0.0), + end: Offset(0.0, 0.0), + isForward: widget.hideController.status == AnimationStatus.forward, + parent: widget.hideController, + ); + + return SlideTransition( + position: _slideAnimation, + child: child, + ); + } + + // Closes the cart if the cart is open, otherwise exits the app (this should + // only be relevant for Android). + Future _onWillPop() async { + if (!_isOpen) { + return SystemNavigator.pop(); + } + + close(); + return true; + } + + @override + Widget build(BuildContext context) { + return AnimatedSize( + key: _expandingBottomSheetKey, + duration: Duration(milliseconds: 225), + curve: Curves.easeInOut, + vsync: this, + alignment: FractionalOffset.topLeft, + child: WillPopScope( + onWillPop: _onWillPop, + child: AnimatedBuilder( + animation: widget.hideController, + builder: _buildSlideAnimation, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: open, + child: ScopedModelDescendant( + builder: (context, child, model) { + return AnimatedBuilder( + builder: _buildCart, + animation: _controller, + ); + }, + ), + ), + ), + ), + ); + } +} + +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) { + Animation thumbnailSize = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + curve: Interval(0.33, 1.0, curve: Curves.easeIn), + parent: animation, + ), + ); + + Animation opacity = CurvedAnimation( + curve: Interval(0.33, 1.0, 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(); + Set internalSet = Set.from(_internalList); + Set listSet = Set.from(_list.list); + + Set difference = internalSet.difference(listSet); + if (difference.isEmpty) { + return; + } + + difference.forEach((product) { + 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() { + return AnimatedList( + key: _listKey, + shrinkWrap: true, + itemBuilder: _buildThumbnail, + initialItemCount: _list.length, + scrollDirection: Axis.horizontal, + physics: NeverScrollableScrollPhysics(), // Cart shouldn't scroll + ); + } + + @override + Widget build(BuildContext context) { + _updateLists(); + return ScopedModelDescendant( + builder: (context, child, model) => _buildAnimatedList(), + ); + } +} + +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) { + 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. + List products = productMap.keys.toList(); + int overflow = 0; + int numProducts = products.length; + if (numProducts > 3) { + for (int i = 3; i < numProducts; i++) { + overflow += productMap[products[i]]; + } + } + return overflow; + } + + Widget _buildOverflow(AppStateModel model, BuildContext context) { + if (model.productsInCart.length > 3) { + int numOverflowProducts = _calculateOverflow(model); + // Maximum of 99 so padding doesn't get messy. + int displayedOverflowProducts = numOverflowProducts <= 99 ? numOverflowProducts : 99; + return Container( + child: Text('+$displayedOverflowProducts', + style: Theme.of(context).primaryTextTheme.button, + ), + ); + } else { + return Container(); // build() can never return null. + } + } + + @override + Widget build(BuildContext context) { + return ScopedModelDescendant( + builder: (builder, child, model) => _buildOverflow(model, context), + ); + } +} + +class ProductThumbnail extends StatelessWidget { + final Animation animation; + final Animation opacityAnimation; + final Product product; + + ProductThumbnail(this.animation, this.opacityAnimation, this.product); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: opacityAnimation, + child: ScaleTransition( + scale: animation, + child: Container( + width: 40.0, + height: 40.0, + decoration: BoxDecoration( + image: DecorationImage( + image: ExactAssetImage( + product.assetName, // asset name + package: product.assetPackage, // asset package + ), + fit: BoxFit.cover, + ), + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + margin: EdgeInsets.only(left: 16.0), + ), + ), + ); + } +} + +// _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 = List.from(initialItems ?? []); + + final GlobalKey listKey; + final dynamic 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: 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, (BuildContext context, Animation 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; +} diff --git a/shrine/lib/home.dart b/shrine/lib/home.dart index ba5dd370d..9772e7cfa 100644 --- a/shrine/lib/home.dart +++ b/shrine/lib/home.dart @@ -15,21 +15,45 @@ import 'package:flutter/material.dart'; import 'package:scoped_model/scoped_model.dart'; +import 'backdrop.dart'; +import 'expanding_bottom_sheet.dart'; import 'model/app_state_model.dart'; import 'model/product.dart'; import 'supplemental/asymmetric_view.dart'; -class HomePage extends StatelessWidget { +class ProductPage extends StatelessWidget { final Category category; - const HomePage({this.category: Category.all}); + const ProductPage({this.category: Category.all}); @override Widget build(BuildContext context) { return ScopedModelDescendant( - builder: (context, child, model) => AsymmetricView( - products: model.getProducts(), - ), + builder: (BuildContext context, Widget child, AppStateModel model) { + return AsymmetricView( + products: model.getProducts(), + ); + }); + } +} + +class HomePage extends StatelessWidget { + final ExpandingBottomSheet expandingBottomSheet; + final Backdrop backdrop; + + const HomePage({ + Key key, + this.expandingBottomSheet, + this.backdrop + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + backdrop, + Align(child: expandingBottomSheet, alignment: Alignment.bottomRight) + ], ); } } diff --git a/shrine/lib/model/app_state_model.dart b/shrine/lib/model/app_state_model.dart index 75986ab14..7d86a044c 100644 --- a/shrine/lib/model/app_state_model.dart +++ b/shrine/lib/model/app_state_model.dart @@ -103,7 +103,7 @@ class AppStateModel extends Model { // Loads the list of available products from the repo. void loadProducts() { - _availableProducts = ProductsRepository.loadProducts(); + _availableProducts = ProductsRepository.loadProducts(Category.all); notifyListeners(); } diff --git a/shrine/lib/model/products_repository.dart b/shrine/lib/model/products_repository.dart index 01caa04b3..d355bac9b 100644 --- a/shrine/lib/model/products_repository.dart +++ b/shrine/lib/model/products_repository.dart @@ -15,8 +15,8 @@ import 'product.dart'; class ProductsRepository { - static List loadProducts() { - return const [ + static List loadProducts(Category category) { + const allProducts = [ Product( category: Category.accessories, id: 0, @@ -284,5 +284,10 @@ class ProductsRepository { price: 58, ), ]; + if (category == Category.all) { + return allProducts; + } else { + return allProducts.where((Product p) => p.category == category).toList(); + } } } diff --git a/shrine/lib/shopping_cart.dart b/shrine/lib/shopping_cart.dart index c1aa1e4c8..e5d825ed1 100644 --- a/shrine/lib/shopping_cart.dart +++ b/shrine/lib/shopping_cart.dart @@ -17,6 +17,7 @@ import 'package:intl/intl.dart'; import 'package:scoped_model/scoped_model.dart'; import 'colors.dart'; +import 'expanding_bottom_sheet.dart'; import 'model/app_state_model.dart'; import 'model/product.dart'; @@ -47,7 +48,7 @@ class _ShoppingCartPageState extends State { final localTheme = Theme.of(context); return Scaffold( - backgroundColor: kShrinePink100, + backgroundColor: kShrinePink50, body: SafeArea( child: Container( child: ScopedModelDescendant( @@ -61,8 +62,8 @@ class _ShoppingCartPageState extends State { SizedBox( width: _leftColumnWidth, child: IconButton( - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: () => ExpandingBottomSheet.of(context).close() ), ), Text( @@ -90,7 +91,7 @@ class _ShoppingCartPageState extends State { shape: const BeveledRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(7.0)), ), - color: kShrinePink300, + color: kShrinePink100, splashColor: kShrineBrown600, child: const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), @@ -98,7 +99,7 @@ class _ShoppingCartPageState extends State { ), onPressed: () { model.clearCart(); - Navigator.of(context).pop(); + ExpandingBottomSheet.of(context).close(); }, ), ), diff --git a/shrine/lib/supplemental/asymmetric_view.dart b/shrine/lib/supplemental/asymmetric_view.dart index 2cbe16e80..15c38fad0 100644 --- a/shrine/lib/supplemental/asymmetric_view.dart +++ b/shrine/lib/supplemental/asymmetric_view.dart @@ -90,6 +90,7 @@ class AsymmetricView extends StatelessWidget { scrollDirection: Axis.horizontal, padding: EdgeInsets.fromLTRB(0.0, 34.0, 16.0, 44.0), children: _buildColumns(context), + physics: AlwaysScrollableScrollPhysics(), ); } } diff --git a/shrine/lib/supplemental/product_card.dart b/shrine/lib/supplemental/product_card.dart index 59381446c..5a2f04ccd 100644 --- a/shrine/lib/supplemental/product_card.dart +++ b/shrine/lib/supplemental/product_card.dart @@ -44,42 +44,47 @@ class ProductCard extends StatelessWidget { builder: (context, child, model) => GestureDetector( onTap: () { model.addProductToCart(product.id); - Scaffold.of(context).showSnackBar(SnackBar( - content: - Text('${product.name} has been added to your cart.'), - )); + // TODO: Add Snackbar }, child: child, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + child: Stack( children: [ - AspectRatio( - aspectRatio: imageAspectRatio, - child: imageWidget, - ), - SizedBox( - height: kTextBoxHeight * MediaQuery.of(context).textScaleFactor, - width: 121.0, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - product == null ? '' : product.name, - style: theme.textTheme.button, - softWrap: false, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - SizedBox(height: 4.0), - Text( - product == null ? '' : formatter.format(product.price), - style: theme.textTheme.caption, + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AspectRatio( + aspectRatio: imageAspectRatio, + child: imageWidget, + ), + SizedBox( + height: kTextBoxHeight * MediaQuery.of(context).textScaleFactor, + width: 121.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + product == null ? '' : product.name, + style: theme.textTheme.button, + softWrap: false, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + SizedBox(height: 4.0), + Text( + product == null ? '' : formatter.format(product.price), + style: theme.textTheme.caption, + ), + ], ), - ], - ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Icon(Icons.add_shopping_cart), ), ], ), diff --git a/shrine/lib/supplemental/product_columns.dart b/shrine/lib/supplemental/product_columns.dart index c12e9ebf6..15c307afc 100644 --- a/shrine/lib/supplemental/product_columns.dart +++ b/shrine/lib/supplemental/product_columns.dart @@ -48,7 +48,7 @@ class TwoProductCardColumn extends StatelessWidget { product: top, ) : SizedBox( - height: heightOfCards, + height: heightOfCards > 0 ? heightOfCards : spacerHeight, ), ), SizedBox(height: spacerHeight),