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
pull/854/head
John Ryan 3 years ago committed by GitHub
parent d3c4645a42
commit 35f1670098
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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 ## Goals
RouteInformationParser. - 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<void>(
key: signInKey,
child: SignInScreen(),
),
else ...[
FadeTransitionPage<void>(
key: scaffoldKey,
child: BookstoreScaffold(),
),
if (selectedBook != null)
MaterialPage<void>(
key: bookDetailsKey,
child: BookDetailsScreen(
book: selectedBook,
),
)
else if (selectedAuthor != null)
MaterialPage<void>(
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

@ -10,6 +10,14 @@ import 'src/app.dart';
void main() { void main() {
// Use package:url_strategy until this pull request is released: // Use package:url_strategy until this pull request is released:
// https://github.com/flutter/flutter/pull/77103 // 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()); runApp(const Bookstore());
} }

@ -26,10 +26,26 @@ class _BookstoreState extends State<Bookstore> {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final library = Library() final library = Library()
..addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true) ..addBook(
..addBook('Too Like the Lightning', 'Ada Palmer', false, true) title: 'Left Hand of Darkness',
..addBook('Kindred', 'Octavia E. Butler', true, false) authorName: 'Ursula K. Le Guin',
..addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false); 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 @override
void initState() { void initState() {
@ -37,7 +53,7 @@ class _BookstoreState extends State<Bookstore> {
/// Configure the parser with all of the app's allowed path templates. /// Configure the parser with all of the app's allowed path templates.
routeParser = TemplateRouteParser( routeParser = TemplateRouteParser(
[ allowedPaths: [
'/signin', '/signin',
'/authors', '/authors',
'/settings', '/settings',
@ -58,7 +74,6 @@ class _BookstoreState extends State<Bookstore> {
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
builder: (context) => BookstoreNavigator( builder: (context) => BookstoreNavigator(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
auth: auth,
), ),
); );
@ -85,7 +100,7 @@ class _BookstoreState extends State<Bookstore> {
); );
} }
Future<void> _handleAuthStateChanged() async { void _handleAuthStateChanged() {
if (!auth.signedIn) { if (!auth.signedIn) {
routeState.go('/signin'); routeState.go('/signin');
} }

@ -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.dart';
export 'auth/auth_guard.dart'; export 'auth/auth_guard.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/author.dart';
export 'data/book.dart'; export 'data/book.dart';
export 'data/library.dart'; export 'data/library.dart';

@ -11,7 +11,12 @@ class Library {
final List<Book> allBooks = []; final List<Book> allBooks = [];
final List<Author> allAuthors = []; final List<Author> 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 = var author =
allAuthors.firstWhereOrNull((author) => author.name == authorName); allAuthors.firstWhereOrNull((author) => author.name == authorName);
var book = Book(allBooks.length, title, isPopular, isNew); var book = Book(allBooks.length, title, isPopular, isNew);

@ -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/delegate.dart';
export 'routing/parsed_route.dart'; export 'routing/parsed_route.dart';
export 'routing/parser.dart'; export 'routing/parser.dart';

@ -9,9 +9,16 @@ import 'parser.dart';
/// A route path that has been parsed by [TemplateRouteParser]. /// A route path that has been parsed by [TemplateRouteParser].
class ParsedRoute { class ParsedRoute {
/// The current path location without query parameters. (/book/123)
final String path; final String path;
/// The path template (/book/:id)
final String pathTemplate; final String pathTemplate;
/// The path parameters ({id: 123})
final Map<String, String> parameters; final Map<String, String> parameters;
/// The query parameters ({search: abc})
final Map<String, String> queryParameters; final Map<String, String> queryParameters;
static const _mapEquality = MapEquality<String, String>(); static const _mapEquality = MapEquality<String, String>();

@ -7,6 +7,11 @@ import 'package:path_to_regexp/path_to_regexp.dart';
import 'parsed_route.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<T> { abstract class RouteGuard<T> {
Future<T> redirect(T from); Future<T> redirect(T from);
} }
@ -17,11 +22,16 @@ class TemplateRouteParser extends RouteInformationParser<ParsedRoute> {
RouteGuard<ParsedRoute>? guard; RouteGuard<ParsedRoute>? guard;
final ParsedRoute initialRoute; final ParsedRoute initialRoute;
TemplateRouteParser(List<String> pathTemplates, TemplateRouteParser({
{String? initialRoute = '/', this.guard}) /// The list of allowed path templates (['/', '/users/:id'])
: initialRoute = required List<String> allowedPaths,
/// The initial route
String? initialRoute = '/',
/// [RouteGuard] used to redirect.
this.guard,
}) : initialRoute =
ParsedRoute(initialRoute ?? '/', initialRoute ?? '/', {}, {}) { ParsedRoute(initialRoute ?? '/', initialRoute ?? '/', {}, {}) {
for (var template in pathTemplates) { for (var template in allowedPaths) {
_addRoute(template); _addRoute(template);
} }
} }

@ -8,11 +8,11 @@ import 'package:flutter/widgets.dart';
import 'parsed_route.dart'; import 'parsed_route.dart';
import 'parser.dart'; import 'parser.dart';
/// The current route state. To change the current route, call obtain the state using /// The current route state. To change the current route, call obtain the state
/// `RouteState.of(context)` and call `go()`: /// using `RouteStateScope.of(context)` and call `go()`:
/// ///
/// ``` /// ```
/// RouteState.of(context).go('/book/2'); /// RouteStateScope.of(context).go('/book/2');
/// ``` /// ```
class RouteState extends ChangeNotifier { class RouteState extends ChangeNotifier {
TemplateRouteParser parser; TemplateRouteParser parser;
@ -24,6 +24,9 @@ class RouteState extends ChangeNotifier {
ParsedRoute get route => _route; ParsedRoute get route => _route;
set route(ParsedRoute route) { set route(ParsedRoute route) {
// Don't notify listeners if the path hasn't changed.
if (_route == route) return;
_route = route; _route = route;
notifyListeners(); notifyListeners();
} }

@ -21,7 +21,7 @@ class BookDetailsScreen extends StatelessWidget {
if (book == null) { if (book == null) {
return const Scaffold( return const Scaffold(
body: Center( body: Center(
child: Text('No book with found.'), child: Text('No book found.'),
), ),
); );
} }

@ -32,6 +32,20 @@ class _BooksScreenState extends State<BooksScreen>
..addListener(_handleTabIndexChanged); ..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 @override
void dispose() { void dispose() {
_tabController.removeListener(_handleTabIndexChanged); _tabController.removeListener(_handleTabIndexChanged);
@ -114,17 +128,4 @@ class _BooksScreenState extends State<BooksScreen>
break; 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);
}
} }

@ -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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -15,10 +19,8 @@ import 'scaffold.dart';
/// on the [routeState] that was parsed by the TemplateRouteParser. /// on the [routeState] that was parsed by the TemplateRouteParser.
class BookstoreNavigator extends StatefulWidget { class BookstoreNavigator extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey; final GlobalKey<NavigatorState> navigatorKey;
final BookstoreAuth auth;
const BookstoreNavigator({ const BookstoreNavigator({
required this.auth,
required this.navigatorKey, required this.navigatorKey,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -28,6 +30,7 @@ class BookstoreNavigator extends StatefulWidget {
} }
class _BookstoreNavigatorState extends State<BookstoreNavigator> { class _BookstoreNavigatorState extends State<BookstoreNavigator> {
final signInKey = const ValueKey('Sign in');
final scaffoldKey = const ValueKey<String>('App scaffold'); final scaffoldKey = const ValueKey<String>('App scaffold');
final bookDetailsKey = const ValueKey<String>('Book details screen'); final bookDetailsKey = const ValueKey<String>('Book details screen');
final authorDetailsKey = const ValueKey<String>('Author details screen'); final authorDetailsKey = const ValueKey<String>('Author details screen');
@ -35,18 +38,19 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final routeState = RouteStateScope.of(context)!; final routeState = RouteStateScope.of(context)!;
final authState = BookstoreAuthScope.of(context)!;
final pathTemplate = routeState.route.pathTemplate; final pathTemplate = routeState.route.pathTemplate;
final library = LibraryScope.of(context); final library = LibraryScope.of(context);
Book? book; Book? selectedBook;
if (pathTemplate == '/book/:bookId') { if (pathTemplate == '/book/:bookId') {
book = library.allBooks.firstWhereOrNull( selectedBook = library.allBooks.firstWhereOrNull(
(b) => b.id.toString() == routeState.route.parameters['bookId']); (b) => b.id.toString() == routeState.route.parameters['bookId']);
} }
Author? author; Author? selectedAuthor;
if (pathTemplate == '/author/:authorId') { if (pathTemplate == '/author/:authorId') {
author = library.allAuthors.firstWhereOrNull( selectedAuthor = library.allAuthors.firstWhereOrNull(
(b) => b.id.toString() == routeState.route.parameters['authorId']); (b) => b.id.toString() == routeState.route.parameters['authorId']);
} }
@ -71,11 +75,11 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
if (routeState.route.pathTemplate == '/signin') if (routeState.route.pathTemplate == '/signin')
// Display the sign in screen. // Display the sign in screen.
FadeTransitionPage<void>( FadeTransitionPage<void>(
key: const ValueKey('Sign in'), key: signInKey,
child: SignInScreen( child: SignInScreen(
onSignIn: (credentials) async { onSignIn: (credentials) async {
var signedIn = await widget.auth var signedIn = await authState.signIn(
.signIn(credentials.username, credentials.password); credentials.username, credentials.password);
if (signedIn) { if (signedIn) {
routeState.go('/books/popular'); routeState.go('/books/popular');
} }
@ -90,18 +94,18 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
), ),
// Add an additional page to the stack if the user is viewing a book // Add an additional page to the stack if the user is viewing a book
// or an author // or an author
if (book != null) if (selectedBook != null)
MaterialPage<void>( MaterialPage<void>(
key: bookDetailsKey, key: bookDetailsKey,
child: BookDetailsScreen( child: BookDetailsScreen(
book: book, book: selectedBook,
), ),
) )
else if (author != null) else if (selectedAuthor != null)
MaterialPage<void>( MaterialPage<void>(
key: authorDetailsKey, key: authorDetailsKey,
child: AuthorDetailsScreen( child: AuthorDetailsScreen(
author: author, author: selectedAuthor,
), ),
), ),
], ],

@ -2,6 +2,7 @@
// for details. All rights reserved. Use of this source code is governed by a // 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. // BSD-style license that can be found in the LICENSE file.
import 'package:bookstore/src/routing.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/link.dart'; import 'package:url_launcher/link.dart';
@ -62,18 +63,15 @@ class SettingsContent extends StatelessWidget {
uri: Uri.parse('/book/0'), uri: Uri.parse('/book/0'),
builder: (context, followLink) { builder: (context, followLink) {
return TextButton( return TextButton(
child: const Text('Go directly to /book/0'), child: const Text('Go directly to /book/0 (Link)'),
onPressed: followLink, onPressed: followLink,
); );
}, },
), ),
Link( TextButton(
uri: Uri.parse('/author/0'), child: const Text('Go directly to /book/0 (RouteState)'),
builder: (context, followLink) { onPressed: () {
return TextButton( RouteStateScope.of(context)!.go('/book/0');
child: const Text('Go directly to /author/0'),
onPressed: followLink,
);
}, },
), ),
].map((w) => Padding(padding: const EdgeInsets.all(8), child: w)), ].map((w) => Padding(padding: const EdgeInsets.all(8), child: w)),

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
class Credentials { class Credentials {
final String username; final String username;
final String password; final String password;
Credentials(this.username, this.password); Credentials(this.username, this.password);
} }
@ -24,8 +25,8 @@ class SignInScreen extends StatefulWidget {
} }
class _SignInScreenState extends State<SignInScreen> { class _SignInScreenState extends State<SignInScreen> {
String username = ''; final _usernameController = TextEditingController();
String password = ''; final _passwordController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -42,26 +43,20 @@ class _SignInScreenState extends State<SignInScreen> {
Text('Sign in', style: Theme.of(context).textTheme.headline4), Text('Sign in', style: Theme.of(context).textTheme.headline4),
TextField( TextField(
decoration: const InputDecoration(labelText: 'Username'), decoration: const InputDecoration(labelText: 'Username'),
onChanged: (v) { controller: _usernameController,
setState(() {
username = v;
});
},
), ),
TextField( TextField(
decoration: const InputDecoration(labelText: 'Password'), decoration: const InputDecoration(labelText: 'Password'),
obscureText: true, obscureText: true,
onChanged: (v) { controller: _passwordController,
setState(() {
password = v;
});
},
), ),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: TextButton( child: TextButton(
onPressed: () async { onPressed: () async {
widget.onSignIn(Credentials(username, password)); widget.onSignIn(Credentials(
_usernameController.value.text,
_passwordController.value.text));
}, },
child: const Text('Sign in'), child: const Text('Sign in'),
), ),

@ -6,9 +6,13 @@ import 'package:flutter/material.dart';
class FadeTransitionPage<T> extends Page<T> { class FadeTransitionPage<T> extends Page<T> {
final Widget child; final Widget child;
final Duration duration;
const FadeTransitionPage({LocalKey? key, required this.child}) const FadeTransitionPage({
: super(key: key); LocalKey? key,
required this.child,
this.duration = const Duration(milliseconds: 300),
}) : super(key: key);
@override @override
Route<T> createRoute(BuildContext context) { Route<T> createRoute(BuildContext context) {
@ -17,10 +21,9 @@ class FadeTransitionPage<T> extends Page<T> {
} }
class PageBasedFadeTransitionRoute<T> extends PageRoute<T> { class PageBasedFadeTransitionRoute<T> extends PageRoute<T> {
PageBasedFadeTransitionRoute(Page page) final FadeTransitionPage<T> page;
: super(
settings: page, PageBasedFadeTransitionRoute(this.page) : super(settings: page);
);
@override @override
Color? get barrierColor => null; Color? get barrierColor => null;
@ -29,7 +32,7 @@ class PageBasedFadeTransitionRoute<T> extends PageRoute<T> {
String? get barrierLabel => null; String? get barrierLabel => null;
@override @override
Duration get transitionDuration => const Duration(milliseconds: 300); Duration get transitionDuration => page.duration;
@override @override
bool get maintainState => true; bool get maintainState => true;

@ -9,10 +9,26 @@ void main() {
group('Library', () { group('Library', () {
test('addBook', () { test('addBook', () {
final library = Library(); final library = Library();
library.addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true); library.addBook(
library.addBook('Too Like the Lightning', 'Ada Palmer', false, true); title: 'Left Hand of Darkness',
library.addBook('Kindred', 'Octavia E. Butler', true, false); authorName: 'Ursula K. Le Guin',
library.addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false); 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.length, 3);
expect(library.allAuthors.first.books.length, 2); expect(library.allAuthors.first.books.length, 2);
expect(library.allBooks.length, 4); expect(library.allBooks.length, 4);

Loading…
Cancel
Save