Favourite on Flutter
Favourites lists marked books. The heart calls one use case and reloads the list.
The client domain is written again. Add the same flag the server domain has, then call the endpoint you just generated.
Domain
On the Flutter Book, add this.favorite = false and final bool favorite. placeOnShelf copies favorite: favorite. Add setFavorite with the same copy the server uses. Book.create still leaves the flag false.
dart_mappable reads the constructor. From shelf_flutter:
dart run build_runner buildAdd expect(book.favorite, isFalse) and expect(book.setFavorite(true).favorite, isTrue) to the existing create test.
The port
Add this to BookRepository:
Future<Book> setFavorite({required String bookId, required bool favorite});ProtocolMappers.toBook sets favorite: dto.favorite.
ServerpodBookRepository.setFavorite calls client.book.setFavorite with SetBookFavoriteInput and maps failures through throwDomain, the same way placeOnShelf does.
@override
Future<Book> setFavorite({
required String bookId,
required bool favorite,
}) async {
try {
final dto = await _client.book.setFavorite(
SetBookFavoriteInput(
bookId: UuidValue.fromString(bookId),
favorite: favorite,
),
);
return _mappers.toBook(dto);
} catch (error) {
_mappers.throwDomain(error);
}
}lib/application/book/set_book_favorite_use_case.dart:
import 'package:shelf_flutter/domain/book/book.dart';
import 'package:shelf_flutter/domain/book/book_repository.dart';
/// The screen's name for marking a book as a favourite.
class SetBookFavoriteUseCase {
const SetBookFavoriteUseCase(this._books);
final BookRepository _books;
Future<Book> execute({required String bookId, required bool favorite}) {
return _books.setFavorite(bookId: bookId, favorite: favorite);
}
}In app/providers.dart:
final setBookFavoriteUseCaseProvider = Provider<SetBookFavoriteUseCase>((ref) {
return SetBookFavoriteUseCase(ref.watch(bookRepositoryProvider));
});BooksNotifier.setFavorite calls that use case, replaces state with a fresh list, and returns NotFound.message. It does not catch ApiNotFoundException.
/// Returns a message when the book does not exist. Otherwise null.
Future<String?> setFavorite({
required String bookId,
required bool favorite,
}) async {
try {
await ref
.read(setBookFavoriteUseCaseProvider)
.execute(bookId: bookId, favorite: favorite);
state = AsyncData(await ref.read(listBooksUseCaseProvider).execute());
return null;
} on NotFound catch (error) {
return error.message;
}
}Add setFavorite to test/fakes/fake_book_repository.dart. The NotFound import is already there from placeOnShelf. A missing id throws NotFound. Otherwise the fake stores book.setFavorite(favorite).
@override
Future<Book> setFavorite({
required String bookId,
required bool favorite,
}) async {
final book = books[bookId];
if (book == null) {
throw const NotFound('Book not found');
}
final updated = book.setFavorite(favorite);
books[bookId] = updated;
return updated;
}The heart
The tile already draws the heart when onFavorite is set. On _BooksPageState, add String? _error;. At the start of build, take the theme. After ShelfSectionHeader, and before the 16-pixel gap, show the message. _list already receives shelves from the shelves chapter. Pass the flag and the callback. The key is favorite-$title, which the widget test taps.
final theme = Theme.of(context); if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],Replace the BookTile inside _list:
BookTile(
title: book.title,
authorName: book.authorName,
status: book.status.name,
shelfName: _shelfName(shelves, book.shelfId),
favorite: book.favorite,
onFavorite: () async {
final message = await ref
.read(booksProvider.notifier)
.setFavorite(bookId: book.id, favorite: !book.favorite);
if (!mounted) return;
setState(() => _error = message);
},
),On ShelfPage, the row does not repeat the shelf name. Pass favorite and the same callback. The page needs a String? _error field if the list-only page did not keep one. The place control already has _error. Use that field for the heart as well.
BookTile(
title: book.title,
authorName: book.authorName,
status: book.status.name,
favorite: book.favorite,
onFavorite: () async {
final message = await ref
.read(booksProvider.notifier)
.setFavorite(bookId: book.id, favorite: !book.favorite);
if (!mounted) return;
setState(() => _error = message);
},
),Add this to test/widget/books_page_test.dart. It opens Add book, saves a book through the dialog, taps the heart, and expects the fake to hold favorite: true and the filled icon on screen. Override shelfRepositoryProvider as well. The library watches shelves.
testWidgets('favourite toggles the heart', (tester) async {
final books = FakeBookRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
bookRepositoryProvider.overrideWithValue(books),
shelfRepositoryProvider.overrideWithValue(FakeShelfRepository()),
],
child: const MaterialApp(home: BooksPage()),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('add-book')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('book-title')),
'The Dispossessed',
);
await tester.enterText(find.byKey(const Key('book-author')), 'Le Guin');
await tester.tap(find.byKey(const Key('save-book')));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
await tester.tap(find.byKey(const Key('favorite-The Dispossessed')));
await tester.pumpAndSettle();
expect(books.books.values.single.favorite, isTrue);
expect(find.byIcon(Icons.favorite), findsOneWidget);
});Favourites
Add favourites to RoutePaths.
static const favourites = '/favourites';Replace lib/presentation/app/shelf_nav.dart. The third label is Favourites. A shelf page still highlights Shelves.
import 'package:beamer/beamer.dart';
import 'package:flutter/material.dart';
import 'package:shelf_flutter/presentation/router/route_paths.dart';
enum ShelfSection { books, shelves, favourites }
class ShelfNav extends StatelessWidget {
const ShelfNav({super.key, required this.section});
final ShelfSection section;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Text('Shelf', style: theme.textTheme.titleLarge),
const Spacer(),
_NavButton(
label: 'Library',
selected: section == ShelfSection.books,
onPressed: () => context.beamToNamed(RoutePaths.books),
),
_NavButton(
label: 'Shelves',
selected: section == ShelfSection.shelves,
onPressed: () => context.beamToNamed(RoutePaths.shelves),
),
_NavButton(
label: 'Favourites',
selected: section == ShelfSection.favourites,
onPressed: () => context.beamToNamed(RoutePaths.favourites),
),
],
);
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
required this.label,
required this.selected,
required this.onPressed,
});
final String label;
final bool selected;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onSurface,
backgroundColor: Colors.transparent,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: const RoundedRectangleBorder(),
textStyle: TextStyle(
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
letterSpacing: -0.2,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
const SizedBox(height: 4),
Container(
height: 2,
width: selected ? 18 : 0,
color: selected ? theme.colorScheme.onSurface : Colors.transparent,
),
],
),
);
}
}lib/presentation/features/favourites/favourites_page.dart is the same rows, filtered to favorite. Empty copy is No favourites yet.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shelf_flutter/domain/book/book.dart';
import 'package:shelf_flutter/domain/shelf/shelf.dart';
import 'package:shelf_flutter/presentation/app/shelf_nav.dart';
import 'package:shelf_flutter/presentation/features/books/books_notifier.dart';
import 'package:shelf_flutter/presentation/features/shelves/shelves_notifier.dart';
import 'package:shelf_flutter/ui/book_tile.dart';
import 'package:shelf_flutter/ui/shelf_frame.dart';
class FavouritesPage extends ConsumerStatefulWidget {
const FavouritesPage({super.key});
@override
ConsumerState<FavouritesPage> createState() => _FavouritesPageState();
}
class _FavouritesPageState extends ConsumerState<FavouritesPage> {
String? _error;
@override
Widget build(BuildContext context) {
final books = ref.watch(booksProvider);
final shelves = ref.watch(shelvesProvider).asData?.value ?? const <Shelf>[];
final theme = Theme.of(context);
final marked = books.asData?.value.where((book) => book.favorite).length;
return ShelfFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ShelfNav(section: ShelfSection.favourites),
const SizedBox(height: 28),
ShelfSectionHeader(
title: 'Favourites',
detail: marked == null
? null
: (marked == 1 ? '1 book' : '$marked books'),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 16),
Expanded(child: _list(books, shelves)),
],
),
);
}
Widget _list(AsyncValue<List<Book>> books, List<Shelf> shelves) {
if (books.hasError && !books.hasValue) {
return const ShelfFailure(message: 'Could not load favourites.');
}
if (!books.hasValue) return const ShelfLoading();
final items = [
for (final book in books.requireValue)
if (book.favorite) book,
];
if (items.isEmpty) {
return const ShelfEmpty(
message: 'No favourites yet',
hint: 'Mark a book from the library.',
);
}
return ListView(
children: [
for (final book in items)
BookTile(
title: book.title,
authorName: book.authorName,
status: book.status.name,
shelfName: _shelfName(shelves, book.shelfId),
favorite: book.favorite,
onFavorite: () async {
final message = await ref
.read(booksProvider.notifier)
.setFavorite(bookId: book.id, favorite: !book.favorite);
if (!mounted) return;
setState(() => _error = message);
},
),
],
);
}
}
String? _shelfName(List<Shelf> shelves, String? shelfId) {
if (shelfId == null) return null;
for (final shelf in shelves) {
if (shelf.id == shelfId) return shelf.name;
}
return null;
}lib/presentation/router/favourites_location.dart:
import 'package:beamer/beamer.dart';
import 'package:flutter/widgets.dart';
import 'package:shelf_flutter/presentation/features/favourites/favourites_page.dart';
import 'package:shelf_flutter/presentation/router/books_location.dart';
import 'package:shelf_flutter/presentation/router/route_paths.dart';
class FavouritesLocation extends BeamLocation<BeamState> {
@override
List<String> get pathPatterns => [RoutePaths.favourites];
@override
List<BeamPage> buildPages(BuildContext context, BeamState state) {
return [
const BeamPage(
key: ValueKey('favourites'),
title: 'Favourites',
child: ScreenReporter(
path: RoutePaths.favourites,
title: 'Favourites',
child: FavouritesPage(),
),
),
];
}
}In ShelfApp, add FavouritesLocation() after ShelvesLocation().
test/widget/favourites_page_test.dart shows the marked book and hides the other.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shelf_flutter/app/providers.dart';
import 'package:shelf_flutter/domain/book/book.dart';
import 'package:shelf_flutter/domain/book/reading_status.dart';
import 'package:shelf_flutter/presentation/features/favourites/favourites_page.dart';
import '../fakes/fake_book_repository.dart';
import '../fakes/fake_shelf_repository.dart';
void main() {
testWidgets('shows only books marked as favourites', (tester) async {
final books = FakeBookRepository();
final created = DateTime.utc(2026, 10, 3);
await books.save(
Book.create(
id: 'book-1',
title: 'The Dispossessed',
authorName: 'Le Guin',
status: ReadingStatus.unread,
createdAt: created,
).setFavorite(true),
);
await books.save(
Book.create(
id: 'book-2',
title: 'The Left Hand of Darkness',
authorName: 'Le Guin',
status: ReadingStatus.unread,
createdAt: created.add(const Duration(seconds: 1)),
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
bookRepositoryProvider.overrideWithValue(books),
shelfRepositoryProvider.overrideWithValue(FakeShelfRepository()),
],
child: const MaterialApp(home: FavouritesPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('The Dispossessed'), findsOneWidget);
expect(find.text('The Left Hand of Darkness'), findsNothing);
expect(find.byIcon(Icons.favorite), findsOneWidget);
});
}flutter test```bash
flutter testNext: server instrumentation.