[Shrine] New Shrine with expanding cart bottom sheet from experimenal (#18)

pull/19/head
Will Larche 6 years ago committed by Andrew Brogdon
parent b5ce05e934
commit 4b4d5fef9c

@ -19,6 +19,7 @@ import 'category_menu_page.dart';
import 'colors.dart'; import 'colors.dart';
import 'home.dart'; import 'home.dart';
import 'login.dart'; import 'login.dart';
import 'expanding_bottom_sheet.dart';
import 'supplemental/cut_corners_border.dart'; import 'supplemental/cut_corners_border.dart';
class ShrineApp extends StatefulWidget { class ShrineApp extends StatefulWidget {
@ -26,16 +27,35 @@ class ShrineApp extends StatefulWidget {
_ShrineAppState createState() => _ShrineAppState(); _ShrineAppState createState() => _ShrineAppState();
} }
class _ShrineAppState extends State<ShrineApp> { class _ShrineAppState extends State<ShrineApp>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Shrine', title: 'Shrine',
home: Backdrop( home: HomePage(
frontLayer: HomePage(), backdrop: Backdrop(
backLayer: CategoryMenuPage(), frontLayer: ProductPage(),
frontTitle: Text('SHRINE'), backLayer: CategoryMenuPage(onCategoryTap: () => _controller.forward()),
backTitle: Text('MENU'), frontTitle: Text('SHRINE'),
backTitle: Text('MENU'),
controller: _controller,
),
expandingBottomSheet: ExpandingBottomSheet(hideController: _controller),
), ),
initialRoute: '/login', initialRoute: '/login',
onGenerateRoute: _getRoute, onGenerateRoute: _getRoute,
@ -72,13 +92,9 @@ ThemeData _buildShrineTheme() {
cardColor: kShrineBackgroundWhite, cardColor: kShrineBackgroundWhite,
textSelectionColor: kShrinePink100, textSelectionColor: kShrinePink100,
errorColor: kShrineErrorRed, errorColor: kShrineErrorRed,
buttonTheme: ButtonThemeData( buttonTheme: ButtonThemeData(textTheme: ButtonTextTheme.accent),
textTheme: ButtonTextTheme.accent,
),
primaryIconTheme: base.iconTheme.copyWith(color: kShrineBrown900), primaryIconTheme: base.iconTheme.copyWith(color: kShrineBrown900),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(border: CutCornersBorder()),
border: CutCornersBorder(),
),
textTheme: _buildShrineTextTheme(base.textTheme), textTheme: _buildShrineTextTheme(base.textTheme),
primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme), primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme),
accentTextTheme: _buildShrineTextTheme(base.accentTextTheme), accentTextTheme: _buildShrineTextTheme(base.accentTextTheme),
@ -101,6 +117,10 @@ TextTheme _buildShrineTextTheme(TextTheme base) {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 16.0, fontSize: 16.0,
), ),
button: base.button.copyWith(
fontWeight: FontWeight.w500,
fontSize: 14.0,
),
) )
.apply( .apply(
fontFamily: 'Rubik', fontFamily: 'Rubik',

@ -16,9 +16,11 @@ import 'package:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'login.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 { class _FrontLayer extends StatelessWidget {
const _FrontLayer({ const _FrontLayer({
@ -74,7 +76,10 @@ class _BackdropTitle extends AnimatedWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Animation<double> animation = this.listenable; final Animation<double> animation = CurvedAnimation(
parent: this.listenable,
curve: Interval(0.0, 0.78),
);
return DefaultTextStyle( return DefaultTextStyle(
style: Theme.of(context).primaryTextTheme.title, style: Theme.of(context).primaryTextTheme.title,
@ -150,16 +155,19 @@ class Backdrop extends StatefulWidget {
final Widget backLayer; final Widget backLayer;
final Widget frontTitle; final Widget frontTitle;
final Widget backTitle; final Widget backTitle;
final AnimationController controller;
const Backdrop({ const Backdrop({
@required this.frontLayer, @required this.frontLayer,
@required this.backLayer, @required this.backLayer,
@required this.frontTitle, @required this.frontTitle,
@required this.backTitle, @required this.backTitle,
@required this.controller,
}) : assert(frontLayer != null), }) : assert(frontLayer != null),
assert(backLayer != null), assert(backLayer != null),
assert(frontTitle != null), assert(frontTitle != null),
assert(backTitle != null); assert(backTitle != null),
assert(controller != null);
@override @override
_BackdropState createState() => _BackdropState(); _BackdropState createState() => _BackdropState();
@ -169,15 +177,12 @@ class _BackdropState extends State<Backdrop>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop'); final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
AnimationController _controller; AnimationController _controller;
Animation<RelativeRect> _layerAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = widget.controller;
duration: Duration(milliseconds: 300),
value: 1.0,
vsync: this,
);
} }
@override @override
@ -193,8 +198,73 @@ class _BackdropState extends State<Backdrop>
} }
void _toggleBackdropLayerVisibility() { void _toggleBackdropLayerVisibility() {
_controller.fling( // Call setState here to update layerAnimation if that's necessary
velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity); 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<RelativeRect> _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<RelativeRect>>[
TweenSequenceItem<RelativeRect>(
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<RelativeRect>(
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) { Widget _buildStack(BuildContext context, BoxConstraints constraints) {
@ -202,18 +272,14 @@ class _BackdropState extends State<Backdrop>
final Size layerSize = constraints.biggest; final Size layerSize = constraints.biggest;
final double layerTop = layerSize.height - layerTitleHeight; final double layerTop = layerSize.height - layerTitleHeight;
Animation<RelativeRect> layerAnimation = RelativeRectTween( _layerAnimation = _getLayerAnimation(layerSize, layerTop);
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);
return Stack( return Stack(
key: _backdropKey, key: _backdropKey,
children: <Widget>[ children: <Widget>[
widget.backLayer, widget.backLayer,
PositionedTransition( PositionedTransition(
rect: layerAnimation, rect: _layerAnimation,
child: _FrontLayer( child: _FrontLayer(
onTap: _toggleBackdropLayerVisibility, onTap: _toggleBackdropLayerVisibility,
child: widget.frontLayer, child: widget.frontLayer,
@ -237,17 +303,7 @@ class _BackdropState extends State<Backdrop>
), ),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: const Icon(Icons.shopping_cart), icon: const Icon(Icons.search, semanticLabel: 'login'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ShoppingCartPage()),
);
},
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
@ -256,7 +312,7 @@ class _BackdropState extends State<Backdrop>
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune, semanticLabel: 'login'),
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,

@ -21,10 +21,12 @@ import 'model/product.dart';
class CategoryMenuPage extends StatelessWidget { class CategoryMenuPage extends StatelessWidget {
final List<Category> _categories = Category.values; final List<Category> _categories = Category.values;
final VoidCallback onCategoryTap;
const CategoryMenuPage({ const CategoryMenuPage({
Key key, Key key,
}); this.onCategoryTap,
}) : super(key: key);
Widget _buildCategory(Category category, BuildContext context) { Widget _buildCategory(Category category, BuildContext context) {
final categoryString = final categoryString =
@ -32,7 +34,10 @@ class CategoryMenuPage extends StatelessWidget {
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
return ScopedModelDescendant<AppStateModel>( return ScopedModelDescendant<AppStateModel>(
builder: (context, child, model) => GestureDetector( builder: (context, child, model) => GestureDetector(
onTap: () => model.setCategory(category), onTap: () {
model.setCategory(category);
if (onCategoryTap != null) onCategoryTap();
},
child: model.selectedCategory == category child: model.selectedCategory == category
? Column( ? Column(
children: <Widget>[ children: <Widget>[

@ -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<T> _getEmphasizedEasingAnimation<T>(
{@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<T>>[
TweenSequenceItem<T>(
weight: firstWeight,
tween: Tween<T>(
begin: begin,
end: peak,
).chain(CurveTween(curve: firstCurve)),
),
TweenSequenceItem<T>(
weight: secondWeight,
tween: Tween<T>(
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>().
double _getPeakPoint({double begin, double end}) {
return begin + (end - begin) * _kPeakVelocityProgress;
}
class _ExpandingBottomSheetState extends State<ExpandingBottomSheet> 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<double> _widthAnimation;
Animation<double> _heightAnimation;
Animation<double> _thumbnailOpacityAnimation;
Animation<double> _cartOpacityAnimation;
Animation<double> _shapeAnimation;
Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Animation<double> _getWidthAnimation(double screenWidth) {
if (_controller.status == AnimationStatus.forward) {
// Opening animation
return Tween<double>(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<double> _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<double>(
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<double> _getShapeAnimation() {
if (_controller.status == AnimationStatus.forward) {
return Tween<double>(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<double> _getThumbnailOpacityAnimation() {
return Tween<double>(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<double> _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: <Widget>[
Row(children: <Widget>[
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<AppStateModel>(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<bool> _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<AppStateModel>(
builder: (context, child, model) {
return AnimatedBuilder(
builder: _buildCart,
animation: _controller,
);
},
),
),
),
),
);
}
}
class ProductThumbnailRow extends StatefulWidget {
@override
_ProductThumbnailRowState createState() => _ProductThumbnailRowState();
}
class _ProductThumbnailRowState extends State<ProductThumbnailRow> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
// _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<int> _internalList;
@override
void initState() {
super.initState();
_list = _ListModel(
listKey: _listKey,
initialItems: ScopedModel.of<AppStateModel>(context).productsInCart.keys.toList(),
removedItemBuilder: _buildRemovedThumbnail,
);
_internalList = List<int>.from(_list.list);
}
Product _productWithId(int productId) {
final AppStateModel model = ScopedModel.of<AppStateModel>(context);
final Product product = model.getProductById(productId);
assert(product != null);
return product;
}
Widget _buildRemovedThumbnail(int item, BuildContext context, Animation<double> animation) {
return ProductThumbnail(animation, animation, _productWithId(item));
}
Widget _buildThumbnail(BuildContext context, int index, Animation<double> animation) {
Animation<double> thumbnailSize = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
curve: Interval(0.33, 1.0, curve: Curves.easeIn),
parent: animation,
),
);
Animation<double> 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<AppStateModel>(context).productsInCart.keys.toList();
Set<int> internalSet = Set<int>.from(_internalList);
Set<int> listSet = Set<int>.from(_list.list);
Set<int> 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<AppStateModel>(
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<int, int> 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<int> 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<AppStateModel>(
builder: (builder, child, model) => _buildOverflow(model, context),
);
}
}
class ProductThumbnail extends StatelessWidget {
final Animation<double> animation;
final Animation<double> 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<int> initialItems})
: assert(listKey != null),
assert(removedItemBuilder != null),
_items = List<int>.from(initialItems ?? <int>[]);
final GlobalKey<AnimatedListState> listKey;
final dynamic removedItemBuilder;
final List<int> _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<double> 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<int> get list => _items;
}

@ -15,21 +15,45 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart'; import 'package:scoped_model/scoped_model.dart';
import 'backdrop.dart';
import 'expanding_bottom_sheet.dart';
import 'model/app_state_model.dart'; import 'model/app_state_model.dart';
import 'model/product.dart'; import 'model/product.dart';
import 'supplemental/asymmetric_view.dart'; import 'supplemental/asymmetric_view.dart';
class HomePage extends StatelessWidget { class ProductPage extends StatelessWidget {
final Category category; final Category category;
const HomePage({this.category: Category.all}); const ProductPage({this.category: Category.all});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ScopedModelDescendant<AppStateModel>( return ScopedModelDescendant<AppStateModel>(
builder: (context, child, model) => AsymmetricView( builder: (BuildContext context, Widget child, AppStateModel model) {
products: model.getProducts(), 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: <Widget>[
backdrop,
Align(child: expandingBottomSheet, alignment: Alignment.bottomRight)
],
); );
} }
} }

@ -103,7 +103,7 @@ class AppStateModel extends Model {
// Loads the list of available products from the repo. // Loads the list of available products from the repo.
void loadProducts() { void loadProducts() {
_availableProducts = ProductsRepository.loadProducts(); _availableProducts = ProductsRepository.loadProducts(Category.all);
notifyListeners(); notifyListeners();
} }

@ -15,8 +15,8 @@
import 'product.dart'; import 'product.dart';
class ProductsRepository { class ProductsRepository {
static List<Product> loadProducts() { static List<Product> loadProducts(Category category) {
return const [ const allProducts = <Product>[
Product( Product(
category: Category.accessories, category: Category.accessories,
id: 0, id: 0,
@ -284,5 +284,10 @@ class ProductsRepository {
price: 58, price: 58,
), ),
]; ];
if (category == Category.all) {
return allProducts;
} else {
return allProducts.where((Product p) => p.category == category).toList();
}
} }
} }

@ -17,6 +17,7 @@ import 'package:intl/intl.dart';
import 'package:scoped_model/scoped_model.dart'; import 'package:scoped_model/scoped_model.dart';
import 'colors.dart'; import 'colors.dart';
import 'expanding_bottom_sheet.dart';
import 'model/app_state_model.dart'; import 'model/app_state_model.dart';
import 'model/product.dart'; import 'model/product.dart';
@ -47,7 +48,7 @@ class _ShoppingCartPageState extends State<ShoppingCartPage> {
final localTheme = Theme.of(context); final localTheme = Theme.of(context);
return Scaffold( return Scaffold(
backgroundColor: kShrinePink100, backgroundColor: kShrinePink50,
body: SafeArea( body: SafeArea(
child: Container( child: Container(
child: ScopedModelDescendant<AppStateModel>( child: ScopedModelDescendant<AppStateModel>(
@ -61,8 +62,8 @@ class _ShoppingCartPageState extends State<ShoppingCartPage> {
SizedBox( SizedBox(
width: _leftColumnWidth, width: _leftColumnWidth,
child: IconButton( child: IconButton(
icon: const Icon(Icons.keyboard_arrow_down), icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).pop(), onPressed: () => ExpandingBottomSheet.of(context).close()
), ),
), ),
Text( Text(
@ -90,7 +91,7 @@ class _ShoppingCartPageState extends State<ShoppingCartPage> {
shape: const BeveledRectangleBorder( shape: const BeveledRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7.0)), borderRadius: BorderRadius.all(Radius.circular(7.0)),
), ),
color: kShrinePink300, color: kShrinePink100,
splashColor: kShrineBrown600, splashColor: kShrineBrown600,
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0), padding: EdgeInsets.symmetric(vertical: 12.0),
@ -98,7 +99,7 @@ class _ShoppingCartPageState extends State<ShoppingCartPage> {
), ),
onPressed: () { onPressed: () {
model.clearCart(); model.clearCart();
Navigator.of(context).pop(); ExpandingBottomSheet.of(context).close();
}, },
), ),
), ),

@ -90,6 +90,7 @@ class AsymmetricView extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: EdgeInsets.fromLTRB(0.0, 34.0, 16.0, 44.0), padding: EdgeInsets.fromLTRB(0.0, 34.0, 16.0, 44.0),
children: _buildColumns(context), children: _buildColumns(context),
physics: AlwaysScrollableScrollPhysics(),
); );
} }
} }

@ -44,42 +44,47 @@ class ProductCard extends StatelessWidget {
builder: (context, child, model) => GestureDetector( builder: (context, child, model) => GestureDetector(
onTap: () { onTap: () {
model.addProductToCart(product.id); model.addProductToCart(product.id);
Scaffold.of(context).showSnackBar(SnackBar( // TODO: Add Snackbar
content:
Text('${product.name} has been added to your cart.'),
));
}, },
child: child, child: child,
), ),
child: Column( child: Stack(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
AspectRatio( Column(
aspectRatio: imageAspectRatio, mainAxisAlignment: MainAxisAlignment.center,
child: imageWidget, crossAxisAlignment: CrossAxisAlignment.center,
), children: <Widget>[
SizedBox( AspectRatio(
height: kTextBoxHeight * MediaQuery.of(context).textScaleFactor, aspectRatio: imageAspectRatio,
width: 121.0, child: imageWidget,
child: Column( ),
mainAxisAlignment: MainAxisAlignment.end, SizedBox(
crossAxisAlignment: CrossAxisAlignment.center, height: kTextBoxHeight * MediaQuery.of(context).textScaleFactor,
children: <Widget>[ width: 121.0,
Text( child: Column(
product == null ? '' : product.name, mainAxisAlignment: MainAxisAlignment.end,
style: theme.textTheme.button, crossAxisAlignment: CrossAxisAlignment.center,
softWrap: false, children: <Widget>[
overflow: TextOverflow.ellipsis, Text(
maxLines: 1, product == null ? '' : product.name,
), style: theme.textTheme.button,
SizedBox(height: 4.0), softWrap: false,
Text( overflow: TextOverflow.ellipsis,
product == null ? '' : formatter.format(product.price), maxLines: 1,
style: theme.textTheme.caption, ),
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),
), ),
], ],
), ),

@ -48,7 +48,7 @@ class TwoProductCardColumn extends StatelessWidget {
product: top, product: top,
) )
: SizedBox( : SizedBox(
height: heightOfCards, height: heightOfCards > 0 ? heightOfCards : spacerHeight,
), ),
), ),
SizedBox(height: spacerHeight), SizedBox(height: spacerHeight),

Loading…
Cancel
Save