diff --git a/MDC-103/complete/assets/0-1.jpg b/MDC-103/complete/assets/0-1.jpg new file mode 100644 index 0000000000..532e9ba6a7 Binary files /dev/null and b/MDC-103/complete/assets/0-1.jpg differ diff --git a/MDC-103/complete/assets/1-1.jpg b/MDC-103/complete/assets/1-1.jpg new file mode 100644 index 0000000000..908f09b487 Binary files /dev/null and b/MDC-103/complete/assets/1-1.jpg differ diff --git a/MDC-103/complete/assets/2-1.jpg b/MDC-103/complete/assets/2-1.jpg new file mode 100644 index 0000000000..f64f4eaaa8 Binary files /dev/null and b/MDC-103/complete/assets/2-1.jpg differ diff --git a/MDC-103/complete/assets/2.0x/diamond.png b/MDC-103/complete/assets/2.0x/diamond.png new file mode 100644 index 0000000000..602e2ea516 Binary files /dev/null and b/MDC-103/complete/assets/2.0x/diamond.png differ diff --git a/MDC-103/complete/assets/3-1.jpg b/MDC-103/complete/assets/3-1.jpg new file mode 100644 index 0000000000..7ea93995f7 Binary files /dev/null and b/MDC-103/complete/assets/3-1.jpg differ diff --git a/MDC-103/complete/assets/3.0x/diamond.png b/MDC-103/complete/assets/3.0x/diamond.png new file mode 100644 index 0000000000..78af7cd648 Binary files /dev/null and b/MDC-103/complete/assets/3.0x/diamond.png differ diff --git a/MDC-103/complete/assets/diamond.png b/MDC-103/complete/assets/diamond.png new file mode 100644 index 0000000000..1978a0a5ab Binary files /dev/null and b/MDC-103/complete/assets/diamond.png differ diff --git a/MDC-103/complete/fonts/Rubik-Medium.ttf b/MDC-103/complete/fonts/Rubik-Medium.ttf new file mode 100755 index 0000000000..c0b7965f98 Binary files /dev/null and b/MDC-103/complete/fonts/Rubik-Medium.ttf differ diff --git a/MDC-103/complete/fonts/Rubik-Regular.ttf b/MDC-103/complete/fonts/Rubik-Regular.ttf new file mode 100755 index 0000000000..fb52c8eec8 Binary files /dev/null and b/MDC-103/complete/fonts/Rubik-Regular.ttf differ diff --git a/MDC-103/complete/lib/app.dart b/MDC-103/complete/lib/app.dart new file mode 100644 index 0000000000..299aac6ea0 --- /dev/null +++ b/MDC-103/complete/lib/app.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'home.dart'; +import 'login.dart'; +import 'notched_corner_border.dart'; +import 'supplemental/theming.dart'; + +class ShrineApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + + return MaterialApp( + title: 'Shrine', + home: new HomePage(), + initialRoute: '/login', + onGenerateRoute: _getRoute, + theme: _kShrineTheme, + ); + } + + Route _getRoute(RouteSettings settings) { + if (settings.name == '/login') { + return new MaterialPageRoute( + settings: settings, + builder: (BuildContext context) => new LoginPage(), + fullscreenDialog: true, + ); + } + + return null; + } +} + +final ThemeData _kShrineTheme = _buildShrineTheme(); + +IconThemeData _customIconTheme(IconThemeData original) { + return original.copyWith(color: kShrineBrown900); +} + +ThemeData _buildShrineTheme() { + final ThemeData base = new ThemeData.light(); + return base.copyWith( + accentColor: kShrineBrown900, + primaryColor: kShrinePink100, + buttonColor: kShrinePink100, + scaffoldBackgroundColor: kShrineBackgroundWhite, + cardColor: kShrineBackgroundWhite, + textSelectionColor: kShrinePink100, + errorColor: kShrineErrorRed, + buttonTheme: const ButtonThemeData( + textTheme: ButtonTextTheme.accent, + ), + primaryIconTheme: base.iconTheme.copyWith( + color: kShrineBrown900 + ), + inputDecorationTheme: new InputDecorationTheme( + border: new NotchedCornerBorder(), + ), + 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, + ), + ).apply( + fontFamily: 'Rubik', + displayColor: kShrineBrown900, + bodyColor: kShrineBrown900, + ); +} diff --git a/MDC-103/complete/lib/home.dart b/MDC-103/complete/lib/home.dart new file mode 100644 index 0000000000..86bd640e84 --- /dev/null +++ b/MDC-103/complete/lib/home.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'model/data.dart'; +import 'supplemental/asymmetric_grid.dart'; + +class HomePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: new AppBar( + brightness: Brightness.light, + leading: new IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + print('Menu button'); + }, + ), + title: const Text('SHRINE'), + actions: [ + new IconButton( + icon: const Icon(Icons.search), + onPressed: () { + print('Search button'); + }, + ), + new IconButton( + icon: const Icon(Icons.tune), + onPressed: () { + print('Filter button'); + }, + ), + ], + ), + body: ProductsView(products: getAllProducts()), + ); + } +} diff --git a/MDC-103/complete/lib/login.dart b/MDC-103/complete/lib/login.dart new file mode 100644 index 0000000000..974e2dc40c --- /dev/null +++ b/MDC-103/complete/lib/login.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'supplemental/theming.dart'; + +class LoginPage extends StatefulWidget { + @override + LoginPageState createState() { + return new LoginPageState(); + } +} + +class LoginPageState extends State { + final _usernameController = new TextEditingController(); + final _passwordController = new TextEditingController(); + + @override + Widget build(BuildContext context) { + return new Scaffold( + body: new SafeArea( + child: new ListView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + children: [ + const SizedBox(height: 80.0), + new Column( + children: [ + new Image.asset('assets/diamond.png'), + const SizedBox(height: 16.0), + new Text( + 'SHRINE', + style: Theme.of(context).textTheme.headline, + ), + ], + ), + const SizedBox(height: 120.0), + new PrimaryColorOverride( + color: kShrineBrown900, + child: new TextField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + ), + ), + ), + const SizedBox(height: 12.0), + new PrimaryColorOverride( + color: kShrineBrown900, + child: new TextField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + ), + ), + ), + new ButtonBar( + children: [ + new FlatButton( + child: const Text('CANCEL'), + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + onPressed: () { + _usernameController.clear(); + _passwordController.clear(); + }, + ), + new RaisedButton( + child: const Text('NEXT'), + elevation: 8.0, + shape: BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ], + ), + ), + ); + } +} + +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 new Theme( + child: child, + data: Theme.of(context).copyWith(primaryColor: color), + ); + } +} diff --git a/MDC-103/complete/lib/main.dart b/MDC-103/complete/lib/main.dart new file mode 100644 index 0000000000..33ed30f2d6 --- /dev/null +++ b/MDC-103/complete/lib/main.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +import 'app.dart'; + +void main() => runApp(new ShrineApp()); diff --git a/MDC-103/complete/lib/model/data.dart b/MDC-103/complete/lib/model/data.dart new file mode 100644 index 0000000000..aabadf8571 --- /dev/null +++ b/MDC-103/complete/lib/model/data.dart @@ -0,0 +1,90 @@ +import 'product.dart'; + +List getAllProducts() { + return const [ + Product( + category: Category.home, + id: 0, + isFeatured: true, + name: 'Tab Can', + price: 35, + ), + Product( + category: Category.accessories, + id: 1, + isFeatured: false, + name: 'Pineapple Wall', + price: 80, + ), + Product( + category: Category.clothing, + id: 2, + isFeatured: false, + name: 'Tab & Fresca Cold', + price: 100, + ), + Product( + category: Category.home, + id: 3, + isFeatured: false, + name: 'Capris', + price: 5, + ), + Product( + category: Category.home, + id: 0, + isFeatured: true, + name: 'Tab Can', + price: 35, + ), + Product( + category: Category.accessories, + id: 1, + isFeatured: false, + name: 'Pineapple Wall', + price: 80, + ), + Product( + category: Category.clothing, + id: 2, + isFeatured: false, + name: 'Tab & Fresca Cold', + price: 100, + ), + Product( + category: Category.home, + id: 3, + isFeatured: false, + name: 'Capris', + price: 5, + ), + Product( + category: Category.home, + id: 0, + isFeatured: true, + name: 'Tab Can', + price: 35, + ), + Product( + category: Category.accessories, + id: 1, + isFeatured: false, + name: 'Pineapple Wall', + price: 80, + ), + Product( + category: Category.clothing, + id: 2, + isFeatured: false, + name: 'Tab & Fresca Cold', + price: 100, + ), + Product( + category: Category.home, + id: 3, + isFeatured: false, + name: 'Capris', + price: 5, + ), + ]; +} diff --git a/MDC-103/complete/lib/model/product.dart b/MDC-103/complete/lib/model/product.dart new file mode 100644 index 0000000000..2d672301e7 --- /dev/null +++ b/MDC-103/complete/lib/model/product.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +enum Category { none, accessories, clothing, home } + +class Product { + const Product({ + @required this.category, + @required this.id, + @required this.isFeatured, + @required this.name, + @required this.price, + }) : assert(category != null), + assert(id != null), + assert(isFeatured != null), + assert(name != null), + assert(price != null); + + final Category category; + final int id; + final bool isFeatured; + final String name; + final int price; + + @override + String toString() => '$name (id=$id)'; +} diff --git a/MDC-103/complete/lib/notched_corner_border.dart b/MDC-103/complete/lib/notched_corner_border.dart new file mode 100644 index 0000000000..7e6a81bae5 --- /dev/null +++ b/MDC-103/complete/lib/notched_corner_border.dart @@ -0,0 +1,125 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class NotchedCornerBorder extends OutlineInputBorder { + const NotchedCornerBorder({ + BorderSide borderSide: BorderSide.none, + BorderRadius borderRadius: const BorderRadius.all(Radius.circular(2.0)), + this.cut: 7.0, + double gapPadding: 2.0, + }) : super( + borderSide: borderSide, + borderRadius: borderRadius, + gapPadding: gapPadding); + + @override + NotchedCornerBorder copyWith({ + BorderSide borderSide, + BorderRadius borderRadius, + double gapPadding, + double cut, + }) { + return new NotchedCornerBorder( + borderRadius: borderRadius ?? this.borderRadius, + borderSide: borderSide ?? this.borderSide, + cut: cut ?? this.cut, + gapPadding: gapPadding ?? this.gapPadding, + ); + } + + final double cut; + + @override + ShapeBorder lerpFrom(ShapeBorder a, double t) { + if (a is NotchedCornerBorder) { + final NotchedCornerBorder outline = a; + return new NotchedCornerBorder( + borderRadius: BorderRadius.lerp(outline.borderRadius, borderRadius, t), + borderSide: BorderSide.lerp(outline.borderSide, borderSide, t), + cut: cut, + gapPadding: outline.gapPadding, + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder lerpTo(ShapeBorder b, double t) { + if (b is NotchedCornerBorder) { + final NotchedCornerBorder outline = b; + return new NotchedCornerBorder( + borderRadius: BorderRadius.lerp(borderRadius, outline.borderRadius, t), + borderSide: BorderSide.lerp(borderSide, outline.borderSide, t), + cut: cut, + gapPadding: outline.gapPadding, + ); + } + return super.lerpTo(b, t); + } + + Path _notchedCornerPath(Rect center, + [double start = 0.0, double extent = 0.0]) { + final Path path = new Path(); + if (start > 0.0 || extent > 0.0) { + path.relativeMoveTo(extent + start, center.top); + _notchedSidesAndBottom(center, path); + path..lineTo(center.left + cut, center.top)..lineTo(start, center.top); + } else { + path.moveTo(center.left + cut, center.top); + _notchedSidesAndBottom(center, path); + path.lineTo(center.left + cut, center.top); + } + return path; + } + + Path _notchedSidesAndBottom(Rect center, Path path) { + return path + ..lineTo(center.right - cut, center.top) + ..lineTo(center.right, center.top + cut) + ..lineTo(center.right, center.top + center.height - cut) + ..lineTo(center.right - cut, center.top + center.height) + ..lineTo(center.left + cut, center.top + center.height) + ..lineTo(center.left, center.top + center.height - cut) + ..lineTo(center.left, center.top + cut); + } + + @override + void paint( + Canvas canvas, + Rect rect, { + double gapStart, + double gapExtent: 0.0, + double gapPercentage: 0.0, + TextDirection textDirection, + }) { + assert(gapExtent != null); + assert(gapPercentage >= 0.0 && gapPercentage <= 1.0); + + final Paint paint = borderSide.toPaint(); + final RRect outer = borderRadius.toRRect(rect); + if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) { + canvas.drawPath(_notchedCornerPath(outer.middleRect), paint); + } else { + final double extent = + lerpDouble(0.0, gapExtent + gapPadding * 2.0, gapPercentage); + switch (textDirection) { + case TextDirection.rtl: + { + final Path path = _notchedCornerPath( + outer.middleRect, gapStart + gapPadding - extent, extent); + canvas.drawPath(path, paint); + break; + } + case TextDirection.ltr: + { + final Path path = _notchedCornerPath( + outer.middleRect, gapStart - gapPadding, extent); + canvas.drawPath(path, paint); + break; + } + } + } + } +} diff --git a/MDC-103/complete/lib/product_card.dart b/MDC-103/complete/lib/product_card.dart new file mode 100644 index 0000000000..1c87e11748 --- /dev/null +++ b/MDC-103/complete/lib/product_card.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'model/product.dart'; + +class ProductCard extends StatelessWidget { + ProductCard({this.imageAspectRatio: 33 / 49, this.product}) + : assert(imageAspectRatio == null || imageAspectRatio > 0); + + final double imageAspectRatio; + final Product product; + + static final kTextBoxHeight = 65.0; + + @override + Widget build(BuildContext context) { + final NumberFormat formatter = new NumberFormat.simpleCurrency( + decimalDigits: 0, locale: Localizations.localeOf(context).toString()); + final ThemeData theme = Theme.of(context); + + final imageWidget = new Image.asset( + 'assets/${product.id}-1.jpg', + fit: BoxFit.cover, + ); + + return new Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + new AspectRatio( + aspectRatio: imageAspectRatio, + child: imageWidget, + ), + new SizedBox( + height: kTextBoxHeight * MediaQuery.of(context).textScaleFactor, + width: 121.0, + child: new Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // TODO(larche): Make headline6 when available + new Text( + product == null ? '' : product.name, + style: theme.textTheme.button, + softWrap: false, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const SizedBox(height: 4.0), + // TODO(larche): Make subtitle2 when available + new Text( + product == null ? '' : formatter.format(product.price), + style: theme.textTheme.caption, + ), + ], + ), + ), + ], + ); + } +} diff --git a/MDC-103/complete/lib/product_columns.dart b/MDC-103/complete/lib/product_columns.dart new file mode 100644 index 0000000000..4cb842dfc4 --- /dev/null +++ b/MDC-103/complete/lib/product_columns.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'model/product.dart'; +import 'product_card.dart'; + +class TwoProductCardColumn extends StatelessWidget { + TwoProductCardColumn({ + this.bottom, + this.top, + }) : assert(bottom != null); + + final Product bottom, top; + + @override + Widget build(BuildContext context) { + return new LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + const spacerHeight = 44.0; + + double heightOfCards = (constraints.biggest.height - spacerHeight) / 2.0; + double heightOfImages = heightOfCards - ProductCard.kTextBoxHeight; + double imageAspectRatio = constraints.biggest.width / heightOfImages; + + return new Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + new Container( + padding: const EdgeInsetsDirectional.only(start: 28.0), + child: top != null + ? new ProductCard( + imageAspectRatio: imageAspectRatio, + product: top, + ) + : new SizedBox( + height: heightOfCards, + ), + ), + const SizedBox(height: spacerHeight), + new Container( + padding: const EdgeInsetsDirectional.only(end: 28.0), + child: new ProductCard( + imageAspectRatio: imageAspectRatio, + product: bottom, + ), + ), + ], + ); + }); + } +} + +class OneProductCardColumn extends StatelessWidget { + OneProductCardColumn({this.product}); + + final Product product; + + @override + Widget build(BuildContext context) { + return new Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + new ProductCard( + product: product, + ), + const SizedBox( + height: 40.0, + ), + ], + ); + } +} diff --git a/MDC-103/complete/lib/supplemental/asymmetric_grid.dart b/MDC-103/complete/lib/supplemental/asymmetric_grid.dart new file mode 100644 index 0000000000..4e5e7e9394 --- /dev/null +++ b/MDC-103/complete/lib/supplemental/asymmetric_grid.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../model/product.dart'; +import '../product_columns.dart'; + +class ProductsView extends StatelessWidget { + final List products; + + const ProductsView({Key key, this.products}); + + List _buildColumns(BuildContext context) { + if (products == null || products.isEmpty) { + return const []; + } + + /// This will return a list of columns. It will oscillate between the two + /// kinds of columns. Even cases of the index (0, 2, 4, etc) will be + /// TwoProductCardColumn and the odd cases will be OneProductCardColumn. + /// + /// Each pair of columns will advance us 3 products forward (2 + 1). That's + /// some kinda awkward math so we use _evenCasesIndex and _oddCasesIndex as + /// helpers for creating the index of the product list that will correspond + /// to the index of the list of columns. + return List.generate(_listItemCount(products.length), (int index) { + double width = .59 * MediaQuery.of(context).size.width; + Widget column; + if (index % 2 == 0) { + /// Even cases + int bottom = _evenCasesIndex(index); + column = new TwoProductCardColumn( + bottom: products[bottom], + top: products.length - 1 >= bottom + 1 + ? products[bottom + 1] + : null); + width += 32.0; + } else { + /// Odd cases + column = new OneProductCardColumn( + product: products[_oddCasesIndex(index)], + ); + } + return new Container( + width: width, + child: new Padding( + padding: new EdgeInsets.symmetric(horizontal: 16.0), + child: column, + ), + ); + }).toList(); + } + + int _evenCasesIndex(int input) { + /// The operator ~/ is a cool one. It's the truncating division operator. It + /// divides the number and if there's a remainder / decimal, it cuts it off. + /// This is like dividing and then casting the result to int. Also, it's + /// functionally equivalent to floor() in this case. + return input ~/ 2 * 3; + } + + int _oddCasesIndex(int input) { + assert(input > 0); + return (input / 2).ceil() * 3 - 1; + } + + int _listItemCount(int totalItems) { + if (totalItems % 3 == 0) { + return totalItems ~/ 3 * 2; + } else { + return (totalItems / 3).ceil() * 2 - 1; + } + } + + @override + Widget build(BuildContext context) { + return new ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(0.0, 34.0, 16.0, 44.0), + children: _buildColumns(context), + ); + } +} diff --git a/MDC-103/complete/lib/supplemental/theming.dart b/MDC-103/complete/lib/supplemental/theming.dart new file mode 100644 index 0000000000..f37f6e713b --- /dev/null +++ b/MDC-103/complete/lib/supplemental/theming.dart @@ -0,0 +1,12 @@ +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 = const Color(0xFFFFFFFF); \ No newline at end of file diff --git a/MDC-103/complete/pubspec.yaml b/MDC-103/complete/pubspec.yaml new file mode 100644 index 0000000000..5079e8743f --- /dev/null +++ b/MDC-103/complete/pubspec.yaml @@ -0,0 +1,28 @@ +name: Shrine +description: Learn the basics of using Material Components by building a simple app with core components. + +dependencies: + flutter: + sdk: flutter + intl: "^0.15.4" + + cupertino_icons: ^0.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - assets/diamond.png + - assets/0-1.jpg + - assets/1-1.jpg + - assets/2-1.jpg + - assets/3-1.jpg + fonts: + - family: Rubik + fonts: + - asset: fonts/Rubik-Regular.ttf + - asset: fonts/Rubik-Medium.ttf + weight: 500