The place control
The place control sits on the open shelf. The page does not re-check capacity.
The server use case owns the rule. The Flutter use case names the intent and calls the port.
lib/application/shelf/place_book_on_shelf_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 the server workflow. Capacity is decided on the server.
class PlaceBookOnShelfUseCase {
const PlaceBookOnShelfUseCase(this._books);
final BookRepository _books;
Future<Book> execute({required String bookId, required String shelfId}) {
return _books.placeOnShelf(bookId: bookId, shelfId: shelfId);
}
}It takes BookRepository only. It does not take ShelfRepository. If it counted books on the client, the capacity rule would exist twice, and a full shelf could still be accepted by a stale list.
Add placeOnShelf to BookRepository now. The shelf endpoint exists, so client.shelf.place compiles.
/// Calls the server use case that coordinates books and shelves.
Future<Book> placeOnShelf({required String bookId, required String shelfId});Add the same method to test/fakes/fake_book_repository.dart, and import package:shelf_flutter/domain/shared/not_found.dart. A missing id is NotFound. The widget test places a book that is already in the map.
import 'package:shelf_flutter/domain/shared/not_found.dart'; @override
Future<Book> placeOnShelf({
required String bookId,
required String shelfId,
}) async {
final book = books[bookId];
if (book == null) {
throw const NotFound('Book not found');
}
final placed = book.placeOnShelf(shelfId);
books[bookId] = placed;
return placed;
}ServerpodBookRepository.placeOnShelf calls client.shelf.place and maps ApiConflictException to Conflict through throwDomain. UuidValue comes from package:shelf_client.
@override
Future<Book> placeOnShelf({
required String bookId,
required String shelfId,
}) async {
try {
final dto = await _client.shelf.place(
PlaceBookInput(
bookId: UuidValue.fromString(bookId),
shelfId: UuidValue.fromString(shelfId),
),
);
return _mappers.toBook(dto);
} catch (error) {
_mappers.throwDomain(error);
}
}Add this provider. It watches bookRepositoryProvider.
final placeBookOnShelfUseCaseProvider = Provider<PlaceBookOnShelfUseCase>((
ref,
) {
return PlaceBookOnShelfUseCase(ref.watch(bookRepositoryProvider));
});Add place to ShelvesNotifier. It catches ValidationFailure, NotFound, and Conflict and returns the message. It does not invent a local placeOnShelf.
Future<String?> place({
required String bookId,
required String shelfId,
}) async {
try {
await ref
.read(placeBookOnShelfUseCaseProvider)
.execute(bookId: bookId, shelfId: shelfId);
return null;
} on ValidationFailure catch (error) {
return error.message;
} on NotFound catch (error) {
return error.message;
} on Conflict catch (error) {
return error.message;
}
}Replace lib/presentation/features/shelves/shelf_page.dart. The shelf is already chosen, so the control is one dropdown of books that are not on a shelf. Keys: place-book and place-book-on-shelf. A full shelf says Shelf is full and hides the control. On success the page invalidates booksProvider.
import 'package:beamer/beamer.dart';
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/presentation/router/route_paths.dart';
import 'package:shelf_flutter/ui/book_tile.dart';
import 'package:shelf_flutter/ui/shelf_frame.dart';
class ShelfPage extends ConsumerStatefulWidget {
const ShelfPage({super.key, required this.shelfId});
final String shelfId;
@override
ConsumerState<ShelfPage> createState() => _ShelfPageState();
}
class _ShelfPageState extends ConsumerState<ShelfPage> {
String? _bookId;
String? _error;
@override
Widget build(BuildContext context) {
final shelves = ref.watch(shelvesProvider);
final books = ref.watch(booksProvider);
return ShelfFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ShelfNav(section: ShelfSection.shelves),
const SizedBox(height: 20),
Expanded(child: _body(shelves, books)),
],
),
);
}
Widget _body(AsyncValue<List<Shelf>> shelves, AsyncValue<List<Book>> books) {
final failed =
(shelves.hasError && !shelves.hasValue) ||
(books.hasError && !books.hasValue);
if (failed) {
return const ShelfFailure(message: 'Could not load this shelf.');
}
if (!shelves.hasValue || !books.hasValue) return const ShelfLoading();
final shelf = _findShelf(shelves.requireValue, widget.shelfId);
if (shelf == null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_BackHeader(title: 'Shelf'),
const Expanded(
child: ShelfEmpty(
message: 'Shelf not found',
hint: 'Go back to the list of shelves.',
),
),
],
);
}
final onShelf = [
for (final book in books.requireValue)
if (book.shelfId == shelf.id) book,
];
final full = onShelf.length >= shelf.capacity;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_BackHeader(
title: shelf.name,
detail: '${onShelf.length} of ${shelf.capacity} books',
),
const SizedBox(height: 16),
if (full)
Text('Shelf is full', style: theme.textTheme.bodyMedium)
else
_PlaceBook(
books: books.requireValue,
bookId: _bookId,
error: _error,
onBook: (id) => setState(() => _bookId = id),
onPlace: () async {
final bookId = _bookId;
if (bookId == null) return;
final message = await ref
.read(shelvesProvider.notifier)
.place(bookId: bookId, shelfId: shelf.id);
if (!mounted) return;
setState(() {
_error = message;
if (message == null) _bookId = null;
});
if (message == null) ref.invalidate(booksProvider);
},
),
const SizedBox(height: 16),
Expanded(
child: onShelf.isEmpty
? const ShelfEmpty(
message: 'Nothing on this shelf yet',
hint: 'Place a book that is not on a shelf.',
)
: ListView(
children: [
for (final book in onShelf)
BookTile(
title: book.title,
authorName: book.authorName,
status: book.status.name,
),
],
),
),
],
);
}
}
class _BackHeader extends StatelessWidget {
const _BackHeader({required this.title, this.detail});
final String title;
final String? detail;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
IconButton(
key: const Key('shelf-back'),
tooltip: 'Shelves',
onPressed: () => context.beamToNamed(RoutePaths.shelves),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleLarge),
if (detail != null)
Text(detail!, style: theme.textTheme.bodySmall),
],
),
),
],
);
}
}
class _PlaceBook extends StatelessWidget {
const _PlaceBook({
required this.books,
required this.bookId,
required this.error,
required this.onBook,
required this.onPlace,
});
final List<Book> books;
final String? bookId;
final String? error;
final ValueChanged<String?> onBook;
final VoidCallback onPlace;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final available = [
for (final book in books)
if (book.shelfId == null) book,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Place a book', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outline),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
key: const Key('place-book'),
isExpanded: true,
hint: const Text('Book'),
value: bookId,
items: [
for (final book in available)
DropdownMenuItem(
value: book.id,
child: Text(book.title),
),
],
onChanged: onBook,
),
),
),
),
),
const SizedBox(width: 8),
FilledButton(
key: const Key('place-book-on-shelf'),
onPressed: bookId == null ? null : onPlace,
child: const Text('Place'),
),
],
),
if (error != null) ...[
const SizedBox(height: 8),
Text(error!, style: TextStyle(color: theme.colorScheme.error)),
],
],
);
}
}
Shelf? _findShelf(List<Shelf> shelves, String id) {
for (final shelf in shelves) {
if (shelf.id == id) return shelf;
}
return null;
}test/widget/place_book_test.dart opens that shelf, selects the book, taps Place, and expects books.books['book-1']!.shelfId to be 'shelf-1'.
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/domain/shelf/shelf.dart';
import 'package:shelf_flutter/presentation/features/shelves/shelf_page.dart';
import '../fakes/fake_book_repository.dart';
import '../fakes/fake_shelf_repository.dart';
void main() {
testWidgets('place stores the book on this shelf', (tester) async {
final books = FakeBookRepository();
final shelves = FakeShelfRepository();
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,
),
);
await shelves.save(
Shelf.create(
id: 'shelf-1',
name: 'Fiction',
capacity: 2,
createdAt: created,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
bookRepositoryProvider.overrideWithValue(books),
shelfRepositoryProvider.overrideWithValue(shelves),
],
child: const MaterialApp(home: ShelfPage(shelfId: 'shelf-1')),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('place-book')));
await tester.pumpAndSettle();
await tester.tap(find.text('The Dispossessed').last);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('place-book-on-shelf')));
await tester.pumpAndSettle();
expect(books.books['book-1']!.shelfId, 'shelf-1');
});
}Add this test to test/widget/shelf_page_test.dart. A shelf of capacity 1 that already holds a book hides the dropdown.
testWidgets('a full shelf hides the place control', (tester) async {
final books = FakeBookRepository();
final shelves = FakeShelfRepository();
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,
).placeOnShelf('shelf-1'),
);
await shelves.save(
Shelf.create(
id: 'shelf-1',
name: 'Fiction',
capacity: 1,
createdAt: created,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
bookRepositoryProvider.overrideWithValue(books),
shelfRepositoryProvider.overrideWithValue(shelves),
],
child: const MaterialApp(home: ShelfPage(shelfId: 'shelf-1')),
),
);
await tester.pumpAndSettle();
expect(find.text('Shelf is full'), findsOneWidget);
expect(find.byKey(const Key('place-book')), findsNothing);
});flutter testRun the app against the server. Save two books and a shelf of capacity 1. Open the shelf. Place the first book. Place the second. The page shows Shelf is full.
Next: favourite a book.