diff --git a/README.md b/README.md index a85425c264..0233d57b03 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Codelabs MDC-101 through MDC-104 will guide you through building and integrating The starter and completed code is in the various branches of this repo. ## Getting Started -Visit the [Google codelabs site](https://codelabs.developers.google.com/), or [codelabs.developers.google.com/codelabs/mdc-101-flutter](https://codelabs.developers.google.com/codelabs/mdc-101-flutter), to follow along the guided steps. +Visit the [Google codelabs site](https://codelabs.developers.google.com/), or [codelabs.developers.google.com/codelabs/mdc-104-flutter](https://codelabs.developers.google.com/codelabs/mdc-104-flutter), to follow along the guided steps. ## Support diff --git a/mdc_100_series/assets/2.0x/slanted_menu.png b/mdc_100_series/assets/2.0x/slanted_menu.png new file mode 100755 index 0000000000..5958055e42 Binary files /dev/null and b/mdc_100_series/assets/2.0x/slanted_menu.png differ diff --git a/mdc_100_series/assets/3.0x/slanted_menu.png b/mdc_100_series/assets/3.0x/slanted_menu.png new file mode 100755 index 0000000000..9b4920d4be Binary files /dev/null and b/mdc_100_series/assets/3.0x/slanted_menu.png differ diff --git a/mdc_100_series/assets/slanted_menu.png b/mdc_100_series/assets/slanted_menu.png new file mode 100755 index 0000000000..c80d2d47ea Binary files /dev/null and b/mdc_100_series/assets/slanted_menu.png differ diff --git a/mdc_100_series/lib/app.dart b/mdc_100_series/lib/app.dart index 08abe62fec..bab16bda50 100644 --- a/mdc_100_series/lib/app.dart +++ b/mdc_100_series/lib/app.dart @@ -14,29 +14,111 @@ import 'package:flutter/material.dart'; +import 'backdrop.dart'; +import 'colors.dart'; import 'home.dart'; import 'login.dart'; +import 'category_menu_page.dart'; +import 'model/product.dart'; +import 'supplemental/cut_corners_border.dart'; -class ShrineApp extends StatelessWidget { +class ShrineApp extends StatefulWidget { + @override + _ShrineAppState createState() => _ShrineAppState(); +} + +class _ShrineAppState extends State { + Category _currentCategory = Category.all; + @override Widget build(BuildContext context) { return MaterialApp( title: 'Shrine', - home: HomePage(), + home: Backdrop( + currentCategory: _currentCategory, + frontLayer: HomePage(category: _currentCategory), + backLayer: CategoryMenuPage( + currentCategory: _currentCategory, + onCategoryTap: _onCategoryTap, + ), + frontTitle: Text('SHRINE'), + backTitle: Text('MENU'), + ), initialRoute: '/login', onGenerateRoute: _getRoute, + theme: _kShrineTheme, ); } - Route _getRoute(RouteSettings settings) { - if (settings.name != '/login') { - return null; - } + /// Function to call when a [Category] is tapped. + void _onCategoryTap(Category category) { + setState(() { + _currentCategory = category; + }); + } +} +Route _getRoute(RouteSettings settings) { + if (settings.name == '/login') { return MaterialPageRoute( settings: settings, builder: (BuildContext context) => LoginPage(), fullscreenDialog: true, ); } + + return null; +} + +final ThemeData _kShrineTheme = _buildShrineTheme(); + +IconThemeData _customIconTheme(IconThemeData original) { + return original.copyWith(color: kShrineBrown900); +} + +ThemeData _buildShrineTheme() { + final ThemeData base = ThemeData.light(); + return base.copyWith( + accentColor: kShrineBrown900, + primaryColor: kShrinePink100, + buttonColor: kShrinePink100, + scaffoldBackgroundColor: kShrineBackgroundWhite, + cardColor: kShrineBackgroundWhite, + textSelectionColor: kShrinePink100, + errorColor: kShrineErrorRed, + buttonTheme: ButtonThemeData( + textTheme: ButtonTextTheme.accent, + ), + primaryIconTheme: base.iconTheme.copyWith(color: kShrineBrown900), + inputDecorationTheme: InputDecorationTheme( + border: CutCornersBorder(), + ), + textTheme: _buildShrineTextTheme(base.textTheme), + primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme), + accentTextTheme: _buildShrineTextTheme(base.accentTextTheme), + iconTheme: _customIconTheme(base.iconTheme), + ); +} + +TextTheme _buildShrineTextTheme(TextTheme base) { + return base.copyWith( + headline: base.headline.copyWith( + fontWeight: FontWeight.w500, + ), + title: base.title.copyWith( + fontSize: 18.0 + ), + caption: base.caption.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), + body2: base.body2.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + ), + ).apply( + fontFamily: 'Rubik', + displayColor: kShrineBrown900, + bodyColor: kShrineBrown900, + ); } diff --git a/mdc_100_series/lib/backdrop.dart b/mdc_100_series/lib/backdrop.dart new file mode 100644 index 0000000000..e03a51885c --- /dev/null +++ b/mdc_100_series/lib/backdrop.dart @@ -0,0 +1,279 @@ +// 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 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +import 'model/product.dart'; +import 'login.dart'; + +const double _kFlingVelocity = 2.0; + +class _FrontLayer extends StatelessWidget { + const _FrontLayer({ + Key key, + this.onTap, + this.child, + }) : super(key: key); + + final VoidCallback onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + return Material( + elevation: 16.0, + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.only(topLeft: Radius.circular(46.0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: 40.0, + alignment: AlignmentDirectional.centerStart, + ), + ), + Expanded( + child: child, + ), + ], + ), + ); + } +} + +class _BackdropTitle extends AnimatedWidget { + final Function onPress; + final Widget frontTitle; + final Widget backTitle; + + const _BackdropTitle({ + Key key, + Listenable listenable, + this.onPress, + @required this.frontTitle, + @required this.backTitle, + }) : assert(frontTitle != null), + assert(backTitle != null), + super(key: key, listenable: listenable); + + @override + Widget build(BuildContext context) { + final Animation animation = this.listenable; + + return DefaultTextStyle( + style: Theme.of(context).primaryTextTheme.title, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: Row(children: [ + // branded icon + SizedBox( + width: 72.0, + child: IconButton( + padding: EdgeInsets.only(right: 8.0), + onPressed: this.onPress, + icon: Stack(children: [ + Opacity( + opacity: animation.value, + child: ImageIcon(AssetImage('assets/slanted_menu.png')), + ), + FractionalTranslation( + translation: Tween( + begin: Offset.zero, + end: Offset(1.0, 0.0), + ).evaluate(animation), + child: ImageIcon(AssetImage('assets/diamond.png')), + )]), + ), + ), + // Here, we do a custom cross fade between backTitle and frontTitle. + // This makes a smooth animation between the two texts. + Stack( + children: [ + Opacity( + opacity: CurvedAnimation( + parent: ReverseAnimation(animation), + curve: Interval(0.5, 1.0), + ).value, + child: FractionalTranslation( + translation: Tween( + begin: Offset.zero, + end: Offset(0.5, 0.0), + ).evaluate(animation), + child: backTitle, + ), + ), + Opacity( + opacity: CurvedAnimation( + parent: animation, + curve: Interval(0.5, 1.0), + ).value, + child: FractionalTranslation( + translation: Tween( + begin: Offset(-0.25, 0.0), + end: Offset.zero, + ).evaluate(animation), + child: frontTitle, + ), + ), + ], + ) + ]), + ); + } +} + +/// Builds a Backdrop. +/// +/// A Backdrop widget has two layers, front and back. The front layer is shown +/// by default, and slides down to show the back layer, from which a user +/// can make a selection. The user can also configure the titles for when the +/// front or back layer is showing. +class Backdrop extends StatefulWidget { + final Category currentCategory; + final Widget frontLayer; + final Widget backLayer; + final Widget frontTitle; + final Widget backTitle; + + const Backdrop({ + @required this.currentCategory, + @required this.frontLayer, + @required this.backLayer, + @required this.frontTitle, + @required this.backTitle, + }) : assert(currentCategory != null), + assert(frontLayer != null), + assert(backLayer != null), + assert(frontTitle != null), + assert(backTitle != null); + + @override + _BackdropState createState() => _BackdropState(); +} + +class _BackdropState extends State + with SingleTickerProviderStateMixin { + final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop'); + AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: Duration(milliseconds: 300), + value: 1.0, + vsync: this, + ); + } + + @override + void didUpdateWidget(Backdrop old) { + super.didUpdateWidget(old); + + if (widget.currentCategory != old.currentCategory) { + _toggleBackdropLayerVisibility(); + } else if (!_frontLayerVisible) { + _controller.fling(velocity: _kFlingVelocity); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _frontLayerVisible { + final AnimationStatus status = _controller.status; + return status == AnimationStatus.completed || + status == AnimationStatus.forward; + } + + void _toggleBackdropLayerVisibility() { + _controller.fling( + velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity); + } + + Widget _buildStack(BuildContext context, BoxConstraints constraints) { + const double layerTitleHeight = 48.0; + 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); + + return Stack( + key: _backdropKey, + children: [ + widget.backLayer, + PositionedTransition( + rect: layerAnimation, + child: _FrontLayer( + onTap: _toggleBackdropLayerVisibility, + child: widget.frontLayer, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + var appBar = AppBar( + brightness: Brightness.light, + elevation: 0.0, + titleSpacing: 0.0, + title: _BackdropTitle( + listenable: _controller.view, + onPress: _toggleBackdropLayerVisibility, + frontTitle: widget.frontTitle, + backTitle: widget.backTitle, + ), + actions: [ + new IconButton( + icon: const Icon(Icons.search), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (BuildContext context) => LoginPage()), + ); + }, + ), + new IconButton( + icon: const Icon(Icons.tune), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (BuildContext context) => LoginPage()), + ); + }, + ), + ], + ); + return Scaffold( + appBar: appBar, + body: LayoutBuilder( + builder: _buildStack, + ), + ); + } +} diff --git a/mdc_100_series/lib/category_menu_page.dart b/mdc_100_series/lib/category_menu_page.dart new file mode 100644 index 0000000000..86ef68487d --- /dev/null +++ b/mdc_100_series/lib/category_menu_page.dart @@ -0,0 +1,82 @@ +// 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 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +import 'colors.dart'; +import 'model/product.dart'; + +class CategoryMenuPage extends StatelessWidget { + final Category currentCategory; + final ValueChanged onCategoryTap; + final List _categories = Category.values; + + const CategoryMenuPage({ + Key key, + @required this.currentCategory, + @required this.onCategoryTap, + }) : assert(currentCategory != null), + assert(onCategoryTap != null); + + Widget _buildCategory(Category category, BuildContext context) { + final categoryString = + category.toString().replaceAll('Category.', '').toUpperCase(); + final ThemeData theme = Theme.of(context); + return GestureDetector( + onTap: () => onCategoryTap(category), + child: category == currentCategory + ? Column( + children: [ + SizedBox(height: 16.0), + Text( + categoryString, + style: theme.textTheme.body2, + textAlign: TextAlign.center, + ), + SizedBox(height: 14.0), + Container( + width: 70.0, + height: 2.0, + color: Color(0xFFEAA4A4), + ), + ], + ) + : Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + categoryString, + style: theme.textTheme.body2.copyWith( + color: kShrineBrown900.withAlpha(153) + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: EdgeInsets.only(top: 40.0), + color: kShrinePink100, + child: ListView( + children: _categories + .map((Category c) => _buildCategory(c, context)) + .toList()), + ), + ); + } +} diff --git a/mdc_100_series/lib/colors.dart b/mdc_100_series/lib/colors.dart new file mode 100644 index 0000000000..c979e3b5e7 --- /dev/null +++ b/mdc_100_series/lib/colors.dart @@ -0,0 +1,26 @@ +// 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 'package:flutter/material.dart'; + +const kShrinePink50 = const Color(0xFFFEEAE6); +const kShrinePink100 = const Color(0xFFFEDBD0); +const kShrinePink300 = const Color(0xFFFBB8AC); + +const kShrineBrown900 = const Color(0xFF442B2D); + +const kShrineErrorRed = const Color(0xFFC5032B); + +const kShrineSurfaceWhite = const Color(0xFFFFFBFA); +const kShrineBackgroundWhite = Colors.white; \ No newline at end of file diff --git a/mdc_100_series/lib/home.dart b/mdc_100_series/lib/home.dart index 7ccf6d7f2a..439b526b76 100644 --- a/mdc_100_series/lib/home.dart +++ b/mdc_100_series/lib/home.dart @@ -14,13 +14,17 @@ import 'package:flutter/material.dart'; +import 'model/data.dart'; +import 'model/product.dart'; +import 'supplemental/asymmetric_view.dart'; + class HomePage extends StatelessWidget { + final Category category; + + const HomePage({this.category: Category.all}); + @override Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Text('You did it!'), - ), - ); + return AsymmetricView(products: getProducts(category)); } } diff --git a/mdc_100_series/lib/login.dart b/mdc_100_series/lib/login.dart index d28a4c2bee..4fd9272ffd 100644 --- a/mdc_100_series/lib/login.dart +++ b/mdc_100_series/lib/login.dart @@ -14,31 +14,98 @@ import 'package:flutter/material.dart'; +import 'colors.dart'; + class LoginPage extends StatefulWidget { @override - _LoginPageState createState() => new _LoginPageState(); + _LoginPageState createState() => _LoginPageState(); } class _LoginPageState extends State { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + @override Widget build(BuildContext context) { - return new Scaffold( - body: new SafeArea( - child: new ListView( - padding: const EdgeInsets.symmetric(horizontal: 24.0), + return Scaffold( + body: SafeArea( + child: ListView( + padding: EdgeInsets.symmetric(horizontal: 24.0), children: [ - const SizedBox(height: 80.0), - new Column( + SizedBox(height: 80.0), + Column( + children: [ + Image.asset('assets/diamond.png'), + SizedBox(height: 16.0), + Text( + 'SHRINE', + style: Theme.of(context).textTheme.headline, + ), + ], + ), + SizedBox(height: 120.0), + PrimaryColorOverride( + color: kShrineBrown900, + child: TextField( + controller: _usernameController, + decoration: InputDecoration( + labelText: 'Username', + ), + ), + ), + const SizedBox(height: 12.0), + new PrimaryColorOverride( + color: kShrineBrown900, + child: TextField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + ), + ), + ), + ButtonBar( children: [ - new Image.asset('assets/diamond.png'), - const SizedBox(height: 16.0), - const Text('SHRINE'), + FlatButton( + child: Text('CANCEL'), + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + onPressed: () { + _usernameController.clear(); + _passwordController.clear(); + }, + ), + RaisedButton( + child: Text('NEXT'), + elevation: 8.0, + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + onPressed: () { + Navigator.pop(context); + }, + ), ], ), - const SizedBox(height: 120.0), ], ), ), ); } } + +class PrimaryColorOverride extends StatelessWidget { + const PrimaryColorOverride({Key key, this.color, this.child}) + : super(key: key); + + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) { + return Theme( + child: child, + data: Theme.of(context).copyWith(primaryColor: color), + ); + } +} diff --git a/mdc_100_series/lib/model/data.dart b/mdc_100_series/lib/model/data.dart old mode 100755 new mode 100644 diff --git a/mdc_100_series/lib/model/product.dart b/mdc_100_series/lib/model/product.dart old mode 100755 new mode 100644 index 5df0ad197b..abca50ab83 --- a/mdc_100_series/lib/model/product.dart +++ b/mdc_100_series/lib/model/product.dart @@ -39,5 +39,5 @@ class Product { String get assetPackage => 'shrine_images'; @override - String toString() => "$name (id=$id)"; + String toString() => '$name (id=$id)'; } diff --git a/mdc_100_series/lib/supplemental/product_columns.dart b/mdc_100_series/lib/supplemental/product_columns.dart index dfa9f63ddf..c12e9ebf6b 100644 --- a/mdc_100_series/lib/supplemental/product_columns.dart +++ b/mdc_100_series/lib/supplemental/product_columns.dart @@ -33,11 +33,12 @@ class TwoProductCardColumn extends StatelessWidget { double heightOfCards = (constraints.biggest.height - spacerHeight) / 2.0; double heightOfImages = heightOfCards - ProductCard.kTextBoxHeight; - double imageAspectRatio = constraints.biggest.width / heightOfImages; + double imageAspectRatio = + (heightOfImages >= 0.0 && constraints.biggest.width > heightOfImages) + ? constraints.biggest.width / heightOfImages + : 33 / 49; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + return ListView( children: [ Padding( padding: EdgeInsetsDirectional.only(start: 28.0), @@ -71,15 +72,15 @@ class OneProductCardColumn extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, + return ListView( + reverse: true, children: [ - ProductCard( - product: product, - ), SizedBox( height: 40.0, ), + ProductCard( + product: product, + ), ], ); } diff --git a/mdc_100_series/mdc_100_series.iml b/mdc_100_series/mdc_100_series.iml index 485a35d430..104f7fb6b8 100644 --- a/mdc_100_series/mdc_100_series.iml +++ b/mdc_100_series/mdc_100_series.iml @@ -1,17 +1,22 @@ + + + + + + - - - + + - \ No newline at end of file diff --git a/mdc_100_series/pubspec.yaml b/mdc_100_series/pubspec.yaml index 2bbb1312d3..b3e7f95a1b 100644 --- a/mdc_100_series/pubspec.yaml +++ b/mdc_100_series/pubspec.yaml @@ -1,5 +1,5 @@ name: Shrine -description: Learn the basics of using Material Components by building a simple app with core components. +description: Take your design up a notch and learn to use our advanced component backdrop menu. dependencies: flutter: @@ -17,6 +17,7 @@ flutter: uses-material-design: true assets: - assets/diamond.png + - assets/slanted_menu.png - packages/shrine_images/0-0.jpg - packages/shrine_images/1-0.jpg - packages/shrine_images/2-0.jpg @@ -55,3 +56,10 @@ flutter: - packages/shrine_images/35-0.jpg - packages/shrine_images/36-0.jpg - packages/shrine_images/37-0.jpg + + fonts: + - family: Rubik + fonts: + - asset: fonts/Rubik-Regular.ttf + - asset: fonts/Rubik-Medium.ttf + weight: 500