// Copyright 2018 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 'package:flutter_web/material.dart'; import 'package:flutter_web/rendering.dart'; class PestoDemo extends StatelessWidget { const PestoDemo({Key key}) : super(key: key); static const String routeName = '/pesto'; @override Widget build(BuildContext context) => PestoHome(); } const String _kSmallLogoImage = 'logos/pesto/logo_small.png'; const double _kAppBarHeight = 128.0; const double _kFabHalfSize = 28.0; // TODO(mpcomplete): needs to adapt to screen size const double _kRecipePageMaxWidth = 500.0; final Set _favoriteRecipes = Set(); final ThemeData _kTheme = ThemeData( brightness: Brightness.light, primarySwatch: Colors.teal, accentColor: Colors.redAccent, ); class PestoHome extends StatelessWidget { @override Widget build(BuildContext context) { return const RecipeGridPage(recipes: kPestoRecipes); } } class PestoFavorites extends StatelessWidget { @override Widget build(BuildContext context) { return RecipeGridPage(recipes: _favoriteRecipes.toList()); } } class PestoStyle extends TextStyle { const PestoStyle({ double fontSize = 12.0, FontWeight fontWeight, Color color = Colors.black87, double letterSpacing, double height, }) : super( inherit: false, color: color, fontFamily: 'Raleway', fontSize: fontSize, fontWeight: fontWeight, textBaseline: TextBaseline.alphabetic, letterSpacing: letterSpacing, height: height, ); } // Displays a grid of recipe cards. class RecipeGridPage extends StatefulWidget { const RecipeGridPage({Key key, this.recipes}) : super(key: key); final List recipes; @override _RecipeGridPageState createState() => _RecipeGridPageState(); } class _RecipeGridPageState extends State { final GlobalKey scaffoldKey = GlobalKey(); @override Widget build(BuildContext context) { final double statusBarHeight = MediaQuery.of(context).padding.top; return Theme( data: _kTheme.copyWith(platform: Theme.of(context).platform), child: Scaffold( key: scaffoldKey, floatingActionButton: FloatingActionButton( child: const Icon(Icons.edit), onPressed: () { scaffoldKey.currentState.showSnackBar(const SnackBar( content: Text('Not supported.'), )); }, ), body: CustomScrollView( semanticChildCount: widget.recipes.length, slivers: [ _buildAppBar(context, statusBarHeight), _buildBody(context, statusBarHeight), ], ), ), ); } Widget _buildAppBar(BuildContext context, double statusBarHeight) { return SliverAppBar( pinned: true, expandedHeight: _kAppBarHeight, actions: [ IconButton( icon: const Icon(Icons.search), tooltip: 'Search', onPressed: () { scaffoldKey.currentState.showSnackBar(const SnackBar( content: Text('Not supported.'), )); }, ), ], flexibleSpace: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final Size size = constraints.biggest; final double appBarHeight = size.height - statusBarHeight; final double t = (appBarHeight - kToolbarHeight) / (_kAppBarHeight - kToolbarHeight); final double extraPadding = Tween(begin: 10.0, end: 24.0).transform(t); final double logoHeight = appBarHeight - 1.5 * extraPadding; return Padding( padding: EdgeInsets.only( top: statusBarHeight + 0.5 * extraPadding, bottom: extraPadding, ), child: Center( child: PestoLogo(height: logoHeight, t: t.clamp(0.0, 1.0))), ); }, ), ); } Widget _buildBody(BuildContext context, double statusBarHeight) { final EdgeInsets mediaPadding = MediaQuery.of(context).padding; final EdgeInsets padding = EdgeInsets.only( top: 8.0, left: 8.0 + mediaPadding.left, right: 8.0 + mediaPadding.right, bottom: 8.0); return SliverPadding( padding: padding, sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: _kRecipePageMaxWidth, crossAxisSpacing: 8.0, mainAxisSpacing: 8.0, ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { final Recipe recipe = widget.recipes[index]; return RecipeCard( recipe: recipe, onTap: () { showRecipePage(context, recipe); }, ); }, childCount: widget.recipes.length, ), ), ); } void showFavoritesPage(BuildContext context) { Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/pesto/favorites'), builder: (BuildContext context) => PestoFavorites(), )); } void showRecipePage(BuildContext context, Recipe recipe) { Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/pesto/recipe'), builder: (BuildContext context) { return Theme( data: _kTheme.copyWith(platform: Theme.of(context).platform), child: RecipePage(recipe: recipe), ); }, )); } } class PestoLogo extends StatefulWidget { const PestoLogo({this.height, this.t}); final double height; final double t; @override _PestoLogoState createState() => _PestoLogoState(); } class _PestoLogoState extends State { // Native sizes for logo and its image/text components. static const double kLogoHeight = 162.0; static const double kLogoWidth = 220.0; static const double kImageHeight = 108.0; static const double kTextHeight = 48.0; final TextStyle titleStyle = const PestoStyle( fontSize: kTextHeight, fontWeight: FontWeight.w900, color: Colors.white, letterSpacing: 3.0); final RectTween _textRectTween = RectTween( begin: Rect.fromLTWH(0.0, kLogoHeight, kLogoWidth, kTextHeight), end: Rect.fromLTWH(0.0, kImageHeight, kLogoWidth, kTextHeight)); final Curve _textOpacity = const Interval(0.4, 1.0, curve: Curves.easeInOut); final RectTween _imageRectTween = RectTween( begin: Rect.fromLTWH(0.0, 0.0, kLogoWidth, kLogoHeight), end: Rect.fromLTWH(0.0, 0.0, kLogoWidth, kImageHeight), ); @override Widget build(BuildContext context) { return Semantics( namesRoute: true, child: Transform( transform: Matrix4.identity()..scale(widget.height / kLogoHeight), alignment: Alignment.topCenter, child: SizedBox( width: kLogoWidth, child: Stack( overflow: Overflow.visible, children: [ Positioned.fromRect( rect: _imageRectTween.lerp(widget.t), child: Image.asset( '$_kSmallLogoImage', fit: BoxFit.contain, ), ), Positioned.fromRect( rect: _textRectTween.lerp(widget.t), child: Opacity( opacity: _textOpacity.transform(widget.t), child: Text('PESTO', style: titleStyle, textAlign: TextAlign.center), ), ), ], ), ), ), ); } } // A card with the recipe's image, author, and title. class RecipeCard extends StatelessWidget { const RecipeCard({Key key, this.recipe, this.onTap}) : super(key: key); final Recipe recipe; final VoidCallback onTap; TextStyle get titleStyle => const PestoStyle(fontSize: 24.0, fontWeight: FontWeight.w600); TextStyle get authorStyle => const PestoStyle(fontWeight: FontWeight.w500, color: Colors.black54); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Hero( tag: '${recipe.imagePath}', child: AspectRatio( aspectRatio: 4.0 / 3.0, child: Image.asset( '${recipe.imagePath}', package: recipe.imagePackage, fit: BoxFit.cover, semanticLabel: recipe.name, ), ), ), Expanded( child: Row( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Image.asset( '${recipe.ingredientsImagePath}', package: recipe.ingredientsImagePackage, width: 48.0, height: 48.0, ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(recipe.name, style: titleStyle, softWrap: false, overflow: TextOverflow.ellipsis), Text(recipe.author, style: authorStyle), ], ), ), ], ), ), ], ), ), ); } } // Displays one recipe. Includes the recipe sheet with a background image. class RecipePage extends StatefulWidget { const RecipePage({Key key, this.recipe}) : super(key: key); final Recipe recipe; @override _RecipePageState createState() => _RecipePageState(); } class _RecipePageState extends State { final GlobalKey _scaffoldKey = GlobalKey(); final TextStyle menuItemStyle = const PestoStyle( fontSize: 15.0, color: Colors.black54, height: 24.0 / 15.0); double _getAppBarHeight(BuildContext context) => MediaQuery.of(context).size.height * 0.3; @override Widget build(BuildContext context) { // The full page content with the recipe's image behind it. This // adjusts based on the size of the screen. If the recipe sheet touches // the edge of the screen, use a slightly different layout. final double appBarHeight = _getAppBarHeight(context); final Size screenSize = MediaQuery.of(context).size; final bool fullWidth = screenSize.width < _kRecipePageMaxWidth; final bool isFavorite = _favoriteRecipes.contains(widget.recipe); return Scaffold( key: _scaffoldKey, body: Stack( children: [ Positioned( top: 0.0, left: 0.0, right: 0.0, height: appBarHeight + _kFabHalfSize, child: Hero( tag: '${widget.recipe.imagePath}', child: Image.asset( '${widget.recipe.imagePath}', package: widget.recipe.imagePackage, fit: fullWidth ? BoxFit.fitWidth : BoxFit.cover, ), ), ), CustomScrollView( slivers: [ SliverAppBar( expandedHeight: appBarHeight - _kFabHalfSize, backgroundColor: Colors.transparent, actions: [ PopupMenuButton( onSelected: (String item) {}, itemBuilder: (BuildContext context) => >[ _buildMenuItem(Icons.share, 'Tweet recipe'), _buildMenuItem(Icons.email, 'Email recipe'), _buildMenuItem(Icons.message, 'Message recipe'), _buildMenuItem(Icons.people, 'Share on Facebook'), ], ), ], flexibleSpace: const FlexibleSpaceBar( background: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment(0.0, -1.0), end: Alignment(0.0, -0.2), colors: [Color(0x60000000), Color(0x00000000)], ), ), ), ), ), SliverToBoxAdapter( child: Stack( children: [ Container( padding: const EdgeInsets.only(top: _kFabHalfSize), width: fullWidth ? null : _kRecipePageMaxWidth, child: RecipeSheet(recipe: widget.recipe), ), Positioned( right: 16.0, child: FloatingActionButton( child: Icon( isFavorite ? Icons.favorite : Icons.favorite_border), onPressed: _toggleFavorite, ), ), ], )), ], ), ], ), ); } PopupMenuItem _buildMenuItem(IconData icon, String label) { return PopupMenuItem( child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 24.0), child: Icon(icon, color: Colors.black54)), Text(label, style: menuItemStyle), ], ), ); } void _toggleFavorite() { setState(() { if (_favoriteRecipes.contains(widget.recipe)) _favoriteRecipes.remove(widget.recipe); else _favoriteRecipes.add(widget.recipe); }); } } /// Displays the recipe's name and instructions. class RecipeSheet extends StatelessWidget { RecipeSheet({Key key, this.recipe}) : super(key: key); final TextStyle titleStyle = const PestoStyle(fontSize: 34.0); final TextStyle descriptionStyle = const PestoStyle( fontSize: 15.0, color: Colors.black54, height: 24.0 / 15.0); final TextStyle itemStyle = const PestoStyle(fontSize: 15.0, height: 24.0 / 15.0); final TextStyle itemAmountStyle = PestoStyle( fontSize: 15.0, color: _kTheme.primaryColor, height: 24.0 / 15.0); final TextStyle headingStyle = const PestoStyle( fontSize: 16.0, fontWeight: FontWeight.bold, height: 24.0 / 15.0); final Recipe recipe; @override Widget build(BuildContext context) { return Material( child: SafeArea( top: false, bottom: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 40.0), child: Table( columnWidths: const { 0: FixedColumnWidth(64.0) }, children: [ TableRow(children: [ TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Image.asset('${recipe.ingredientsImagePath}', package: recipe.ingredientsImagePackage, width: 32.0, height: 32.0, alignment: Alignment.centerLeft, fit: BoxFit.scaleDown)), TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Text(recipe.name, style: titleStyle)), ]), TableRow(children: [ const SizedBox(), Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), child: Text(recipe.description, style: descriptionStyle)), ]), TableRow(children: [ const SizedBox(), Padding( padding: const EdgeInsets.only(top: 24.0, bottom: 4.0), child: Text('Ingredients', style: headingStyle)), ]), ] ..addAll(recipe.ingredients .map((RecipeIngredient ingredient) { return _buildItemRow(ingredient.amount, ingredient.description); })) ..add(TableRow(children: [ const SizedBox(), Padding( padding: const EdgeInsets.only(top: 24.0, bottom: 4.0), child: Text('Steps', style: headingStyle)), ])) ..addAll(recipe.steps.map((RecipeStep step) { return _buildItemRow(step.duration ?? '', step.description); })), ), ), ), ); } TableRow _buildItemRow(String left, String right) { return TableRow( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Text(left, style: itemAmountStyle), ), Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Text(right, style: itemStyle), ), ], ); } } class Recipe { const Recipe( {this.name, this.author, this.description, this.imagePath, this.imagePackage, this.ingredientsImagePath, this.ingredientsImagePackage, this.ingredients, this.steps}); final String name; final String author; final String description; final String imagePath; final String imagePackage; final String ingredientsImagePath; final String ingredientsImagePackage; final List ingredients; final List steps; } class RecipeIngredient { const RecipeIngredient({this.amount, this.description}); final String amount; final String description; } class RecipeStep { const RecipeStep({this.duration, this.description}); final String duration; final String description; } const List kPestoRecipes = [ Recipe( name: 'Roasted Chicken', author: 'Peter Carlsson', ingredientsImagePath: 'food/icons/main.png', description: 'The perfect dish to welcome your family and friends with on a crisp autumn night. Pair with roasted veggies to truly impress them.', imagePath: 'food/roasted_chicken.png', ingredients: [ RecipeIngredient(amount: '1 whole', description: 'Chicken'), RecipeIngredient(amount: '1/2 cup', description: 'Butter'), RecipeIngredient(amount: '1 tbsp', description: 'Onion powder'), RecipeIngredient(amount: '1 tbsp', description: 'Freshly ground pepper'), RecipeIngredient(amount: '1 tsp', description: 'Salt'), ], steps: [ RecipeStep(duration: '1 min', description: 'Put in oven'), RecipeStep(duration: '1hr 45 min', description: 'Cook'), ], ), Recipe( name: 'Chopped Beet Leaves', author: 'Trevor Hansen', ingredientsImagePath: 'food/icons/veggie.png', description: 'This vegetable has more to offer than just its root. Beet greens can be tossed into a salad to add some variety or sauteed on its own with some oil and garlic.', imagePath: 'food/chopped_beet_leaves.png', ingredients: [ RecipeIngredient(amount: '3 cups', description: 'Beet greens'), ], steps: [ RecipeStep(duration: '5 min', description: 'Chop'), ], ), Recipe( name: 'Pesto Pasta', author: 'Ali Connors', ingredientsImagePath: 'food/icons/main.png', description: 'With this pesto recipe, you can quickly whip up a meal to satisfy your savory needs. And if you\'re feeling festive, you can add bacon to taste.', imagePath: 'food/pesto_pasta.png', ingredients: [ RecipeIngredient(amount: '1/4 cup ', description: 'Pasta'), RecipeIngredient(amount: '2 cups', description: 'Fresh basil leaves'), RecipeIngredient(amount: '1/2 cup', description: 'Parmesan cheese'), RecipeIngredient( amount: '1/2 cup', description: 'Extra virgin olive oil'), RecipeIngredient(amount: '1/3 cup', description: 'Pine nuts'), RecipeIngredient(amount: '1/4 cup', description: 'Lemon juice'), RecipeIngredient(amount: '3 cloves', description: 'Garlic'), RecipeIngredient(amount: '1/4 tsp', description: 'Salt'), RecipeIngredient(amount: '1/8 tsp', description: 'Pepper'), RecipeIngredient(amount: '3 lbs', description: 'Bacon'), ], steps: [ RecipeStep(duration: '15 min', description: 'Blend'), ], ), Recipe( name: 'Cherry Pie', author: 'Sandra Adams', ingredientsImagePath: 'food/icons/main.png', description: 'Sometimes when you\'re craving some cheer in your life you can jumpstart your day with some cherry pie. Dessert for breakfast is perfectly acceptable.', imagePath: 'food/cherry_pie.png', ingredients: [ RecipeIngredient(amount: '1', description: 'Pie crust'), RecipeIngredient( amount: '4 cups', description: 'Fresh or frozen cherries'), RecipeIngredient(amount: '1 cup', description: 'Granulated sugar'), RecipeIngredient(amount: '4 tbsp', description: 'Cornstarch'), RecipeIngredient(amount: '1½ tbsp', description: 'Butter'), ], steps: [ RecipeStep(duration: '15 min', description: 'Mix'), RecipeStep(duration: '1hr 30 min', description: 'Bake'), ], ), Recipe( name: 'Spinach Salad', author: 'Peter Carlsson', ingredientsImagePath: 'food/icons/spicy.png', description: 'Everyone\'s favorite leafy green is back. Paired with fresh sliced onion, it\'s ready to tackle any dish, whether it be a salad or an egg scramble.', imagePath: 'food/spinach_onion_salad.png', ingredients: [ RecipeIngredient(amount: '4 cups', description: 'Spinach'), RecipeIngredient(amount: '1 cup', description: 'Sliced onion'), ], steps: [ RecipeStep(duration: '5 min', description: 'Mix'), ], ), Recipe( name: 'Butternut Squash Soup', author: 'Ali Connors', ingredientsImagePath: 'food/icons/healthy.png', description: 'This creamy butternut squash soup will warm you on the chilliest of winter nights and bring a delightful pop of orange to the dinner table.', imagePath: 'food/butternut_squash_soup.png', ingredients: [ RecipeIngredient(amount: '1', description: 'Butternut squash'), RecipeIngredient(amount: '4 cups', description: 'Chicken stock'), RecipeIngredient(amount: '2', description: 'Potatoes'), RecipeIngredient(amount: '1', description: 'Onion'), RecipeIngredient(amount: '1', description: 'Carrot'), RecipeIngredient(amount: '1', description: 'Celery'), RecipeIngredient(amount: '1 tsp', description: 'Salt'), RecipeIngredient(amount: '1 tsp', description: 'Pepper'), ], steps: [ RecipeStep(duration: '10 min', description: 'Prep vegetables'), RecipeStep(duration: '5 min', description: 'Stir'), RecipeStep(duration: '1 hr 10 min', description: 'Cook') ], ), Recipe( name: 'Spanakopita', author: 'Trevor Hansen', ingredientsImagePath: 'food/icons/quick.png', description: 'You \'feta\' believe this is a crowd-pleaser! Flaky phyllo pastry surrounds a delicious mixture of spinach and cheeses to create the perfect appetizer.', imagePath: 'food/spanakopita.png', ingredients: [ RecipeIngredient(amount: '1 lb', description: 'Spinach'), RecipeIngredient(amount: '½ cup', description: 'Feta cheese'), RecipeIngredient(amount: '½ cup', description: 'Cottage cheese'), RecipeIngredient(amount: '2', description: 'Eggs'), RecipeIngredient(amount: '1', description: 'Onion'), RecipeIngredient(amount: '½ lb', description: 'Phyllo dough'), ], steps: [ RecipeStep(duration: '5 min', description: 'Sauté vegetables'), RecipeStep( duration: '3 min', description: 'Stir vegetables and other filling ingredients'), RecipeStep( duration: '10 min', description: 'Fill phyllo squares half-full with filling and fold.'), RecipeStep(duration: '40 min', description: 'Bake') ], ), ];