diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index e564eb1..a16328c 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt index e7b966a..800af60 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt @@ -22,10 +22,28 @@ class MetadataRepository( suspend fun lookup(isbn: String): BookMetadata? { val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null - return coroutineScope { + val merged = coroutineScope { val openLibrary = async { openLibraryClient.lookup(isbn13) } val googleBooks = async { googleBooksClient.lookup(isbn13) } MetadataMerger.merge(openLibrary.await(), googleBooks.await()) + } ?: return null + return if (merged.coverUrl.isNullOrBlank()) { + merged.copy(coverUrl = byIsbnCoverUrl(isbn13)) + } else { + merged } } + + companion object { + /** + * Last resort when neither source reported cover art, per SPEC's cover chain. + * `default=false` is load-bearing: without it this endpoint answers 200 with a + * 1x1 transparent GIF for editions it has no art for, which an image loader + * treats as a successful load and paints as an invisible cover. With it, a + * miss is a 404, so [org.modg.bookshelf.ui.components.BookCover] can fall back + * to its placeholder. + */ + fun byIsbnCoverUrl(isbn13: String): String = + "https://covers.openlibrary.org/b/isbn/$isbn13-L.jpg?default=false" + } } diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt index 3baeed6..b78d629 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt @@ -18,6 +18,7 @@ data class OpenLibraryBookDto( @SerialName("publish_date") val publishDate: String? = null, @SerialName("number_of_pages") val numberOfPages: Int? = null, val identifiers: OpenLibraryIdentifiersDto? = null, + val cover: OpenLibraryCoverDto? = null, ) @Serializable @@ -26,13 +27,33 @@ data class OpenLibraryAuthorDto(val name: String? = null) @Serializable data class OpenLibraryPublisherDto(val name: String? = null) +/** + * Present only when Open Library actually holds cover art for the edition, and so + * the only trustworthy "has a cover" signal from this API. A synthesized by-ISBN + * covers.openlibrary.org URL is NOT evidence of one: for an edition with no art it + * answers 200 with a 43-byte 1x1 transparent GIF (verified 2026-09-09), which any + * image loader reports as a successful load — the cover then renders as nothing at + * all and no error placeholder ever fires. Only `?default=false` turns that into a + * 404; see [MetadataRepository] for the last-resort URL that uses it. + */ +@Serializable +data class OpenLibraryCoverDto( + val small: String? = null, + val medium: String? = null, + val large: String? = null, +) + @Serializable data class OpenLibraryIdentifiersDto( @SerialName("isbn_10") val isbn10: List = emptyList(), @SerialName("isbn_13") val isbn13: List = emptyList(), ) -/** Maps the OL DTO to the source-agnostic [BookMetadata], deriving the cover URL per SPEC. */ +/** + * Maps the OL DTO to the source-agnostic [BookMetadata]. [coverUrl] stays null unless + * OL reports real cover art, so that the SPEC merge rule can fall through to Google + * Books' thumbnail instead of pinning a URL that resolves to a blank image. + */ fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = BookMetadata( isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13, isbn10 = identifiers?.isbn10?.firstOrNull(), @@ -43,5 +64,5 @@ fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = Book publishedDate = publishDate, pageCount = numberOfPages, description = null, - coverUrl = "https://covers.openlibrary.org/b/isbn/$lookupIsbn13-L.jpg", + coverUrl = cover?.large?.takeIf { it.isNotBlank() } ?: cover?.medium?.takeIf { it.isNotBlank() }, ) diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt index cc3ac91..3b47ec3 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookCover.kt @@ -3,15 +3,20 @@ package org.modg.bookshelf.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.BrokenImage import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -27,9 +32,12 @@ const val BookCoverAspectRatio = 2f / 3f /** * A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the * hero of this app's design — real art fills the whole shape edge to edge. - * When there's no [coverUrl], or the load fails, we fall back to the same - * restrained "letterpress" placeholder: a paper-toned panel with a debossed - * spine motif rather than a broken-image icon or empty grey box. + * When there's no [coverUrl], or the load fails, or one is still in flight, we + * fall back to the same restrained "letterpress" placeholder: a paper-toned panel + * with a debossed spine motif rather than a broken-image icon or empty grey box. + * The placeholder is drawn in EVERY non-success state on purpose — a cover slot + * that renders nothing at all leaves the title floating in blank space, which is + * exactly what a transparent 1x1 stand-in cover used to produce. */ @Composable fun BookCover( @@ -52,11 +60,16 @@ fun BookCover( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) { - when (painter.state) { + // painter.state is a StateFlow, NOT a State. Branching on it + // directly compiles (a `when` used as a statement needs no else) but + // every branch is always false, so the slot draws nothing at all — + // no cover and no placeholder. Collect it before matching. + val state by painter.state.collectAsState() + when (state) { is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true) is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Empty, - -> CoverPlaceholder(errored = false, loading = true) + -> CoverPlaceholder(errored = false) is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() } } @@ -65,25 +78,39 @@ fun BookCover( } @Composable -private fun CoverPlaceholder(errored: Boolean, loading: Boolean = false) { +private fun CoverPlaceholder(errored: Boolean) { val paperAlt = MaterialTheme.colorScheme.surfaceVariant val ink = MaterialTheme.colorScheme.onSurfaceVariant + val spine = MaterialTheme.colorScheme.primary + val gold = MaterialTheme.colorScheme.secondary Box( modifier = Modifier .fillMaxSize() .background(paperAlt) - .border(width = 1.dp, color = ink.copy(alpha = 0.15f)) - .padding(2.dp) - .border(width = 1.dp, color = ink.copy(alpha = 0.1f)), + .border(width = 1.dp, color = ink.copy(alpha = 0.22f)), contentAlignment = Alignment.Center, ) { - if (!loading) { - Icon( - imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories, - contentDescription = null, - tint = ink.copy(alpha = if (errored) 0.35f else 0.28f), - modifier = Modifier.fillMaxSize(0.32f), + // A bound: a mahogany spine strip down the left edge with a gold hairline + // beside it. This is what makes an art-less cover still read as a book. + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(10.dp) + .background(spine.copy(alpha = 0.35f)), + ) + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(gold.copy(alpha = 0.55f)), ) } + Icon( + imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories, + contentDescription = null, + tint = ink.copy(alpha = 0.4f), + modifier = Modifier.fillMaxSize(0.3f), + ) } } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt index d56de52..352c2f1 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/BookshelfScaffold.kt @@ -2,7 +2,6 @@ package org.modg.bookshelf.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -18,6 +17,12 @@ import androidx.compose.ui.Modifier * screen title and a gold hairline rule underneath, plus optional nav/action * slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for * this instead of a bare [Scaffold] so the chrome stays consistent. + * + * [syncStatusBar] goes in the Scaffold's own bottom-bar slot rather than in a + * hand-rolled Column under the content: that is what makes the height it + * occupies show up in the [PaddingValues] handed to [content], so a screen that + * applies them can't scroll its last row underneath the status line. The bar + * takes the navigation-bar inset itself (see [SyncStatusBar]). */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -49,14 +54,8 @@ fun BookshelfScaffold( } }, floatingActionButton = floatingActionButton, + bottomBar = syncStatusBar, containerColor = MaterialTheme.colorScheme.surface, - content = { innerPadding -> - Column(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(1f)) { - content(innerPadding) - } - syncStatusBar() - } - }, + content = content, ) } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt b/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt index 2e477b7..e935b94 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/components/SyncStatusBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -38,8 +39,13 @@ enum class SyncStatus { } /** - * A slim, quiet status line — never a blocking banner or dialog. Sits at the - * bottom of [BookshelfScaffold] screens. + * A slim, quiet status line — never a blocking banner or dialog. Sits in + * [BookshelfScaffold]'s bottom-bar slot. + * + * The app draws edge to edge, so this is the one composable that sits against + * the very bottom of the display: it owns the navigation-bar inset itself, and + * its horizontal padding is deliberately wider than the app's usual 16dp so the + * dot and label clear a phone's rounded display corners. */ @Composable fun SyncStatusBar( @@ -51,7 +57,8 @@ fun SyncStatusBar( modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(horizontal = 16.dp, vertical = 8.dp), + .navigationBarsPadding() + .padding(horizontal = 28.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt index 0a77d91..6941187 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -264,9 +265,14 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) { contentDescription = book.title, modifier = Modifier.fillMaxWidth(), ) + // titleSmall's 24sp line height is right for a paragraph and wrong here: on a + // wrapped two-line title it opened a bigger gap between the title's own lines + // than between the title and the author beneath it, so the author read as part + // of the title block. Tighten the leading and give the author its own gap, so + // the card groups as one title + one byline. Text( text = book.title, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleSmall.copy(lineHeight = 20.sp), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -280,6 +286,7 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), ) } } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt index 229ba3d..8d9325e 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanModels.kt @@ -18,7 +18,14 @@ object DuplicateCheck { /** What the scan bottom sheet is currently showing. */ sealed interface ScanSheetState { data object Hidden : ScanSheetState - data object Loading : ScanSheetState + + /** + * Carries [isbn13] so the sheet can name the code it just read. A bare spinner + * doesn't tell the user the barcode was recognised, and they keep holding the + * book up to the camera; echoing the number back is the signal that they can + * lower it. + */ + data class Loading(val isbn13: String) : ScanSheetState data class Found(val isbn13: String, val metadata: BookMetadata, val duplicate: DuplicateStatus) : ScanSheetState data class NotFound(val isbn13: String) : ScanSheetState } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt index 727e416..839c7e2 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt @@ -137,9 +137,7 @@ fun ScanScreen( when (val state = sheetState) { is ScanSheetState.Hidden -> Unit is ScanSheetState.Loading -> ModalBottomSheet(onDismissRequest = { }, sheetState = rememberModalBottomSheetState()) { - Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + SearchingSheet(isbn13 = state.isbn13) } is ScanSheetState.Found -> ModalBottomSheet( onDismissRequest = { viewModel.dismissSheet() }, @@ -238,6 +236,40 @@ internal fun ScanReticle(modifier: Modifier = Modifier) { } } +/** + * Shown the moment a barcode is decoded, while the metadata lookup runs. It names + * the ISBN it read and says so in words, because a lone spinner reads as "still + * working on it" — the user goes on holding the book up to the camera when the + * camera is already done with it. + */ +@Composable +internal fun SearchingSheet(isbn13: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text( + text = "Searching…", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 20.dp), + ) + Text( + text = "ISBN $isbn13", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = "Barcode read — you can lower the book.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } +} + @Composable internal fun SessionBadge(count: Int, modifier: Modifier = Modifier) { if (count == 0) return diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt index a5d0ee8..b7b834e 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanViewModel.kt @@ -52,7 +52,7 @@ class ScanViewModel( private suspend fun onScanned(isbn13: String) { if (_sheetState.value !is ScanSheetState.Hidden) return // a sheet is already up for a previous hit - _sheetState.value = ScanSheetState.Loading + _sheetState.value = ScanSheetState.Loading(isbn13) val duplicate = DuplicateCheck.check(bookRepository.findByIsbn13(isbn13)) val metadata = metadataRepository.lookup(isbn13) _sheetState.value = ScanMetadataOutcome.from(isbn13, metadata, duplicate) diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt index acb7270..dd2ef31 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/setup/SetupScreen.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState @@ -15,6 +17,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -38,11 +43,23 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) { }, ) val state by viewModel.uiState.collectAsState() + val focusManager = LocalFocusManager.current + + val canSubmit = !state.isSubmitting && + state.serverUrl.isNotBlank() && + state.email.isNotBlank() && + state.password.isNotBlank() PaperSurface(modifier = Modifier.fillMaxSize()) { + // This screen has no Scaffold of its own, so it owns its window insets. + // safeDrawingPadding covers the IME as well as the system bars, and it sits + // OUTSIDE verticalScroll on purpose: the keyboard then shrinks the scrollable + // viewport rather than covering it, so Compose brings the newly focused field + // into view instead of leaving Password stranded behind the IME. Column( modifier = Modifier .fillMaxSize() + .safeDrawingPadding() .verticalScroll(rememberScrollState()) .padding(24.dp), verticalArrangement = Arrangement.Center, @@ -61,7 +78,11 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) { label = { Text("Server URL") }, placeholder = { Text("https://library.example.com") }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), isError = state.urlError != null, modifier = Modifier.fillMaxWidth(), ) @@ -81,7 +102,11 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) { onValueChange = viewModel::onEmailChanged, label = { Text("Email") }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), isError = state.credentialsError != null, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) @@ -91,7 +116,16 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) { label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + if (canSubmit) viewModel.submit(onSetupComplete) + }, + ), isError = state.credentialsError != null, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) @@ -107,10 +141,7 @@ fun SetupScreen(onSetupComplete: () -> Unit, container: AppContainer) { PrimaryButton( text = if (state.isSubmitting) "Signing in…" else "Sign in", onClick = { viewModel.submit(onSetupComplete) }, - enabled = !state.isSubmitting && - state.serverUrl.isNotBlank() && - state.email.isNotBlank() && - state.password.isNotBlank(), + enabled = canSubmit, modifier = Modifier.padding(top = 24.dp), ) } diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataRepositoryTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataRepositoryTest.kt new file mode 100644 index 0000000..d147cc7 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataRepositoryTest.kt @@ -0,0 +1,25 @@ +package org.modg.bookshelf.data.metadata + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MetadataRepositoryTest { + + /** + * `default=false` is the whole point of this URL. Without it covers.openlibrary.org + * answers 200 with a 43-byte 1x1 transparent GIF for an edition it holds no art for + * — an image loader calls that a successful load, so the cover slot renders empty + * and the placeholder never appears. With it, a miss is a 404 the loader can report. + */ + @Test + fun `by-isbn cover url disables the blank stand-in image`() { + val url = MetadataRepository.byIsbnCoverUrl("9780201558029") + + assertEquals( + "https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg?default=false", + url, + ) + assertTrue("must opt out of the 1x1 stand-in", url.contains("default=false")) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt index 2bc4c22..cd9f70f 100644 --- a/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt @@ -29,7 +29,36 @@ class OpenLibraryClientTest { assertEquals(672, result.pageCount) assertEquals("9780201558029", result.isbn13) assertEquals("0201558025", result.isbn10) - assertEquals("https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg", result.coverUrl) + assertEquals("https://covers.openlibrary.org/b/id/675832-L.jpg", result.coverUrl) + } + + /** + * The by-ISBN cover endpoint answers 200 with a 1x1 transparent GIF for editions + * with no art, so synthesizing that URL here would hand the UI a cover that loads + * "successfully" and paints nothing. No `cover` object means no cover URL, which + * is what lets [MetadataMerger] fall through to Google Books' thumbnail. + */ + @Test + fun `leaves coverUrl null when the response reports no cover art`() { + val body = """ + {"ISBN:9780201558029": {"title": "Concrete Mathematics", "publishers": [{"name": "AW"}]}} + """.trimIndent() + val result = checkNotNull(client.parseResponse(body, "9780201558029")) + + assertEquals("Concrete Mathematics", result.title) + assertNull(result.coverUrl) + } + + @Test + fun `falls back to the medium cover when no large one is offered`() { + val body = """ + {"ISBN:9780201558029": {"title": "Concrete Mathematics", + "cover": {"small": "https://covers.openlibrary.org/b/id/675832-S.jpg", + "medium": "https://covers.openlibrary.org/b/id/675832-M.jpg"}}} + """.trimIndent() + val result = checkNotNull(client.parseResponse(body, "9780201558029")) + + assertEquals("https://covers.openlibrary.org/b/id/675832-M.jpg", result.coverUrl) } @Test diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt index 76270b3..b88df94 100644 --- a/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScanScreenPaparazziTest.kt @@ -28,6 +28,7 @@ import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.scan.DuplicateStatus import org.modg.bookshelf.ui.scan.FoundBookSheet import org.modg.bookshelf.ui.scan.ScanReticle +import org.modg.bookshelf.ui.scan.SearchingSheet import org.modg.bookshelf.ui.scan.SessionBadge import org.modg.bookshelf.ui.theme.BookshelfTheme @@ -52,6 +53,10 @@ class ScanScreenPaparazziTest { @Test fun scanFoundSheetLight() = snapshotBoth("scan-found-sheet") { FoundSheetOverlay() } + /** The state between "barcode decoded" and "metadata back" — see [SearchingSheet]. */ + @Test + fun scanSearchingSheetLight() = snapshotBoth("scan-searching-sheet") { SearchingSheetOverlay() } + @Composable private fun ReticleOverlay() = Shell { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -89,6 +94,19 @@ class ScanScreenPaparazziTest { } } + @Composable + private fun SearchingSheetOverlay() = Shell { + Box(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + color = MaterialTheme.colorScheme.surface, + ) { + SearchingSheet(isbn13 = "9780765326355") + } + } + } + @Composable private fun Shell(overlay: @Composable () -> Unit) { BookshelfScaffold( diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png index 6897a58..c3d5649 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png index 2e4db3a..9d35aad 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_bookCoverPlaceholder_book-cover-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png index 59dfd7f..e6a8f46 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png index 29f9bfe..f474f57 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_scaffoldAndDividerLight_scaffold-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png index d0ea6c0..56f3a1c 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png index e2e7606..bb65cbe 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.components_ComponentGalleryPaparazziTest_syncStatusBarStates_sync-status-bar-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png index 87f40d5..44722b8 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png index 88b8b96..07c9eab 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_DetailScreenPaparazziTest_detailPopulatedLight_detail-populated-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png index 6fdb470..ecd0f24 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png index 8005998..0a298ed 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png index dbecbd1..6b4131b 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png index 9193e2e..b578d34 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png index 6826fd1..585fff0 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png index 54f0771..492f258 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanFoundSheetLight_scan-found-sheet-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-dark.png new file mode 100644 index 0000000..ef8f8f0 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-light.png new file mode 100644 index 0000000..888e600 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_ScanScreenPaparazziTest_scanSearchingSheetLight_scan-searching-sheet-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png index b9f9839..5396600 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png index 1354963..94aa248 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_SettingsScreenPaparazziTest_settingsPopulatedLight_settings-populated-light.png differ