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
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(
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |