// Copyright 2016 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; import 'shrine_data.dart'; import 'shrine_order.dart'; import 'shrine_page.dart'; import 'shrine_theme.dart'; import 'shrine_types.dart'; const double unitSize = kToolbarHeight; final List _products = new List.from(allProducts()); final Map _shoppingCart = {}; const int _childrenPerBlock = 8; const int _rowsPerBlock = 5; int _minIndexInRow(int rowIndex) { final int blockIndex = rowIndex ~/ _rowsPerBlock; return const [0, 2, 4, 6, 7][rowIndex % _rowsPerBlock] + blockIndex * _childrenPerBlock; } int _maxIndexInRow(int rowIndex) { final int blockIndex = rowIndex ~/ _rowsPerBlock; return const [1, 3, 5, 6, 7][rowIndex % _rowsPerBlock] + blockIndex * _childrenPerBlock; } int _rowAtIndex(int index) { final int blockCount = index ~/ _childrenPerBlock; return const [0, 0, 1, 1, 2, 2, 3, 4][index - blockCount * _childrenPerBlock] + blockCount * _rowsPerBlock; } int _columnAtIndex(int index) { return const [0, 1, 0, 1, 0, 1, 0, 0][index % _childrenPerBlock]; } int _columnSpanAtIndex(int index) { return const [1, 1, 1, 1, 1, 1, 2, 2][index % _childrenPerBlock]; } // The Shrine home page arranges the product cards into two columns. The card // on every 4th and 5th row spans two columns. class ShrineGridLayout extends SliverGridLayout { const ShrineGridLayout({ @required this.rowStride, @required this.columnStride, @required this.tileHeight, @required this.tileWidth, }); final double rowStride; final double columnStride; final double tileHeight; final double tileWidth; @override int getMinChildIndexForScrollOffset(double scrollOffset) { return _minIndexInRow(scrollOffset ~/ rowStride); } @override int getMaxChildIndexForScrollOffset(double scrollOffset) { return _maxIndexInRow(scrollOffset ~/ rowStride); } @override SliverGridGeometry getGeometryForChildIndex(int index) { final int row = _rowAtIndex(index); final int column = _columnAtIndex(index); final int columnSpan = _columnSpanAtIndex(index); return new SliverGridGeometry( scrollOffset: row * rowStride, crossAxisOffset: column * columnStride, mainAxisExtent: tileHeight, crossAxisExtent: tileWidth + (columnSpan - 1) * columnStride, ); } @override double estimateMaxScrollOffset(int childCount) { if (childCount == null) return null; if (childCount == 0) return 0.0; final int rowCount = _rowAtIndex(childCount - 1) + 1; final double rowSpacing = rowStride - tileHeight; return rowStride * rowCount - rowSpacing; } } class ShrineGridDelegate extends SliverGridDelegate { static const double _kSpacing = 8.0; @override SliverGridLayout getLayout(SliverConstraints constraints) { final double tileWidth = (constraints.crossAxisExtent - _kSpacing) / 2.0; final double tileHeight = 40.0 + 144.0 + 40.0; return new ShrineGridLayout( tileWidth: tileWidth, tileHeight: tileHeight, rowStride: tileHeight + _kSpacing, columnStride: tileWidth + _kSpacing, ); } @override bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false; } /// Displays the Vendor's name and avatar. class VendorItem extends StatelessWidget { VendorItem({ Key key, this.vendor }) : super(key: key) { assert(vendor != null); } final Vendor vendor; @override Widget build(BuildContext context) { return new SizedBox( height: 24.0, child: new Row( children: [ new SizedBox( width: 24.0, child: new ClipRRect( borderRadius: new BorderRadius.circular(12.0), child: new Image.asset(vendor.avatarAsset, fit: ImageFit.cover), ), ), const SizedBox(width: 8.0), new Expanded( child: new Text(vendor.name, style: ShrineTheme.of(context).vendorItemStyle), ), ], ), ); } } /// Displays the product's price. If the product is in the shopping cart the background /// is highlighted. abstract class PriceItem extends StatelessWidget { PriceItem({ Key key, this.product }) : super(key: key) { assert(product != null); } final Product product; Widget buildItem(BuildContext context, TextStyle style, EdgeInsets padding) { BoxDecoration decoration; if (_shoppingCart[product] != null) decoration = new BoxDecoration(backgroundColor: ShrineTheme.of(context).priceHighlightColor); return new Container( padding: padding, decoration: decoration, child: new Text(product.priceString, style: style), ); } } class ProductPriceItem extends PriceItem { ProductPriceItem({ Key key, Product product }) : super(key: key, product: product); @override Widget build(BuildContext context) { return buildItem( context, ShrineTheme.of(context).priceStyle, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), ); } } class FeaturePriceItem extends PriceItem { FeaturePriceItem({ Key key, Product product }) : super(key: key, product: product); @override Widget build(BuildContext context) { return buildItem( context, ShrineTheme.of(context).featurePriceStyle, const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), ); } } /// Layout the main left and right elements of a FeatureItem. class FeatureLayout extends MultiChildLayoutDelegate { FeatureLayout(); static final String left = 'left'; static final String right = 'right'; // Horizontally: the feature product image appears on the left and // occupies 50% of the available width; the feature product's // description apepars on the right and occupies 50% of the available // width + unitSize. The left and right widgets overlap and the right // widget is stacked on top. @override void performLayout(Size size) { final double halfWidth = size.width / 2.0; layoutChild(left, new BoxConstraints.tightFor(width: halfWidth, height: size.height)); positionChild(left, Offset.zero); layoutChild(right, new BoxConstraints.expand(width: halfWidth + unitSize, height: size.height)); positionChild(right, new Offset(halfWidth - unitSize, 0.0)); } @override bool shouldRelayout(FeatureLayout oldDelegate) => false; } /// A card that highlights the "featured" catalog item. class FeatureItem extends StatelessWidget { FeatureItem({ Key key, this.product }) : super(key: key) { assert(product.featureTitle != null); assert(product.featureDescription != null); } final Product product; @override Widget build(BuildContext context) { final ShrineTheme theme = ShrineTheme.of(context); return new AspectRatio( aspectRatio: 3.0 / 3.5, child: new Container( decoration: new BoxDecoration( backgroundColor: theme.cardBackgroundColor, border: new Border(bottom: new BorderSide(color: theme.dividerColor)), ), child: new Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ new SizedBox( height: unitSize, child: new Align( alignment: FractionalOffset.topRight, child: new FeaturePriceItem(product: product), ), ), new Expanded( child: new CustomMultiChildLayout( delegate: new FeatureLayout(), children: [ new LayoutId( id: FeatureLayout.left, child: new ClipRect( child: new OverflowBox( minWidth: 340.0, maxWidth: 340.0, minHeight: 340.0, maxHeight: 340.0, alignment: FractionalOffset.topRight, child: new Image.asset(product.imageAsset, fit: ImageFit.cover), ), ), ), new LayoutId( id: FeatureLayout.right, child: new Padding( padding: const EdgeInsets.only(right: 16.0), child: new Column( mainAxisAlignment: MainAxisAlignment.center, children: [ new Padding( padding: const EdgeInsets.only(top: 18.0), child: new Text(product.featureTitle, style: theme.featureTitleStyle), ), new Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: new Text(product.featureDescription, style: theme.featureStyle), ), new VendorItem(vendor: product.vendor), ], ), ), ), ], ), ), ], ), ), ); } } /// A card that displays a product's image, price, and vendor. class ProductItem extends StatelessWidget { ProductItem({ Key key, this.product, this.onPressed }) : super(key: key) { assert(product != null); } final Product product; final VoidCallback onPressed; @override Widget build(BuildContext context) { return new Card( child: new Stack( children: [ new Column( children: [ new Align( alignment: FractionalOffset.centerRight, child: new ProductPriceItem(product: product), ), new Container( width: 144.0, height: 144.0, padding: const EdgeInsets.symmetric(horizontal: 8.0), child: new Hero( tag: product.tag, child: new Image.asset(product.imageAsset, fit: ImageFit.contain), ), ), new Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: new VendorItem(vendor: product.vendor), ), ], ), new Material( type: MaterialType.transparency, child: new InkWell(onTap: onPressed), ), ], ), ); } } /// The Shrine app's home page. Displays the featured item above all of the /// product items arranged in two columns. class ShrineHome extends StatefulWidget { @override _ShrineHomeState createState() => new _ShrineHomeState(); } class _ShrineHomeState extends State { static final GlobalKey scaffoldKey = new GlobalKey(debugLabel: 'Shrine Home'); static final ShrineGridDelegate gridDelegate = new ShrineGridDelegate(); Future showOrderPage(Product product) async { final Order order = _shoppingCart[product] ?? new Order(product: product); final Order completedOrder = await Navigator.push(context, new ShrineOrderRoute( order: order, builder: (BuildContext context) { return new OrderPage( order: order, products: _products, shoppingCart: _shoppingCart, ); } )); assert(completedOrder.product != null); if (completedOrder.quantity == 0) _shoppingCart.remove(completedOrder.product); } @override Widget build(BuildContext context) { final Product featured = _products.firstWhere((Product product) => product.featureDescription != null); return new ShrinePage( scaffoldKey: scaffoldKey, products: _products, shoppingCart: _shoppingCart, body: new CustomScrollView( slivers: [ new SliverToBoxAdapter( child: new FeatureItem(product: featured), ), new SliverPadding( padding: const EdgeInsets.all(16.0), sliver: new SliverGrid( gridDelegate: gridDelegate, delegate: new SliverChildListDelegate( _products.map((Product product) { return new ProductItem( product: product, onPressed: () { showOrderPage(product); }, ); }).toList(), ), ), ), ], ), ); } }