From 35f1670098627434fbe3fa55d4a6b133ef981ef9 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Mon, 12 Jul 2021 14:22:53 -0700 Subject: [PATCH] Update Navigation and Routing sample (#851) * Add duration parameter to FadeTransitionPage * Use didChangeDependencies instead of didUpdateWidget * Don't notify listeners if the path hasn't changed * Update navigation sample WIP * Use Link and RouteStateScope in settings screen * update README * use named parameters for Library.addBook() * Make _handleAuthStateChanged synchronous * add missing copyright headers * Address code review comments * Address code review comments --- navigation_and_routing/README.md | 149 +++++++++++++++++- navigation_and_routing/lib/main.dart | 10 +- navigation_and_routing/lib/src/app.dart | 29 +++- navigation_and_routing/lib/src/auth.dart | 4 + navigation_and_routing/lib/src/data.dart | 4 + .../lib/src/data/library.dart | 7 +- navigation_and_routing/lib/src/routing.dart | 4 + .../lib/src/routing/parsed_route.dart | 7 + .../lib/src/routing/parser.dart | 18 ++- .../lib/src/routing/route_state.dart | 9 +- .../lib/src/screens/book_details.dart | 2 +- .../lib/src/screens/books.dart | 27 ++-- .../lib/src/screens/navigator.dart | 30 ++-- .../lib/src/screens/settings.dart | 14 +- .../lib/src/screens/sign_in.dart | 21 +-- .../lib/src/widgets/fade_transition_page.dart | 17 +- navigation_and_routing/test/library_test.dart | 24 ++- 17 files changed, 298 insertions(+), 78 deletions(-) diff --git a/navigation_and_routing/README.md b/navigation_and_routing/README.md index 2839f0e14..8502eee55 100644 --- a/navigation_and_routing/README.md +++ b/navigation_and_routing/README.md @@ -1,5 +1,148 @@ -# bookstore +# Navigation and Routing +A sample that shows how to use the [Router][] API to handle common navigation +scenarios. -This sample shows how to set up a Router using a custom RouterDelegate and -RouteInformationParser. +## Goals +- Demonstrate common navigation scenarios: + - Parsing path parameters ('/user/:id') + - Sign in (validation / guards) + - Nested navigation +- Provide a reusable implementation of RouterDelegate and RouteInformationParser +- Demonstrate how [deep linking][] is configured on iOS and Android +- Demonstrate how to use the Link widget from `package:url_Launcher` with the + Router API. +## How it works +The top-level widget, `Bookstore`, sets up the state for this app. It places +three `InheritedNotifier` widgets in the tree: `RouteStateScope`, +`BookstoreAuthScope`, and `LibraryScope`, which provide the state for the +application: + + - **`RouteState`**: stores the current route path (`/book/1`) as a `ParsedRoute` + object (see below). + - **`BookstoreAuthScope`**: stores a mock authentication API, `BookstoreAuth`. + - **`LibraryScope`**: stores the data for the app, `Library`. + +The `Bookstore` widget also uses the [MaterialApp.router()][router-ctor] +constructor to opt-in to the [Router][] API. This constructor requires a +[RouterDelegate][] and [RouteInformationParser][]. This app uses the +`routing.dart` library, described below. + +## routing.dart +This library contains a general-purpose routing solution for medium-sized apps. +It implements these classes: + +- **`SimpleRouterDelegate`**: Implements `RouterDelegate`. Updates `RouteState` when + a new route has been pushed to the application by the operating system. Also + notifies the `Router` widget whenever the `RouteState` changes. +- **`TemplateRouteParser`**: Implements RouteInformationParser. Parses the + incoming route path into a `ParsedRoute` object. A `RouteGuard` can be + provided to guard access to certain routes. +- **`ParsedRoute`**: Contains the current route location ("/user/2"), path + parameters ({id: 2}), query parameters ("?search=abc"), and path template + ("/user/:id") +- **`RouteState`**: Stores the current `ParsedRoute`. +- **`RouteGuard`**: Guards access to routes. Can be overridden to redirect the + incoming route if a condition isn't met. + +## App Structure + +The `SimpleRouterDelegate` constructor requires a `WidgetBuilder` parameter and +a `navigatorKey`. This app uses a `BookstoreNavigator` widget, which configures +a `Navigator` with a list of pages, based on the current `RouteState`. + +```dart +SimpleRouterDelegate( + routeState: routeState, + navigatorKey: navigatorKey, + builder: (context) => BookstoreNavigator( + navigatorKey: navigatorKey, + ), +); +``` + +This `Navigator` is configured to display either the sign-in screen or the +`BookstoreScaffold`. An additional screen is stacked on top of the +`BookstoreScaffold` if a book or author is currently selected: + +```dart +return Navigator( + key: widget.navigatorKey, + onPopPage: (route, dynamic result) { + // ... + }, + pages: [ + if (routeState.route.pathTemplate == '/signin') + FadeTransitionPage( + key: signInKey, + child: SignInScreen(), + ), + else ...[ + FadeTransitionPage( + key: scaffoldKey, + child: BookstoreScaffold(), + ), + if (selectedBook != null) + MaterialPage( + key: bookDetailsKey, + child: BookDetailsScreen( + book: selectedBook, + ), + ) + else if (selectedAuthor != null) + MaterialPage( + key: authorDetailsKey, + child: AuthorDetailsScreen( + author: selectedAuthor, + ), + ), + ], + ], +); +``` + +The `BookstoreScaffold` widget uses `package:adaptive_navigation` to build a +navigation rail or bottom navigation bar based on the size of the screen. The +body of this screen is `BookstoreScaffoldBody`, which configures a nested +Navigator to display either the `AuthorsScreen`, `SettingsScreen`, or +`BooksScreen` widget. + +## Linking vs updating RouteState + +There are two ways to change the current route, either by updating `RouteState`, +which the RouterDelegate listens to, or use the Link widget from +`package:url_launcher`. The `SettingsScreen` widget demonstrates both options: + +``` +Link( + uri: Uri.parse('/book/0'), + builder: (context, followLink) { + return TextButton( + child: const Text('Go directly to /book/0 (Link)'), + onPressed: followLink, + ); + }, +), +TextButton( + child: const Text('Go directly to /book/0 (RouteState)'), + onPressed: () { + RouteStateScope.of(context)!.go('/book/0'); + }, +), +``` + +## Questions/issues + +If you have a general question about the Router API, the best places to go are: + +- [The FlutterDev Google Group](https://groups.google.com/forum/#!forum/flutter-dev) +- [StackOverflow](https://stackoverflow.com/questions/tagged/flutter) + +If you run into an issue with the sample itself, please file an issue +in the [main Flutter repo](https://github.com/flutter/flutter/issues). + +[Router]: https://api.flutter.dev/flutter/widgets/Router-class.html +[RouterDelegate]: https://api.flutter.dev/flutter/widgets/RouterDelegate-class.html +[RouteInformationParser]: https://api.flutter.dev/flutter/widgets/RouteInformationParser-class.html +[router-ctor]: https://api.flutter.dev/flutter/material/MaterialApp/MaterialApp.router.html +[deep linking]: https://flutter.dev/docs/development/ui/navigation/deep-linking diff --git a/navigation_and_routing/lib/main.dart b/navigation_and_routing/lib/main.dart index 9b68df0ea..87d0058f9 100644 --- a/navigation_and_routing/lib/main.dart +++ b/navigation_and_routing/lib/main.dart @@ -10,6 +10,14 @@ import 'src/app.dart'; void main() { // Use package:url_strategy until this pull request is released: // https://github.com/flutter/flutter/pull/77103 - setPathUrlStrategy(); + + // Use to setHashUrlStrategy() to use "/#/" in the address bar (default). Use + // setPathUrlStrategy() to use the path. You may need to configure your web + // server to redirect all paths to index.html. + // + // On mobile platforms, both functions are no-ops. + setHashUrlStrategy(); + // setPathUrlStrategy(); + runApp(const Bookstore()); } diff --git a/navigation_and_routing/lib/src/app.dart b/navigation_and_routing/lib/src/app.dart index 9c50ec4b0..4419264bf 100644 --- a/navigation_and_routing/lib/src/app.dart +++ b/navigation_and_routing/lib/src/app.dart @@ -26,10 +26,26 @@ class _BookstoreState extends State { final GlobalKey navigatorKey = GlobalKey(); final library = Library() - ..addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true) - ..addBook('Too Like the Lightning', 'Ada Palmer', false, true) - ..addBook('Kindred', 'Octavia E. Butler', true, false) - ..addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false); + ..addBook( + title: 'Left Hand of Darkness', + authorName: 'Ursula K. Le Guin', + isPopular: true, + isNew: true) + ..addBook( + title: 'Too Like the Lightning', + authorName: 'Ada Palmer', + isPopular: false, + isNew: true) + ..addBook( + title: 'Kindred', + authorName: 'Octavia E. Butler', + isPopular: true, + isNew: false) + ..addBook( + title: 'The Lathe of Heaven', + authorName: 'Ursula K. Le Guin', + isPopular: false, + isNew: false); @override void initState() { @@ -37,7 +53,7 @@ class _BookstoreState extends State { /// Configure the parser with all of the app's allowed path templates. routeParser = TemplateRouteParser( - [ + allowedPaths: [ '/signin', '/authors', '/settings', @@ -58,7 +74,6 @@ class _BookstoreState extends State { navigatorKey: navigatorKey, builder: (context) => BookstoreNavigator( navigatorKey: navigatorKey, - auth: auth, ), ); @@ -85,7 +100,7 @@ class _BookstoreState extends State { ); } - Future _handleAuthStateChanged() async { + void _handleAuthStateChanged() { if (!auth.signedIn) { routeState.go('/signin'); } diff --git a/navigation_and_routing/lib/src/auth.dart b/navigation_and_routing/lib/src/auth.dart index 07d46c741..aca584a6b 100644 --- a/navigation_and_routing/lib/src/auth.dart +++ b/navigation_and_routing/lib/src/auth.dart @@ -1,2 +1,6 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + export 'auth/auth.dart'; export 'auth/auth_guard.dart'; diff --git a/navigation_and_routing/lib/src/data.dart b/navigation_and_routing/lib/src/data.dart index eb3ce57d0..f82a31de1 100644 --- a/navigation_and_routing/lib/src/data.dart +++ b/navigation_and_routing/lib/src/data.dart @@ -1,3 +1,7 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + export 'data/author.dart'; export 'data/book.dart'; export 'data/library.dart'; diff --git a/navigation_and_routing/lib/src/data/library.dart b/navigation_and_routing/lib/src/data/library.dart index bbded022e..8ae2c485e 100644 --- a/navigation_and_routing/lib/src/data/library.dart +++ b/navigation_and_routing/lib/src/data/library.dart @@ -11,7 +11,12 @@ class Library { final List allBooks = []; final List allAuthors = []; - void addBook(String title, String authorName, bool isPopular, bool isNew) { + void addBook({ + required String title, + required String authorName, + required bool isPopular, + required bool isNew, + }) { var author = allAuthors.firstWhereOrNull((author) => author.name == authorName); var book = Book(allBooks.length, title, isPopular, isNew); diff --git a/navigation_and_routing/lib/src/routing.dart b/navigation_and_routing/lib/src/routing.dart index 5bb948d7a..cf0be2c0a 100644 --- a/navigation_and_routing/lib/src/routing.dart +++ b/navigation_and_routing/lib/src/routing.dart @@ -1,3 +1,7 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + export 'routing/delegate.dart'; export 'routing/parsed_route.dart'; export 'routing/parser.dart'; diff --git a/navigation_and_routing/lib/src/routing/parsed_route.dart b/navigation_and_routing/lib/src/routing/parsed_route.dart index 41a5f64c2..3cca9372a 100644 --- a/navigation_and_routing/lib/src/routing/parsed_route.dart +++ b/navigation_and_routing/lib/src/routing/parsed_route.dart @@ -9,9 +9,16 @@ import 'parser.dart'; /// A route path that has been parsed by [TemplateRouteParser]. class ParsedRoute { + /// The current path location without query parameters. (/book/123) final String path; + + /// The path template (/book/:id) final String pathTemplate; + + /// The path parameters ({id: 123}) final Map parameters; + + /// The query parameters ({search: abc}) final Map queryParameters; static const _mapEquality = MapEquality(); diff --git a/navigation_and_routing/lib/src/routing/parser.dart b/navigation_and_routing/lib/src/routing/parser.dart index 1904e024a..4d0bbda6d 100644 --- a/navigation_and_routing/lib/src/routing/parser.dart +++ b/navigation_and_routing/lib/src/routing/parser.dart @@ -7,6 +7,11 @@ import 'package:path_to_regexp/path_to_regexp.dart'; import 'parsed_route.dart'; +/// Used by [TemplateRouteParser] to guard access to routes. +/// +/// Override this class to change the route that is returned by +/// [TemplateRouteParser.parseRouteInformation] if a condition is not met, for +/// example, if the user is not signed in. abstract class RouteGuard { Future redirect(T from); } @@ -17,11 +22,16 @@ class TemplateRouteParser extends RouteInformationParser { RouteGuard? guard; final ParsedRoute initialRoute; - TemplateRouteParser(List pathTemplates, - {String? initialRoute = '/', this.guard}) - : initialRoute = + TemplateRouteParser({ + /// The list of allowed path templates (['/', '/users/:id']) + required List allowedPaths, + /// The initial route + String? initialRoute = '/', + /// [RouteGuard] used to redirect. + this.guard, + }) : initialRoute = ParsedRoute(initialRoute ?? '/', initialRoute ?? '/', {}, {}) { - for (var template in pathTemplates) { + for (var template in allowedPaths) { _addRoute(template); } } diff --git a/navigation_and_routing/lib/src/routing/route_state.dart b/navigation_and_routing/lib/src/routing/route_state.dart index 5ee7e176f..21565b78a 100644 --- a/navigation_and_routing/lib/src/routing/route_state.dart +++ b/navigation_and_routing/lib/src/routing/route_state.dart @@ -8,11 +8,11 @@ import 'package:flutter/widgets.dart'; import 'parsed_route.dart'; import 'parser.dart'; -/// The current route state. To change the current route, call obtain the state using -/// `RouteState.of(context)` and call `go()`: +/// The current route state. To change the current route, call obtain the state +/// using `RouteStateScope.of(context)` and call `go()`: /// /// ``` -/// RouteState.of(context).go('/book/2'); +/// RouteStateScope.of(context).go('/book/2'); /// ``` class RouteState extends ChangeNotifier { TemplateRouteParser parser; @@ -24,6 +24,9 @@ class RouteState extends ChangeNotifier { ParsedRoute get route => _route; set route(ParsedRoute route) { + // Don't notify listeners if the path hasn't changed. + if (_route == route) return; + _route = route; notifyListeners(); } diff --git a/navigation_and_routing/lib/src/screens/book_details.dart b/navigation_and_routing/lib/src/screens/book_details.dart index 96e71e015..7193d73d9 100644 --- a/navigation_and_routing/lib/src/screens/book_details.dart +++ b/navigation_and_routing/lib/src/screens/book_details.dart @@ -21,7 +21,7 @@ class BookDetailsScreen extends StatelessWidget { if (book == null) { return const Scaffold( body: Center( - child: Text('No book with found.'), + child: Text('No book found.'), ), ); } diff --git a/navigation_and_routing/lib/src/screens/books.dart b/navigation_and_routing/lib/src/screens/books.dart index d4239fb24..32bd8c50e 100644 --- a/navigation_and_routing/lib/src/screens/books.dart +++ b/navigation_and_routing/lib/src/screens/books.dart @@ -32,6 +32,20 @@ class _BooksScreenState extends State ..addListener(_handleTabIndexChanged); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final newPath = routeState.route.pathTemplate; + if (newPath.startsWith('/books/popular')) { + _tabController.index = 0; + } else if (newPath.startsWith('/books/new')) { + _tabController.index = 1; + } else if (newPath == '/books/all') { + _tabController.index = 2; + } + } + @override void dispose() { _tabController.removeListener(_handleTabIndexChanged); @@ -114,17 +128,4 @@ class _BooksScreenState extends State break; } } - - @override - void didUpdateWidget(BooksScreen oldWidget) { - var newPath = routeState.route.pathTemplate; - if (newPath.startsWith('/books/popular')) { - _tabController.index = 0; - } else if (newPath.startsWith('/books/new')) { - _tabController.index = 1; - } else if (newPath == '/books/all') { - _tabController.index = 2; - } - super.didUpdateWidget(oldWidget); - } } diff --git a/navigation_and_routing/lib/src/screens/navigator.dart b/navigation_and_routing/lib/src/screens/navigator.dart index d02b99f6f..a443f917b 100644 --- a/navigation_and_routing/lib/src/screens/navigator.dart +++ b/navigation_and_routing/lib/src/screens/navigator.dart @@ -1,3 +1,7 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. 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:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -15,10 +19,8 @@ import 'scaffold.dart'; /// on the [routeState] that was parsed by the TemplateRouteParser. class BookstoreNavigator extends StatefulWidget { final GlobalKey navigatorKey; - final BookstoreAuth auth; const BookstoreNavigator({ - required this.auth, required this.navigatorKey, Key? key, }) : super(key: key); @@ -28,6 +30,7 @@ class BookstoreNavigator extends StatefulWidget { } class _BookstoreNavigatorState extends State { + final signInKey = const ValueKey('Sign in'); final scaffoldKey = const ValueKey('App scaffold'); final bookDetailsKey = const ValueKey('Book details screen'); final authorDetailsKey = const ValueKey('Author details screen'); @@ -35,18 +38,19 @@ class _BookstoreNavigatorState extends State { @override Widget build(BuildContext context) { final routeState = RouteStateScope.of(context)!; + final authState = BookstoreAuthScope.of(context)!; final pathTemplate = routeState.route.pathTemplate; final library = LibraryScope.of(context); - Book? book; + Book? selectedBook; if (pathTemplate == '/book/:bookId') { - book = library.allBooks.firstWhereOrNull( + selectedBook = library.allBooks.firstWhereOrNull( (b) => b.id.toString() == routeState.route.parameters['bookId']); } - Author? author; + Author? selectedAuthor; if (pathTemplate == '/author/:authorId') { - author = library.allAuthors.firstWhereOrNull( + selectedAuthor = library.allAuthors.firstWhereOrNull( (b) => b.id.toString() == routeState.route.parameters['authorId']); } @@ -71,11 +75,11 @@ class _BookstoreNavigatorState extends State { if (routeState.route.pathTemplate == '/signin') // Display the sign in screen. FadeTransitionPage( - key: const ValueKey('Sign in'), + key: signInKey, child: SignInScreen( onSignIn: (credentials) async { - var signedIn = await widget.auth - .signIn(credentials.username, credentials.password); + var signedIn = await authState.signIn( + credentials.username, credentials.password); if (signedIn) { routeState.go('/books/popular'); } @@ -90,18 +94,18 @@ class _BookstoreNavigatorState extends State { ), // Add an additional page to the stack if the user is viewing a book // or an author - if (book != null) + if (selectedBook != null) MaterialPage( key: bookDetailsKey, child: BookDetailsScreen( - book: book, + book: selectedBook, ), ) - else if (author != null) + else if (selectedAuthor != null) MaterialPage( key: authorDetailsKey, child: AuthorDetailsScreen( - author: author, + author: selectedAuthor, ), ), ], diff --git a/navigation_and_routing/lib/src/screens/settings.dart b/navigation_and_routing/lib/src/screens/settings.dart index eafb8c3e2..35a18bff3 100644 --- a/navigation_and_routing/lib/src/screens/settings.dart +++ b/navigation_and_routing/lib/src/screens/settings.dart @@ -2,6 +2,7 @@ // for details. 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:bookstore/src/routing.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/link.dart'; @@ -62,18 +63,15 @@ class SettingsContent extends StatelessWidget { uri: Uri.parse('/book/0'), builder: (context, followLink) { return TextButton( - child: const Text('Go directly to /book/0'), + child: const Text('Go directly to /book/0 (Link)'), onPressed: followLink, ); }, ), - Link( - uri: Uri.parse('/author/0'), - builder: (context, followLink) { - return TextButton( - child: const Text('Go directly to /author/0'), - onPressed: followLink, - ); + TextButton( + child: const Text('Go directly to /book/0 (RouteState)'), + onPressed: () { + RouteStateScope.of(context)!.go('/book/0'); }, ), ].map((w) => Padding(padding: const EdgeInsets.all(8), child: w)), diff --git a/navigation_and_routing/lib/src/screens/sign_in.dart b/navigation_and_routing/lib/src/screens/sign_in.dart index 1d2926560..e66bf1895 100644 --- a/navigation_and_routing/lib/src/screens/sign_in.dart +++ b/navigation_and_routing/lib/src/screens/sign_in.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; class Credentials { final String username; final String password; + Credentials(this.username, this.password); } @@ -24,8 +25,8 @@ class SignInScreen extends StatefulWidget { } class _SignInScreenState extends State { - String username = ''; - String password = ''; + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); @override Widget build(BuildContext context) { @@ -42,26 +43,20 @@ class _SignInScreenState extends State { Text('Sign in', style: Theme.of(context).textTheme.headline4), TextField( decoration: const InputDecoration(labelText: 'Username'), - onChanged: (v) { - setState(() { - username = v; - }); - }, + controller: _usernameController, ), TextField( decoration: const InputDecoration(labelText: 'Password'), obscureText: true, - onChanged: (v) { - setState(() { - password = v; - }); - }, + controller: _passwordController, ), Padding( padding: const EdgeInsets.all(16), child: TextButton( onPressed: () async { - widget.onSignIn(Credentials(username, password)); + widget.onSignIn(Credentials( + _usernameController.value.text, + _passwordController.value.text)); }, child: const Text('Sign in'), ), diff --git a/navigation_and_routing/lib/src/widgets/fade_transition_page.dart b/navigation_and_routing/lib/src/widgets/fade_transition_page.dart index 2d64a4e1c..cdb4d565e 100644 --- a/navigation_and_routing/lib/src/widgets/fade_transition_page.dart +++ b/navigation_and_routing/lib/src/widgets/fade_transition_page.dart @@ -6,9 +6,13 @@ import 'package:flutter/material.dart'; class FadeTransitionPage extends Page { final Widget child; + final Duration duration; - const FadeTransitionPage({LocalKey? key, required this.child}) - : super(key: key); + const FadeTransitionPage({ + LocalKey? key, + required this.child, + this.duration = const Duration(milliseconds: 300), + }) : super(key: key); @override Route createRoute(BuildContext context) { @@ -17,10 +21,9 @@ class FadeTransitionPage extends Page { } class PageBasedFadeTransitionRoute extends PageRoute { - PageBasedFadeTransitionRoute(Page page) - : super( - settings: page, - ); + final FadeTransitionPage page; + + PageBasedFadeTransitionRoute(this.page) : super(settings: page); @override Color? get barrierColor => null; @@ -29,7 +32,7 @@ class PageBasedFadeTransitionRoute extends PageRoute { String? get barrierLabel => null; @override - Duration get transitionDuration => const Duration(milliseconds: 300); + Duration get transitionDuration => page.duration; @override bool get maintainState => true; diff --git a/navigation_and_routing/test/library_test.dart b/navigation_and_routing/test/library_test.dart index bdebca088..4305d47cc 100644 --- a/navigation_and_routing/test/library_test.dart +++ b/navigation_and_routing/test/library_test.dart @@ -9,10 +9,26 @@ void main() { group('Library', () { test('addBook', () { final library = Library(); - library.addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true); - library.addBook('Too Like the Lightning', 'Ada Palmer', false, true); - library.addBook('Kindred', 'Octavia E. Butler', true, false); - library.addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false); + library.addBook( + title: 'Left Hand of Darkness', + authorName: 'Ursula K. Le Guin', + isPopular: true, + isNew: true); + library.addBook( + title: 'Too Like the Lightning', + authorName: 'Ada Palmer', + isPopular: false, + isNew: true); + library.addBook( + title: 'Kindred', + authorName: 'Octavia E. Butler', + isPopular: true, + isNew: false); + library.addBook( + title: 'The Lathe of Heaven', + authorName: 'Ursula K. Le Guin', + isPopular: false, + isNew: false); expect(library.allAuthors.length, 3); expect(library.allAuthors.first.books.length, 2); expect(library.allBooks.length, 4);