Wave 9 (K-manual): add a book by hand, no ISBN required

Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 241 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/K-manual.txt. Worker: $5.051114400000003, 108 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
This commit is contained in:
Spriteandclaude committed 2026-09-15 11:58:37 +00:00
1 parent 9b74f50565
commit b600df69c3
27 files changed
+1363 -6

No files matched your search

@@ -0,0 +1,65 @@
package org.modg.bookshelf.ui.add
import org.modg.bookshelf.data.metadata.IsbnUtils
/**
* The add-by-hand form's fields, independent of any particular entry point.
* [org.modg.bookshelf.ui.nav.Routes.addBook] pre-fills [title]/[isbn] today —
* from the scan screen's manual-ISBN dialog, or the library FAB with neither.
* A follow-up wave (online search) is expected to pre-fill more fields from a
* search hit; [fromRouteArgs] is the one place the route-args-to-draft mapping
* lives, so extending it later is a small, localized change.
*/
data class BookDraft(
val title: String = "",
val subtitle: String = "",
val authors: String = "",
val publisher: String = "",
val publishedDate: String = "",
val pages: String = "",
val isbn: String = "",
val description: String = "",
) {
companion object {
fun fromRouteArgs(title: String?, isbn: String?): BookDraft =
BookDraft(title = title.orEmpty(), isbn = isbn.orEmpty())
}
}
/**
* [BookDraft.isbn] parsed per SPEC's "ISBN-10 or ISBN-13, with or without
* hyphens" (same rule [IsbnUtils.toIsbn13] already enforces for scanning).
* Blank is valid — no ISBN is a legal book. A non-blank value that fails to
* parse is [Invalid], never silently dropped: wave 5 fixed exactly that
* silent-discard bug in the scan screen's manual-ISBN dialog, and this form
* must not reintroduce it.
*/
sealed interface IsbnFieldState {
data object Blank : IsbnFieldState
data class Valid(val isbn13: String, val isbn10: String?) : IsbnFieldState
data object Invalid : IsbnFieldState
}
fun validateIsbn(raw: String): IsbnFieldState {
if (raw.isBlank()) return IsbnFieldState.Blank
val isbn13 = IsbnUtils.toIsbn13(raw) ?: return IsbnFieldState.Invalid
val normalized = IsbnUtils.normalize(raw)
// Keep the ISBN-10 alongside the normalized ISBN-13 only when the user actually
// typed a valid ISBN-10 — a 13-digit entry has no ISBN-10 form to record.
val isbn10 = normalized.takeIf { it.length == 10 && IsbnUtils.isValidIsbn10(it) }
return IsbnFieldState.Valid(isbn13, isbn10)
}
/** [BookDraft.pages] — SPEC "digits only". Blank means unknown, not zero. */
sealed interface PagesFieldState {
data object Blank : PagesFieldState
data class Valid(val pages: Int) : PagesFieldState
data object Invalid : PagesFieldState
}
fun validatePages(raw: String): PagesFieldState {
if (raw.isBlank()) return PagesFieldState.Blank
if (!raw.all(Char::isDigit)) return PagesFieldState.Invalid
val pages = raw.toIntOrNull() ?: return PagesFieldState.Invalid
return PagesFieldState.Valid(pages)
}
@@ -0,0 +1,319 @@
package org.modg.bookshelf.ui.add
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.scan.ShelfPicker
/**
* SPEC gap: there was no way to add a book with no barcode — every save path
* started from a scanned/typed ISBN. A full screen, not a sheet: the library
* has no camera context to anchor a bottom sheet to (unlike [org.modg.bookshelf.ui.scan.ManualEntrySheet]),
* and this form is longer than that sheet's two fields.
*/
@Composable
fun AddBookScreen(
initialTitle: String?,
initialIsbn: String?,
onBack: () -> Unit,
onSaved: (bookId: String) -> Unit,
container: AppContainer,
) {
val viewModel: AddBookViewModel = viewModel(
factory = viewModelFactory {
initializer {
AddBookViewModel(
bookRepository = container.bookRepository,
locationRepository = container.locationRepository,
settingsStore = container.settingsStore,
initialDraft = BookDraft.fromRouteArgs(initialTitle, initialIsbn),
)
}
},
)
val state by viewModel.uiState.collectAsState()
AddBookContent(
state = state,
callbacks = AddBookCallbacks(
onTitleChanged = viewModel::onTitleChanged,
onSubtitleChanged = viewModel::onSubtitleChanged,
onAuthorsChanged = viewModel::onAuthorsChanged,
onPublisherChanged = viewModel::onPublisherChanged,
onPublishedDateChanged = viewModel::onPublishedDateChanged,
onPagesChanged = viewModel::onPagesChanged,
onIsbnChanged = viewModel::onIsbnChanged,
onDescriptionChanged = viewModel::onDescriptionChanged,
onShelfSelected = viewModel::onShelfSelected,
onSave = { viewModel.save(onSaved) },
onSaveAndAddAnother = viewModel::saveAndAddAnother,
onBack = onBack,
),
)
}
/**
* Every callback [AddBookContent] needs, grouped into one object per the task's
* `AddBookContent(state, callbacks)` shape — so the Paparazzi test can render
* this REAL composable fed with static data, instead of a hand-rolled lookalike
* (wave 6 shipped exactly that for Locations; HANDOFF.md records it as a known
* soft spot this wave was told not to repeat).
*/
data class AddBookCallbacks(
val onTitleChanged: (String) -> Unit,
val onSubtitleChanged: (String) -> Unit,
val onAuthorsChanged: (String) -> Unit,
val onPublisherChanged: (String) -> Unit,
val onPublishedDateChanged: (String) -> Unit,
val onPagesChanged: (String) -> Unit,
val onIsbnChanged: (String) -> Unit,
val onDescriptionChanged: (String) -> Unit,
val onShelfSelected: (String?) -> Unit,
val onSave: () -> Unit,
val onSaveAndAddAnother: () -> Unit,
val onBack: () -> Unit,
)
@Composable
fun AddBookContent(state: AddBookUiState, callbacks: AddBookCallbacks, modifier: Modifier = Modifier) {
val focusManager = LocalFocusManager.current
val titleFocusRequester = remember { FocusRequester() }
// "Save & add another" clears the form and SPEC wants focus back in Title.
// savedCount only increments on a successful save (not on every keystroke), and
// the empty-title check keeps this from re-stealing focus if the user has
// already started typing the next book by the time recomposition catches up.
LaunchedEffect(state.savedCount) {
if (state.savedCount > 0 && state.draft.title.isEmpty()) {
runCatching { titleFocusRequester.requestFocus() }
}
}
BookshelfScaffold(
title = "Add a book",
navigationIcon = {
IconButton(onClick = callbacks.onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back")
}
},
modifier = modifier,
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxSize()) {
// Same fix as the setup screen (see its KDoc): imePadding OUTSIDE
// verticalScroll shrinks the scrollable viewport instead of letting the
// keyboard cover it, so a field low in this (longer) form isn't
// stranded behind the IME. BookshelfScaffold's own innerPadding only
// accounts for the top/bottom bars, not the IME, so this screen still
// needs to handle it itself.
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
if (state.lastSavedTitle != null) {
Text(
text = "Saved “${state.lastSavedTitle}”. ${state.savedCount} added this session.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp),
)
}
OutlinedTextField(
value = state.draft.title,
onValueChange = callbacks.onTitleChanged,
label = { Text("Title") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().focusRequester(titleFocusRequester),
)
OutlinedTextField(
value = state.draft.subtitle,
onValueChange = callbacks.onSubtitleChanged,
label = { Text("Subtitle") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = state.draft.authors,
onValueChange = callbacks.onAuthorsChanged,
label = { Text("Authors (comma-separated)") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = state.draft.publisher,
onValueChange = callbacks.onPublisherChanged,
label = { Text("Publisher") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = state.draft.publishedDate,
onValueChange = callbacks.onPublishedDateChanged,
label = { Text("Published") },
placeholder = { Text("1960, or 1999-04") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = state.draft.pages,
onValueChange = callbacks.onPagesChanged,
label = { Text("Pages") },
singleLine = true,
isError = state.pagesError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (state.pagesError != null) {
Text(
text = state.pagesError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
OutlinedTextField(
value = state.draft.isbn,
onValueChange = callbacks.onIsbnChanged,
label = { Text("ISBN") },
placeholder = { Text("Optional — ISBN-10 or ISBN-13") },
singleLine = true,
isError = state.isbnError != null,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (state.isbnError != null) {
Text(
text = state.isbnError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
val duplicate = state.duplicate
if (duplicate is DuplicateStatus.AlreadyOwned) {
// A warning, not a block — SPEC: two copies of a book is legal. Same
// copy the scan sheet uses, for consistency (see FoundBookSheet).
Text(
text = "You already own this book (\"${duplicate.title}\").",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
OutlinedTextField(
value = state.draft.description,
onValueChange = callbacks.onDescriptionChanged,
label = { Text("Description") },
minLines = 3,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
ShelfPicker(
bookcases = state.bookcases,
shelves = state.shelves,
selectedShelfId = state.selectedShelfId,
recentShelfId = state.recentShelfId,
onShelfSelected = callbacks.onShelfSelected,
)
if (state.saveError != null) {
Text(
text = "Couldn't save: ${state.saveError}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 12.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SecondaryButton(
text = "Save & add another",
enabled = state.canSave,
onClick = callbacks.onSaveAndAddAnother,
modifier = Modifier.weight(1f),
)
PrimaryButton(
text = if (state.isSaving) "Saving…" else "Save",
enabled = state.canSave,
onClick = callbacks.onSave,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@@ -0,0 +1,226 @@
package org.modg.bookshelf.ui.add
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.ui.scan.DuplicateCheck
import org.modg.bookshelf.ui.scan.DuplicateStatus
/** Everything [AddBookContent] needs to render the form. */
data class AddBookUiState(
val draft: BookDraft = BookDraft(),
val bookcases: List<BookcaseEntity> = emptyList(),
val shelves: List<ShelfEntity> = emptyList(),
val selectedShelfId: String? = null,
val recentShelfId: String? = null,
val duplicate: DuplicateStatus = DuplicateStatus.New,
val isSaving: Boolean = false,
val saveError: String? = null,
val savedCount: Int = 0,
val lastSavedTitle: String? = null,
) {
val isbnError: String?
get() = if (validateIsbn(draft.isbn) is IsbnFieldState.Invalid) {
"That isn't a valid ISBN — check the digits."
} else {
null
}
val pagesError: String?
get() = if (validatePages(draft.pages) is PagesFieldState.Invalid) "Pages must be a number." else null
/** Title is the only required field; a field in error or a save already in flight blocks both Save actions. */
val canSave: Boolean
get() = draft.title.isNotBlank() && isbnError == null && pagesError == null && !isSaving
}
/**
* Owns the add-by-hand form (SPEC gap: no way to add a book with no barcode).
* Offline-first like every other screen: [bookRepository]/[locationRepository]
* are Room only, nothing here touches the network.
*/
class AddBookViewModel(
private val bookRepository: BookRepository,
locationRepository: LocationRepository,
private val settingsStore: SettingsStore,
initialDraft: BookDraft = BookDraft(),
) : ViewModel() {
private data class FormState(
val draft: BookDraft,
val selectedShelfId: String? = null,
val duplicate: DuplicateStatus = DuplicateStatus.New,
val isSaving: Boolean = false,
val saveError: String? = null,
val savedCount: Int = 0,
val lastSavedTitle: String? = null,
)
private val _formState = MutableStateFlow(FormState(draft = initialDraft))
private var duplicateCheckJob: Job? = null
val bookcases: StateFlow<List<BookcaseEntity>> = locationRepository.observeBookcases()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Surfaced as the shelf picker's "Recent" shortcut, same as the scan screen's. */
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
// WhileSubscribed, matching Library/LocationsViewModel's uiState — but note this
// means .value only reflects post-action state to a caller that is actively
// collecting (e.g. Compose's collectAsState, or a test using .first()); a bare
// .value read with no live collector sees only the seed passed to stateIn.
val uiState: StateFlow<AddBookUiState> = combine(
_formState, bookcases, shelves, recentShelfId,
) { form, cases, shelfList, recent ->
AddBookUiState(
draft = form.draft,
bookcases = cases,
shelves = shelfList,
selectedShelfId = form.selectedShelfId,
recentShelfId = recent,
duplicate = form.duplicate,
isSaving = form.isSaving,
saveError = form.saveError,
savedCount = form.savedCount,
lastSavedTitle = form.lastSavedTitle,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AddBookUiState(draft = initialDraft))
init {
// SPEC: pre-select the remembered last shelf. The scan sheet only offers this
// as a "Recent" shortcut because it also has a per-scan shelf that sticks
// across a continuous session; a hand-entry form has no such session anchor,
// so here the remembered shelf IS the default.
viewModelScope.launch {
val remembered = settingsStore.lastShelfId.first()
if (remembered != null) _formState.update { it.copy(selectedShelfId = remembered) }
}
if (initialDraft.isbn.isNotBlank()) scheduleDuplicateCheck(initialDraft.isbn)
}
fun onTitleChanged(value: String) = updateDraft { it.copy(title = value) }
fun onSubtitleChanged(value: String) = updateDraft { it.copy(subtitle = value) }
fun onAuthorsChanged(value: String) = updateDraft { it.copy(authors = value) }
fun onPublisherChanged(value: String) = updateDraft { it.copy(publisher = value) }
fun onPublishedDateChanged(value: String) = updateDraft { it.copy(publishedDate = value) }
fun onPagesChanged(value: String) = updateDraft { it.copy(pages = value) }
fun onDescriptionChanged(value: String) = updateDraft { it.copy(description = value) }
fun onIsbnChanged(value: String) {
updateDraft { it.copy(isbn = value) }
scheduleDuplicateCheck(value)
}
fun onShelfSelected(shelfId: String?) {
_formState.update { it.copy(selectedShelfId = shelfId) }
}
private inline fun updateDraft(transform: (BookDraft) -> BookDraft) {
_formState.update { it.copy(draft = transform(it.draft)) }
}
/** SPEC scan-sheet parity: a warning, not a block — two copies of a book is legal. */
private fun scheduleDuplicateCheck(rawIsbn: String) {
duplicateCheckJob?.cancel()
val isbn13 = (validateIsbn(rawIsbn) as? IsbnFieldState.Valid)?.isbn13
if (isbn13 == null) {
_formState.update { it.copy(duplicate = DuplicateStatus.New) }
return
}
duplicateCheckJob = viewModelScope.launch {
val existing = bookRepository.findByIsbn13(isbn13)
_formState.update { it.copy(duplicate = DuplicateCheck.check(existing)) }
}
}
/** "Save" — navigates to the new book's detail screen on success (see [AddBookScreen]). */
fun save(onSaved: (bookId: String) -> Unit) {
viewModelScope.launch {
val id = performSave(clearAfterSave = false)
if (id != null) onSaved(id)
}
}
/** "Save & add another" — SPEC's real use case, shelving a box of books at once. */
fun saveAndAddAnother() {
viewModelScope.launch { performSave(clearAfterSave = true) }
}
/**
* The actual save, split out so tests can await it directly — same reasoning
* as [org.modg.bookshelf.ui.scan.ScanViewModel.performSave]. HANDOFF.md flagged
* that ViewModel's save paths as still making unguarded Room calls after wave 8
* guarded only the lookup path; this one is guarded from the start. Catches
* [Throwable] — [CancellationException] is rethrown FIRST, ahead of that catch,
* because a cancelled save (screen left mid-save) is normal control flow, not a
* failure, and must propagate rather than being reported as one. Re-entrancy is
* guarded here rather than trusted to the caller's disabled-button state, so a
* genuine double-tap (two taps landing before recomposition disables the
* button) still can't create two books.
*/
internal suspend fun performSave(clearAfterSave: Boolean): String? {
if (_formState.value.isSaving) return null
_formState.update { it.copy(isSaving = true, saveError = null) }
val form = _formState.value
val draft = form.draft
return try {
val isbnValidation = validateIsbn(draft.isbn)
val isbn13 = (isbnValidation as? IsbnFieldState.Valid)?.isbn13
val isbn10 = (isbnValidation as? IsbnFieldState.Valid)?.isbn10
val pageCount = (validatePages(draft.pages) as? PagesFieldState.Valid)?.pages
val authors = draft.authors.split(",").map(String::trim).filter(String::isNotEmpty)
val savedTitle = draft.title.trim()
val bookId = bookRepository.createBook(
title = savedTitle,
subtitle = draft.subtitle.trim().ifEmpty { null },
authors = authors,
isbn13 = isbn13,
isbn10 = isbn10,
publisher = draft.publisher.trim().ifEmpty { null },
publishedDate = draft.publishedDate.trim().ifEmpty { null },
pageCount = pageCount,
description = draft.description.trim().ifEmpty { null },
shelfId = form.selectedShelfId,
)
rememberShelf(form.selectedShelfId)
_formState.update {
it.copy(
draft = if (clearAfterSave) BookDraft() else it.draft,
isSaving = false,
saveError = null,
savedCount = it.savedCount + 1,
lastSavedTitle = savedTitle,
)
}
if (clearAfterSave) scheduleDuplicateCheck("") // the ISBN field just cleared; the warning must too
bookId
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_formState.update { it.copy(isSaving = false, saveError = e.javaClass.simpleName) }
null
}
}
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */
private suspend fun rememberShelf(shelfId: String?) {
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
}
}
@@ -11,8 +11,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Search
@@ -26,6 +29,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
@@ -54,6 +58,7 @@ import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
@@ -68,6 +73,7 @@ fun LibraryScreen(
shelfIdFilter: String?,
onBookClick: (String) -> Unit,
onScanClick: () -> Unit,
onAddByHandClick: () -> Unit,
onLocationsClick: () -> Unit,
onSettingsClick: () -> Unit,
container: AppContainer,
@@ -105,8 +111,18 @@ fun LibraryScreen(
}
},
floatingActionButton = {
FloatingActionButton(onClick = onScanClick) {
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book")
// Scan stays the one primary action (it's the SPEC use case — a box of
// books, one after another); "Add by hand" is a small satellite FAB
// stacked above it rather than a second full-size FAB competing for
// attention, or a toolbar icon that would bury it below Locations/Settings.
Column(horizontalAlignment = Alignment.End) {
SmallFloatingActionButton(onClick = onAddByHandClick) {
Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand")
}
Spacer(modifier = Modifier.height(12.dp))
FloatingActionButton(onClick = onScanClick) {
Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book")
}
}
},
syncStatusBar = {
@@ -137,7 +153,12 @@ fun LibraryScreen(
!uiState.hasAnyBooks -> EmptyState(
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = onScanClick) },
action = {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PrimaryButton(text = "Scan a book", onClick = onScanClick)
SecondaryButton(text = "Add by hand", onClick = onAddByHandClick)
}
},
)
uiState.books.isEmpty() -> EmptyState(
title = "No books match",
@@ -8,6 +8,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.modg.bookshelf.AppContainer
import org.modg.bookshelf.ui.add.AddBookScreen
import org.modg.bookshelf.ui.detail.DetailScreen
import org.modg.bookshelf.ui.library.LibraryScreen
import org.modg.bookshelf.ui.locations.LocationsScreen
@@ -52,6 +53,7 @@ fun BookshelfNavHost(
shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG),
onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) },
onScanClick = { navController.navigate(Routes.SCAN) },
onAddByHandClick = { navController.navigate(Routes.addBook()) },
onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
container = container,
@@ -70,6 +72,37 @@ fun BookshelfNavHost(
composable(Routes.SCAN) {
ScanScreen(
onBack = { navController.popBackStack() },
onEnterByHand = { navController.navigate(Routes.addBook()) },
container = container,
)
}
composable(
route = Routes.ADD_BOOK,
arguments = listOf(
navArgument(Routes.ADD_BOOK_TITLE_ARG) {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument(Routes.ADD_BOOK_ISBN_ARG) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { backStackEntry ->
AddBookScreen(
initialTitle = backStackEntry.arguments?.getString(Routes.ADD_BOOK_TITLE_ARG),
initialIsbn = backStackEntry.arguments?.getString(Routes.ADD_BOOK_ISBN_ARG),
onBack = { navController.popBackStack() },
onSaved = { bookId ->
// Removes the add screen from the back stack, per the task's "Save"
// contract, so Back from detail returns to the library rather than
// to a form that was just successfully submitted.
navController.navigate(Routes.detail(bookId)) {
popUpTo(Routes.ADD_BOOK) { inclusive = true }
}
},
container = container,
)
}
@@ -1,5 +1,7 @@
package org.modg.bookshelf.ui.nav
import android.net.Uri
/**
* Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen
* contract shared with Worker E2 — do not rename or restructure these; E2's
@@ -16,6 +18,37 @@ object Routes {
const val LOCATIONS = "locations"
const val SETTINGS = "settings"
const val ADD_BOOK_TITLE_ARG = "title"
const val ADD_BOOK_ISBN_ARG = "isbn"
private const val ADD_BOOK_BASE = "add"
/**
* Both args are optional (wave 9: "add a book by hand" with nothing pre-filled)
* — nullable/defaultValue-null args, same pattern [LIBRARY_WITH_SHELF] uses so
* this one route also matches the bare "add" entry with neither supplied.
*/
const val ADD_BOOK = "$ADD_BOOK_BASE?$ADD_BOOK_TITLE_ARG={$ADD_BOOK_TITLE_ARG}&$ADD_BOOK_ISBN_ARG={$ADD_BOOK_ISBN_ARG}"
fun libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId"
fun detail(bookId: String) = "detail/$bookId"
/**
* Builds the add-by-hand route, percent-encoding [title]/[isbn] so values
* containing `&`, `?`, `/`, `#` etc. (titles routinely do) survive as a single
* query value instead of corrupting the route string. [Uri.encode] rather than
* [java.net.URLEncoder] because the latter encodes space as `+`, which
* Navigation Compose's Uri-pattern argument matching does NOT decode back to
* space (it does standard percent-decoding, not form-decoding) — see
* RoutesTest for the round-trip this must survive. A null arg is omitted
* entirely rather than encoded as the literal string "null", so a caller with
* nothing to pre-fill (e.g. the plain library FAB) gets exactly the bare
* "add" route wave 9's [org.modg.bookshelf.ui.add.AddBookScreen] expects.
*/
fun addBook(title: String? = null, isbn: String? = null): String {
val query = buildList {
title?.let { add("$ADD_BOOK_TITLE_ARG=${Uri.encode(it)}") }
isbn?.let { add("$ADD_BOOK_ISBN_ARG=${Uri.encode(it)}") }
}
return if (query.isEmpty()) ADD_BOOK_BASE else "$ADD_BOOK_BASE?" + query.joinToString("&")
}
}
@@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -80,6 +81,7 @@ import org.modg.bookshelf.ui.components.ShelfPickerSheet
@Composable
fun ScanScreen(
onBack: () -> Unit,
onEnterByHand: () -> Unit,
container: AppContainer,
) {
val viewModel: ScanViewModel = viewModel(
@@ -205,6 +207,7 @@ fun ScanScreen(
ManualIsbnDialog(
onDismiss = { showManualEntry = false },
onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) },
onEnterByHand = { showManualEntry = false; onEnterByHand() },
)
}
}
@@ -523,7 +526,7 @@ internal fun ShelfPicker(
}
@Composable
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) {
private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit, onEnterByHand: () -> Unit) {
var text by remember { mutableStateOf("") }
// Validate here rather than letting ScanViewModel.manualIsbnEntered drop an
// unparseable ISBN on the floor. A typed-in check digit is easy to get wrong,
@@ -561,6 +564,12 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
modifier = Modifier.padding(top = 4.dp),
)
}
// Escape hatch for the books this dialog can't help with at all — SPEC
// gap: older/self-published/foreign books often have no ISBN, and this
// dialog only ever accepts one.
TextButton(onClick = onEnterByHand, modifier = Modifier.padding(top = 8.dp)) {
Text("No ISBN? Enter the details by hand")
}
}
},
confirmButton = {
@@ -0,0 +1,126 @@
package org.modg.bookshelf.ui.add
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure validation logic for the add-by-hand form — no ViewModel/Room needed.
* See [AddBookViewModelTest] for how [AddBookUiState.canSave] and the save
* paths build on these.
*/
class AddBookModelsTest {
// --- BookDraft.fromRouteArgs ---
@Test
fun `fromRouteArgs fills title and isbn when both present`() {
val draft = BookDraft.fromRouteArgs(title = "Dune", isbn = "9780441013593")
assertEquals("Dune", draft.title)
assertEquals("9780441013593", draft.isbn)
}
@Test
fun `fromRouteArgs defaults absent args to blank, not the literal string null`() {
val draft = BookDraft.fromRouteArgs(title = null, isbn = null)
assertEquals("", draft.title)
assertEquals("", draft.isbn)
}
// --- validateIsbn ---
@Test
fun `blank isbn is valid — no ISBN is a legal book`() {
assertEquals(IsbnFieldState.Blank, validateIsbn(""))
assertEquals(IsbnFieldState.Blank, validateIsbn(" "))
}
@Test
fun `valid isbn13 is accepted and carries no isbn10`() {
val result = validateIsbn("9780441013593")
val valid = result as? IsbnFieldState.Valid
checkNotNull(valid) { "expected Valid, got $result" }
assertEquals("9780441013593", valid.isbn13)
assertNull(valid.isbn10)
}
@Test
fun `valid isbn10 is normalized to isbn13 and the isbn10 is kept`() {
val result = validateIsbn("0-441-01359-7") // Dune's ISBN-10, hyphenated
val valid = result as? IsbnFieldState.Valid
checkNotNull(valid) { "expected Valid, got $result" }
assertEquals("9780441013593", valid.isbn13)
assertEquals("0441013597", valid.isbn10)
}
@Test
fun `a bad checksum is Invalid, not silently dropped`() {
// wave 5 fixed exactly this silent-discard bug in the scan screen's
// manual-ISBN dialog; this form must not reintroduce it.
assertEquals(IsbnFieldState.Invalid, validateIsbn("9780441013594")) // last digit wrong
assertEquals(IsbnFieldState.Invalid, validateIsbn("not-an-isbn"))
}
// --- validatePages ---
@Test
fun `blank pages is valid and means unknown, not zero`() {
assertEquals(PagesFieldState.Blank, validatePages(""))
}
@Test
fun `digit-only pages is valid`() {
assertEquals(PagesFieldState.Valid(412), validatePages("412"))
}
@Test
fun `non-digit pages is rejected`() {
assertEquals(PagesFieldState.Invalid, validatePages("412pp"))
assertEquals(PagesFieldState.Invalid, validatePages("-5"))
assertEquals(PagesFieldState.Invalid, validatePages("4.5"))
}
// --- AddBookUiState.canSave ---
@Test
fun `canSave is false with a blank title`() {
val state = AddBookUiState(draft = BookDraft(title = ""))
assertTrue(!state.canSave)
}
@Test
fun `canSave is true with only a title — every other field is optional`() {
val state = AddBookUiState(draft = BookDraft(title = "Dune"))
assertTrue(state.canSave)
}
@Test
fun `canSave is false while an isbn error is showing`() {
val state = AddBookUiState(draft = BookDraft(title = "Dune", isbn = "not-an-isbn"))
assertTrue(!state.canSave)
assertEquals("That isn't a valid ISBN — check the digits.", state.isbnError)
}
@Test
fun `canSave is false while a pages error is showing`() {
val state = AddBookUiState(draft = BookDraft(title = "Dune", pages = "412pp"))
assertTrue(!state.canSave)
assertEquals("Pages must be a number.", state.pagesError)
}
@Test
fun `canSave is false while a save is already in flight`() {
val state = AddBookUiState(draft = BookDraft(title = "Dune"), isSaving = true)
assertTrue(!state.canSave)
}
}
@@ -0,0 +1,292 @@
package org.modg.bookshelf.ui.add
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.decodeAuthors
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AddBookViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var locationRepository: LocationRepository
private lateinit var bookRepository: BookRepository
private lateinit var viewModel: AddBookViewModel
private lateinit var shelfId: String
private lateinit var context: android.content.Context
@Before
fun setUp() = runTest {
// AddBookViewModel.uiState is a combine(...).stateIn(viewModelScope, ...) —
// viewModelScope runs on Dispatchers.Main, which nothing else in this test
// class supplies. Without this, the combine's collector coroutine either
// never runs or runs on Robolectric's paused main looper, so uiState.value
// stays stuck at its initial seed forever (and any `.first { predicate }`
// await hangs). Unconfined so it drains synchronously with no explicit
// advanceUntilIdle() needed at each assertion site.
Dispatchers.setMain(UnconfinedTestDispatcher())
context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
bookRepository = BookRepository(db.bookDao(), context)
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
viewModel = AddBookViewModel(
bookRepository = bookRepository,
locationRepository = locationRepository,
settingsStore = settingsStore,
)
}
@After
fun tearDown() {
db.close()
Dispatchers.resetMain()
}
// --- field mapping ---
@Test
fun `save maps every field, splitting and trimming authors and dropping empty entries`() = runTest {
viewModel.onTitleChanged(" Dune ")
viewModel.onSubtitleChanged("A novel")
viewModel.onAuthorsChanged(" Frank Herbert ,, Brian Herbert , ")
viewModel.onPublisherChanged("Ace")
viewModel.onPublishedDateChanged("1965")
viewModel.onPagesChanged("412")
viewModel.onIsbnChanged("9780441013593")
viewModel.onDescriptionChanged("A desert planet.")
viewModel.onShelfSelected(shelfId)
val bookId = viewModel.performSave(clearAfterSave = false)
checkNotNull(bookId)
val saved = bookRepository.getById(bookId)
checkNotNull(saved)
assertEquals("Dune", saved.title)
assertEquals("A novel", saved.subtitle)
assertEquals(listOf("Frank Herbert", "Brian Herbert"), decodeAuthors(saved.authorsJson))
assertEquals("Ace", saved.publisher)
assertEquals("1965", saved.publishedDate)
assertEquals(412, saved.pageCount)
assertEquals("9780441013593", saved.isbn13)
assertEquals("A desert planet.", saved.description)
assertEquals(shelfId, saved.shelfId)
}
@Test
fun `blank isbn saves as null, not a silent parse failure`() = runTest {
viewModel.onTitleChanged("Untitled Pamphlet")
val bookId = viewModel.performSave(clearAfterSave = false)
checkNotNull(bookId)
val saved = bookRepository.getById(bookId)
checkNotNull(saved)
assertNull(saved.isbn13)
assertNull(saved.isbn10)
}
@Test
fun `a valid isbn10 is saved as both the normalized isbn13 and the original isbn10`() = runTest {
viewModel.onTitleChanged("Dune")
viewModel.onIsbnChanged("0-441-01359-7")
val bookId = viewModel.performSave(clearAfterSave = false)
checkNotNull(bookId)
val saved = bookRepository.getById(bookId)
checkNotNull(saved)
assertEquals("9780441013593", saved.isbn13)
assertEquals("0441013597", saved.isbn10)
}
// --- duplicate warning ---
@Test
fun `entering the isbn of an already-owned book surfaces the AlreadyOwned warning`() = runTest {
bookRepository.createBook(title = "Dune", isbn13 = "9780441013593")
viewModel.onIsbnChanged("9780441013593")
val duplicate = viewModel.uiState.first { it.duplicate !is DuplicateStatus.New }.duplicate
val alreadyOwned = duplicate as? DuplicateStatus.AlreadyOwned
checkNotNull(alreadyOwned) { "expected AlreadyOwned, got $duplicate" }
assertEquals("Dune", alreadyOwned.title)
}
@Test
fun `an isbn nobody owns yet stays New`() = runTest {
viewModel.onIsbnChanged("9780441013593")
// Let the duplicate check actually run before asserting on it.
bookRepository.findByIsbn13("9780441013593")
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
assertEquals(DuplicateStatus.New, viewModel.uiState.first().duplicate)
}
// --- save & add another ---
@Test
fun `save and add another clears the form but keeps the shelf and increments the count`() = runTest {
viewModel.onShelfSelected(shelfId)
viewModel.onTitleChanged("Dune")
viewModel.onAuthorsChanged("Frank Herbert")
viewModel.performSave(clearAfterSave = true)
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
val state = viewModel.uiState.first()
assertEquals("", state.draft.title)
assertEquals("", state.draft.authors)
assertEquals(shelfId, state.selectedShelfId)
assertEquals(1, state.savedCount)
assertEquals("Dune", state.lastSavedTitle)
}
@Test
fun `plain save does not clear the form`() = runTest {
viewModel.onTitleChanged("Dune")
viewModel.performSave(clearAfterSave = false)
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
val state = viewModel.uiState.first()
assertEquals("Dune", state.draft.title)
assertEquals(1, state.savedCount)
}
// --- failure handling: §4 of the task, mirroring ScanViewModel's runLookup guard ---
@Test
fun `a repository failure leaves the form intact, reports the exception class, and does not throw`() = runTest {
val vm = viewModelWithThrowingCreateBook(IllegalStateException("disk full"))
vm.onTitleChanged("Dune")
vm.onAuthorsChanged("Frank Herbert")
val result = vm.performSave(clearAfterSave = false) // must not throw
assertNull(result)
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
val state = vm.uiState.first()
assertEquals("Dune", state.draft.title) // form intact
assertEquals("Frank Herbert", state.draft.authors)
assertEquals("IllegalStateException", state.saveError)
assertTrue(!state.isSaving)
assertEquals(0, state.savedCount)
}
@Test
fun `a CancellationException during save is rethrown, not swallowed as a failure`() = runTest {
val vm = viewModelWithThrowingCreateBook(CancellationException("scope cancelled"))
vm.onTitleChanged("Dune")
try {
vm.performSave(clearAfterSave = false)
fail("expected CancellationException to propagate")
} catch (e: CancellationException) {
// expected — a cancelled save is not a failed one.
}
}
// --- double-tap guard ---
@Test
fun `a second save while one is in flight does not create a second book`() = runTest {
viewModel.onTitleChanged("Dune")
val first = launch { viewModel.performSave(clearAfterSave = false) }
val second = launch { viewModel.performSave(clearAfterSave = false) }
first.join()
second.join()
val allBooks = mutableListOf<BookEntity>()
bookRepository.observeAll().let { flow -> allBooks.addAll(flow.first()) }
assertEquals(1, allBooks.size)
// .first(), not .value — see the KDoc on AddBookViewModel.uiState.
assertEquals(1, viewModel.uiState.first().savedCount)
}
// --- remembered shelf ---
@Test
fun `saving to a shelf remembers it for next time`() = runTest {
viewModel.onTitleChanged("Dune")
viewModel.onShelfSelected(shelfId)
viewModel.performSave(clearAfterSave = false)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest {
settingsStore.setLastShelfId(shelfId)
viewModel.onTitleChanged("Dune")
viewModel.onShelfSelected(null)
viewModel.performSave(clearAfterSave = false)
assertEquals(shelfId, settingsStore.lastShelfId.first())
}
@Test
fun `the remembered shelf pre-selects the picker on a fresh form`() = runTest {
settingsStore.setLastShelfId(shelfId)
val vm = AddBookViewModel(
bookRepository = bookRepository,
locationRepository = locationRepository,
settingsStore = settingsStore,
)
val preSelected = vm.uiState.first { it.selectedShelfId != null }
assertEquals(shelfId, preSelected.selectedShelfId)
}
private fun viewModelWithThrowingCreateBook(toThrow: Throwable): AddBookViewModel = AddBookViewModel(
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context),
locationRepository = locationRepository,
settingsStore = settingsStore,
)
/** Delegates every query to the real DAO except [upsert], which [AddBookViewModel.performSave]'s createBook hits. */
private class ThrowingUpsertBookDao(
private val delegate: BookDao,
private val toThrow: Throwable,
) : BookDao by delegate {
override suspend fun upsert(book: BookEntity) = throw toThrow
}
}
@@ -0,0 +1,77 @@
package org.modg.bookshelf.ui.nav
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* [Routes.addBook] must percent-encode its args well enough to survive the
* query-string round trip Navigation Compose actually performs on a real
* device: it parses the built route as a [Uri] and extracts each `{arg}`
* placeholder via [Uri.getQueryParameter], which does standard percent-
* decoding (not `+`-as-space form-decoding) — so this test decodes the same
* way, via a real [Uri] under Robolectric, rather than re-implementing
* decoding by hand and only proving the two sides of one hand-rolled pair
* agree with each other.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class RoutesTest {
private fun decode(route: String, arg: String): String? =
Uri.parse("scheme://host/$route").getQueryParameter(arg)
@Test
fun `round-trips a title containing query-breaking characters`() {
val title = "Hänsel & Gretel / Part 1?"
val route = Routes.addBook(title = title)
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
}
@Test
fun `round-trips a title containing a hash`() {
val title = "Chapter #3: The Return"
val route = Routes.addBook(title = title)
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
}
@Test
fun `round-trips spaces and non-ASCII`() {
val title = "Le Petit Prince — a été traduit"
val route = Routes.addBook(title = title)
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
}
@Test
fun `round-trips both title and isbn together`() {
val title = "Q&A: Life, the Universe / Everything?"
val isbn = "978-0-13-468599-1"
val route = Routes.addBook(title = title, isbn = isbn)
assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG))
assertEquals(isbn, decode(route, Routes.ADD_BOOK_ISBN_ARG))
}
@Test
fun `omits absent args entirely rather than encoding a literal null`() {
assertEquals("add", Routes.addBook())
assertEquals("add?title=${Uri.encode("Dune")}", Routes.addBook(title = "Dune"))
val isbnOnlyRoute = Routes.addBook(isbn = "9780441013593")
assertNull(decode(isbnOnlyRoute, Routes.ADD_BOOK_TITLE_ARG))
assertEquals("9780441013593", decode(isbnOnlyRoute, Routes.ADD_BOOK_ISBN_ARG))
}
@Test
fun `bare add route matches the ADD_BOOK pattern's base path`() {
assertEquals("add", Routes.addBook().substringBefore("?"))
assertEquals("add", Routes.ADD_BOOK.substringBefore("?"))
}
}
@@ -0,0 +1,86 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.runtime.Composable
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.ui.add.AddBookCallbacks
import org.modg.bookshelf.ui.add.AddBookContent
import org.modg.bookshelf.ui.add.AddBookUiState
import org.modg.bookshelf.ui.add.BookDraft
import org.modg.bookshelf.ui.scan.DuplicateStatus
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC gap: add-by-hand form for books with no barcode. Renders the REAL
* [AddBookContent] fed with static [AddBookUiState] — no hand-rolled lookalike
* (see [ScreenFixtures]'s KDoc for why a real [org.modg.bookshelf.AppContainer]
* can't drive this inside Paparazzi, and HANDOFF.md for why wave 6's Locations
* shortcut isn't repeated here).
*/
class AddBookScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun addBookEmptyLight() = snapshotBoth("add-book-empty") {
AddBookContent(state = AddBookUiState(), callbacks = noopCallbacks())
}
@Test
fun addBookFilledWithDuplicateLight() = snapshotBoth("add-book-filled-duplicate") {
AddBookContent(
state = AddBookUiState(
draft = BookDraft(
title = "Dune",
subtitle = "",
authors = "Frank Herbert",
publisher = "Ace",
publishedDate = "1965",
pages = "412",
isbn = "9780441013593",
description = "A desert planet, a young man's destiny, and the spice that binds it all.",
),
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
selectedShelfId = ScreenFixtures.topShelf.id,
duplicate = DuplicateStatus.AlreadyOwned(bookId = "b1", title = "Dune"),
),
callbacks = noopCallbacks(),
)
}
@Test
fun addBookIsbnErrorLight() = snapshotBoth("add-book-isbn-error") {
AddBookContent(
state = AddBookUiState(
draft = BookDraft(title = "Old Pamphlet", isbn = "not-an-isbn"),
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
),
callbacks = noopCallbacks(),
)
}
private fun noopCallbacks() = AddBookCallbacks(
onTitleChanged = {},
onSubtitleChanged = {},
onAuthorsChanged = {},
onPublisherChanged = {},
onPublishedDateChanged = {},
onPagesChanged = {},
onIsbnChanged = {},
onDescriptionChanged = {},
onShelfSelected = {},
onSave = {},
onSaveAndAddAnother = {},
onBack = {},
)
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -3,12 +3,16 @@ package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Warehouse
@@ -16,7 +20,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
@@ -28,6 +34,7 @@ import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SecondaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.library.LibraryBookCard
@@ -71,7 +78,11 @@ class LibraryScreenPaparazziTest {
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
},
floatingActionButton = {
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
Column(horizontalAlignment = Alignment.End) {
SmallFloatingActionButton(onClick = {}) { Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") }
Spacer(modifier = Modifier.height(12.dp))
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
}
},
syncStatusBar = { SyncStatusBar(status = syncStatus, label = syncLabel) },
) { innerPadding ->
@@ -91,7 +102,12 @@ class LibraryScreenPaparazziTest {
EmptyState(
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
action = {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PrimaryButton(text = "Scan a book", onClick = {})
SecondaryButton(text = "Add by hand", onClick = {})
}
},
)
} else {
LazyVerticalGrid(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+1
View File
@@ -0,0 +1 @@
3eb4f3f4-1013-4838-91eb-d217251be382
+34
View File
@@ -0,0 +1,34 @@
=== K-manual-fix attempts=1 quota_waits=0 hard_fails=0 ===
cost=$7.268144399999997 turns=7 err=?
--- result (tail) ---
59-6` → `...01359-7`, and the corresponding expected `isbn10` value); added `Dispatchers.setMain(UnconfinedTestDispatcher())`/`resetMain()` in `@Before`/`@After`; added `@OptIn(ExperimentalCoroutinesApi::class)` (matches `ScannerControllerTest`'s existing convention, silences the opt-in warning); switched 5 direct `.value` reads to `.first()`.
- `app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookViewModel.kt`: added a KDoc comment on `uiState` explaining the `WhileSubscribed` caveat for future readers — no behavioral change.
- Recorded and eyeballed the new Paparazzi goldens (`AddBookScreenPaparazziTest` ×3 states ×2 themes, `LibraryScreenPaparazziTest` re-recorded ×2 states ×2 themes) — all render correctly: form fields, ISBN error state, duplicate warning, the new satellite "Add by hand" FAB, and the empty-state secondary button, in both themes.
Nothing else touched — the feature itself (screens, nav, routes, entry points) is exactly as the previous worker left it.
### Gate output (verbatim tails)
```
./tasks/gw assembleDebug
BUILD SUCCESSFUL in 1m 43s
39 actionable tasks: 3 executed, 36 up-to-date
./tasks/gw testDebugUnitTest
BUILD SUCCESSFUL in 1m 24s
34 actionable tasks: 1 executed, 33 up-to-date
```
Summed from `TEST-*.xml`: **tests=241 skipped=2 failures=0 errors=0** (baseline was 205; up 36, zero failures/skips beyond the 2 pre-existing opt-in live tests).
```
./tasks/gw recordPaparazziDebug -> BUILD SUCCESSFUL in 3m 42s
./tasks/gw verifyPaparazziDebug
BUILD SUCCESSFUL in 48s
34 actionable tasks: 1 executed, 33 up-to-date
```
No Kotlin warnings on any touched file in the full build log (`grep "^w:"` empty).
### Not committed
Working tree only, as required — `git status` shows the modifications/new files, nothing staged or committed.
+1
View File
@@ -0,0 +1 @@
1f9304e1-ad80-456e-ab8c-3720b352905e
+4
View File
@@ -0,0 +1,4 @@
=== K-manual attempts=1 quota_waits=0 hard_fails=0 ===
cost=$5.051114400000003 turns=108 err=?
--- result (tail) ---
I'll stop polling now and wait for the background monitor to notify me when `testDebugUnitTest` completes.
+14
View File
@@ -0,0 +1,14 @@
A previous worker implemented the task in ~/bookshelf/tasks/K-manual.txt and left the
working tree FAILING its acceptance gate. Read, in order: ~/bookshelf/docs/SPEC.md,
~/bookshelf/tasks/K-manual.txt (the original instructions — every constraint in it binds
you too), ~/bookshelf/logs/K-manual.summary (that worker's report), then the gate output at
~/bookshelf/logs/gate-K-manual.log. Lines starting "GATE:" and non-zero exits are the failures.
Fix ONLY what makes the gate red. Do not redesign or re-implement the feature. The
test count must end ABOVE 205 with zero failures; do not delete or @Ignore tests to
get there. If a Paparazzi verify failed because snapshots were never recorded, record
them and LOOK at the PNGs before accepting them.
Run in the FOREGROUND (never background a build): ./tasks/gw assembleDebug,
./tasks/gw testDebugUnitTest, ./tasks/gw verifyPaparazziDebug. Do not git commit/add/push.
Report what was wrong, what you changed, and the verbatim output tails.