Second on-device feedback round: eight fixes, and a measured retry policy
The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.
Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):
- Google Books keyless is dead for everyone, permanently. The user's
residential-IP test returned a quota error naming a shared anonymous PROJECT,
not an IP, so the earlier "your phone may well get answers" guess is wrong and
is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
Found into Unavailable, that standing failure meant every Open Library hiccup
surfaced as "one or more sources couldn't be reached". The API key is
deliberately deferred by the user; this commit leaves the source broken.
- Our own timeouts were manufacturing failures. Over 30 live requests, 13%
failed — all fast TLS resets under 2.5s — while successes ran to a median of
4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
so ~8% of lookups that were about to work were cancelled and reported as
unreachable. Timeouts are now 25s/20s/20s.
That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).
SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.
Also from the same feedback round:
- Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
empty bookcases say so, and the most recently used shelf is pinned on top.
- The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
cleared on sign-out. It is offered, never pre-selected: the user weighed that
and chose one tap over the risk of silently mis-shelving a book.
- Locations dialogs and the manual-ISBN dialog auto-focus their first field.
- The library filter menu offers "Add a bookcase to enable filtering" instead of
a lone "All books" that is already the active state and cannot be changed.
- The Locations button is Material Symbols' "shelves" (a bookcase) instead of
Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
- The scan sheet drops "you can lower the book" — the ISBN echo already says it.
assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.
Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
@@ -93,11 +93,24 @@ class AppContainer(private val context: Context) {
|
||||
// server. A call timeout is load-bearing here: with none, a stalled
|
||||
// connection hangs on OkHttp's default (unbounded) socket timeouts, and the
|
||||
// user is standing at a bookshelf waiting on it.
|
||||
//
|
||||
// These were 12s/10s/10s and were MANUFACTURING failures. Measuring the exact
|
||||
// Open Library call 30 times on 2026-09-09 (docs/METADATA-SOURCES.md) found
|
||||
// successful requests with a median of 4.3s but a long tail — p90 9.2s, max
|
||||
// 22.0s, and one connect phase alone of 19.6s. Two of 26 successes exceeded the
|
||||
// old 12s call timeout, so ~8% of lookups that were about to work were being
|
||||
// cancelled and reported to the user as "couldn't be reached".
|
||||
//
|
||||
// Raising these does NOT slow the failure path: every observed failure was a
|
||||
// TLS-stage reset returning in under 2.5s, and a socket that is going to break
|
||||
// breaks long before any of these limits. The timeouts only ever bound the
|
||||
// slow-success tail, which is precisely what we want to stop truncating.
|
||||
// 25s > the 22.0s worst observed success, with room to spare.
|
||||
private val metadataHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(12, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.callTimeout(25, TimeUnit.SECONDS)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
@@ -20,7 +19,18 @@ class GoogleBooksClient(
|
||||
) {
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||
/**
|
||||
* Retries transient failures per [RetryPolicy]. Note this source's standing
|
||||
* failure — keyless requests share one exhausted global quota and answer 429,
|
||||
* which [RetryPolicy] deliberately does NOT retry, so today this costs nothing
|
||||
* and changes nothing here. See docs/METADATA-SOURCES.md.
|
||||
*/
|
||||
suspend fun lookup(isbn13: String): SourceResult =
|
||||
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun lookupOnce(isbn13: String): SourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||
|
||||
private fun fetch(isbn13: String): SourceResult = try {
|
||||
val request = Request.Builder()
|
||||
@@ -29,10 +39,8 @@ class GoogleBooksClient(
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string())
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
SourceResult.Failed("timeout")
|
||||
} catch (e: IOException) {
|
||||
SourceResult.Failed("network error")
|
||||
SourceResult.fromException(e)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,16 +50,16 @@ class GoogleBooksClient(
|
||||
* can never disagree about what a body means.
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?): SourceResult {
|
||||
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
|
||||
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
|
||||
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
|
||||
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
|
||||
val metadata = dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
|
||||
if (metadata != null) SourceResult.Found(metadata) else SourceResult.NotFound
|
||||
} catch (e: SerializationException) {
|
||||
SourceResult.Failed("malformed json")
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SourceResult.Failed("malformed json")
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
@@ -23,7 +22,17 @@ class OpenLibraryClient(
|
||||
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun lookup(isbn13: String): SourceResult = withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||
/**
|
||||
* Retries transient failures per [RetryPolicy]. Measured against the live API,
|
||||
* 13% of requests fail at the TLS stage in well under a second while successful
|
||||
* ones take seconds — so a retry is nearly free and removes most of that 13%.
|
||||
*/
|
||||
suspend fun lookup(isbn13: String): SourceResult =
|
||||
withContext(Dispatchers.IO) { withRetry { fetch(isbn13) } }
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun lookupOnce(isbn13: String): SourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(isbn13) }
|
||||
|
||||
private fun fetch(isbn13: String): SourceResult = try {
|
||||
val request = Request.Builder()
|
||||
@@ -32,10 +41,8 @@ class OpenLibraryClient(
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string(), isbn13)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
SourceResult.Failed("timeout")
|
||||
} catch (e: IOException) {
|
||||
SourceResult.Failed("network error")
|
||||
SourceResult.fromException(e)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,16 +52,16 @@ class OpenLibraryClient(
|
||||
* can never disagree about what a body means.
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?, isbn13: String): SourceResult {
|
||||
if (httpCode !in 200..299) return SourceResult.Failed("http $httpCode")
|
||||
if (body.isNullOrBlank()) return SourceResult.Failed("empty body")
|
||||
if (httpCode !in 200..299) return SourceResult.fromHttpCode(httpCode)
|
||||
if (body.isNullOrBlank()) return SourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return SourceResult.NotFound
|
||||
SourceResult.Found(json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13))
|
||||
} catch (e: SerializationException) {
|
||||
SourceResult.Failed("malformed json")
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SourceResult.Failed("malformed json")
|
||||
SourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* When a failed lookup attempt is worth repeating, and how long to wait first.
|
||||
*
|
||||
* Grounded in a measurement of the live Open Library API from 2026-09-09 — 30
|
||||
* requests, the exact call [OpenLibraryClient] makes (see docs/METADATA-SOURCES.md):
|
||||
*
|
||||
* - 13% of requests failed, every one of them a TLS-stage connection reset.
|
||||
* - Failures were FAST: 0.23s, 0.31s, 0.59s, 2.46s.
|
||||
* - Successes were SLOW and long-tailed: median 4.3s, p90 9.2s, max 22.0s.
|
||||
*
|
||||
* That asymmetry drives every constant here. A retry costs roughly a fifth of a
|
||||
* second of the user's time and turns a 13% failure rate into ~1.7% at two
|
||||
* attempts and ~0.2% at three, which is why the backoff is short rather than the
|
||||
* conventional exponential-with-seconds. It is also why [isRetryable] refuses to
|
||||
* repeat a [FailureKind.TIMEOUT]: a timeout means we already spent the full budget
|
||||
* on that attempt, so repeating it risks tripling the wait for a user standing at
|
||||
* a bookshelf, and the measurement says slow requests usually eventually succeed
|
||||
* rather than fail — the fix for those is a generous timeout, not another attempt.
|
||||
*/
|
||||
object RetryPolicy {
|
||||
|
||||
/** One original attempt plus two retries. Beyond this the marginal gain is noise. */
|
||||
const val MAX_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* Stop starting NEW attempts once this much time has gone into a single source.
|
||||
* A backstop against pathological cases (every attempt hitting the slow tail),
|
||||
* not a normal-path limit. It is deliberately checked only BETWEEN attempts —
|
||||
* an in-flight request is never cancelled, because the 22-second request in the
|
||||
* sample was a successful one and killing it would manufacture exactly the
|
||||
* failure this whole change exists to remove.
|
||||
*/
|
||||
const val TOTAL_BUDGET_MILLIS = 30_000L
|
||||
|
||||
/**
|
||||
* [FailureKind.TRANSPORT] and [FailureKind.SERVER_ERROR] are transient and
|
||||
* cheap to re-ask. The rest are not:
|
||||
* - TIMEOUT — the budget is already spent; see the class KDoc.
|
||||
* - RATE_LIMITED — the source is explicitly asking us to stop. Hammering a
|
||||
* quota is how an intermittent block becomes a permanent one,
|
||||
* and METADATA-SOURCES.md records that happening to this
|
||||
* project's IP during research. When the Google Books API key
|
||||
* lands, revisit this: a keyed 429 is a per-second rate limit
|
||||
* and IS worth one Retry-After-respecting retry, unlike
|
||||
* today's keyless daily-quota 429, which never clears.
|
||||
* - CLIENT_ERROR — an identical request gets an identical answer.
|
||||
* - MALFORMED — same bytes, same parse failure.
|
||||
*/
|
||||
fun isRetryable(kind: FailureKind): Boolean =
|
||||
kind == FailureKind.TRANSPORT || kind == FailureKind.SERVER_ERROR
|
||||
|
||||
/**
|
||||
* Backoff before attempt number [nextAttempt] (2-based: the wait before the
|
||||
* first retry is `backoffMillis(2)`). 250ms then 750ms, plus up to 40% jitter
|
||||
* so that two sources — or two phones in the same house — cannot fall into
|
||||
* lockstep and hammer a recovering server in unison.
|
||||
*/
|
||||
fun backoffMillis(nextAttempt: Int, random: Random = Random.Default): Long {
|
||||
val base = when (nextAttempt) {
|
||||
2 -> 250L
|
||||
else -> 750L
|
||||
}
|
||||
return base + random.nextLong(0, (base * 0.4).toLong().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [attempt] until it succeeds, fails un-retryably, or runs out of attempts or
|
||||
* budget. Returns the LAST result, so the caller always sees a real outcome rather
|
||||
* than a synthesised one.
|
||||
*
|
||||
* A [SourceResult.Failed] that survives retrying has its attempt count appended to
|
||||
* [SourceResult.Failed.reason] ("tls connection reset, 3 attempts"). That string is
|
||||
* shown to the user on the scan sheet and is the only diagnostic we get back from a
|
||||
* real phone — "failed once" and "failed three times in a row" are very different
|
||||
* stories about the network, and without this they are indistinguishable.
|
||||
*
|
||||
* [sleep] and [nowMillis] are injectable purely so tests can run the real policy
|
||||
* with no wall-clock delay; production callers use the defaults.
|
||||
*/
|
||||
suspend fun withRetry(
|
||||
maxAttempts: Int = RetryPolicy.MAX_ATTEMPTS,
|
||||
budgetMillis: Long = RetryPolicy.TOTAL_BUDGET_MILLIS,
|
||||
random: Random = Random.Default,
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
attempt: suspend () -> SourceResult,
|
||||
): SourceResult {
|
||||
val started = nowMillis()
|
||||
var last: SourceResult = attempt()
|
||||
var attemptsMade = 1
|
||||
|
||||
while (attemptsMade < maxAttempts) {
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
if (!RetryPolicy.isRetryable(failure.kind)) break
|
||||
if (nowMillis() - started >= budgetMillis) break
|
||||
|
||||
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
|
||||
last = attempt()
|
||||
attemptsMade++
|
||||
}
|
||||
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure
|
||||
}
|
||||
@@ -1,5 +1,39 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
/**
|
||||
* Why a source failed, in the only terms that matter to a caller: is it worth
|
||||
* asking again? [RetryPolicy] is the single place that decides, so the answer
|
||||
* cannot drift between the two clients.
|
||||
*/
|
||||
enum class FailureKind {
|
||||
/** Never got an answer: DNS, refused connection, reset socket, failed TLS handshake. */
|
||||
TRANSPORT,
|
||||
|
||||
/** We gave up waiting. Distinct from [TRANSPORT] because the budget is already spent. */
|
||||
TIMEOUT,
|
||||
|
||||
/** HTTP 429. The source is telling us to stop asking; asking harder is the wrong move. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** HTTP 5xx — the source's problem, and usually a passing one. */
|
||||
SERVER_ERROR,
|
||||
|
||||
/** HTTP 4xx other than 429. Repeating an identical request cannot change the answer. */
|
||||
CLIENT_ERROR,
|
||||
|
||||
/** 2xx whose body we could not parse. Deterministic: the same bytes will fail again. */
|
||||
MALFORMED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-source lookup outcome (SPEC.md "Book metadata lookup": "Lookup outcome is
|
||||
* THREE-WAY, never a bare null"). [OpenLibraryClient] and [GoogleBooksClient] each
|
||||
@@ -14,10 +48,54 @@ sealed interface SourceResult {
|
||||
data object NotFound : SourceResult
|
||||
|
||||
/**
|
||||
* The source could not be asked, or its answer couldn't be trusted: non-2xx,
|
||||
* timeout, transport error, or a body that didn't parse. [reason] is a short
|
||||
* diagnostic ("http 429", "timeout", "malformed json") for logs — never shown
|
||||
* to the user verbatim.
|
||||
* The source could not be asked, or its answer couldn't be trusted. [reason] is
|
||||
* a short diagnostic ("http 429", "tls reset", "malformed json") that is shown
|
||||
* to the user as supplementary detail on the scan sheet and is our ONLY
|
||||
* diagnostic channel from a real phone — so it names the specific failure, not
|
||||
* a generic one. [kind] is the same fact in a form [RetryPolicy] can act on;
|
||||
* nothing should ever parse [reason] to recover it.
|
||||
*/
|
||||
data class Failed(val reason: String) : SourceResult
|
||||
data class Failed(val reason: String, val kind: FailureKind = FailureKind.TRANSPORT) : SourceResult
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Maps a thrown [IOException] to a specific failure. Both clients call this
|
||||
* so they can never disagree, and so a new exception type gets classified
|
||||
* once rather than twice.
|
||||
*
|
||||
* Measured against the live Open Library API on 2026-09-09 (30 requests):
|
||||
* every observed failure was a TLS-stage `Connection reset by peer`, and all
|
||||
* four came back in under 2.5s while successful requests took a median of
|
||||
* 4.3s. Failures are cheap and fast; that asymmetry is the whole reason
|
||||
* retrying is worth doing, and why the timeouts are set as generously as
|
||||
* they are in `AppContainer.metadataHttpClient`.
|
||||
*
|
||||
* Order matters: [SocketTimeoutException] is an [InterruptedIOException] and
|
||||
* [SSLHandshakeException] is an [SSLException], so the specific arms come
|
||||
* first. OkHttp reports a blown `callTimeout` as a bare
|
||||
* [InterruptedIOException], which is why that arm exists at all.
|
||||
*/
|
||||
fun fromException(e: IOException): Failed = when (e) {
|
||||
is SocketTimeoutException -> Failed("timeout", FailureKind.TIMEOUT)
|
||||
is InterruptedIOException -> Failed("timeout", FailureKind.TIMEOUT)
|
||||
is UnknownHostException -> Failed("dns lookup failed", FailureKind.TRANSPORT)
|
||||
is SSLHandshakeException -> Failed("tls handshake failed", FailureKind.TRANSPORT)
|
||||
is SSLException -> Failed("tls connection reset", FailureKind.TRANSPORT)
|
||||
is ConnectException -> Failed("connection refused", FailureKind.TRANSPORT)
|
||||
is SocketException -> Failed("connection reset", FailureKind.TRANSPORT)
|
||||
else -> Failed("network error (${e.javaClass.simpleName})", FailureKind.TRANSPORT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a non-2xx HTTP status to a specific failure. 429 is called out
|
||||
* separately from the rest of 4xx because it is the one client error that is
|
||||
* about us rather than about the request, and because it is currently
|
||||
* Google Books' permanent state — see docs/METADATA-SOURCES.md.
|
||||
*/
|
||||
fun fromHttpCode(code: Int): Failed = when {
|
||||
code == 429 -> Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED)
|
||||
code in 500..599 -> Failed("http $code (server error)", FailureKind.SERVER_ERROR)
|
||||
else -> Failed("http $code", FailureKind.CLIENT_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class SettingsStore(private val context: Context) {
|
||||
val USER_ID = stringPreferencesKey("user_id")
|
||||
val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
|
||||
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id")
|
||||
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
|
||||
}
|
||||
|
||||
@@ -35,6 +36,13 @@ class SettingsStore(private val context: Context) {
|
||||
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
|
||||
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
|
||||
|
||||
/**
|
||||
* The shelf most recently assigned to a book — SPEC "remember the most recently
|
||||
* used shelf" (shelving a box of books usually means one shelf, over and over).
|
||||
* Never written for "Not shelved"; see [SettingsStore] callers.
|
||||
*/
|
||||
val lastShelfId: Flow<String?> = context.dataStore.data.map { it[Keys.LAST_SHELF_ID] }
|
||||
|
||||
fun cursorFor(collection: String): Flow<String?> =
|
||||
context.dataStore.data.map { it[Keys.cursor(collection)] }
|
||||
|
||||
@@ -62,12 +70,25 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
|
||||
}
|
||||
|
||||
/** Sign out: drop the token/user identity but keep the server URL — no need to re-enter it. */
|
||||
suspend fun setLastShelfId(shelfId: String) {
|
||||
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId }
|
||||
}
|
||||
|
||||
suspend fun clearLastShelfId() {
|
||||
context.dataStore.edit { it.remove(Keys.LAST_SHELF_ID) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out: drop the token/user identity but keep the server URL — no need to
|
||||
* re-enter it. Also drops the remembered shelf: a shared library's other
|
||||
* account shouldn't have its shelving habit leak into this one's session.
|
||||
*/
|
||||
suspend fun clearAuth() {
|
||||
context.dataStore.edit {
|
||||
it.remove(Keys.AUTH_TOKEN)
|
||||
it.remove(Keys.USER_ID)
|
||||
it.remove(Keys.USER_EMAIL)
|
||||
it.remove(Keys.LAST_SHELF_ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package org.modg.bookshelf.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
|
||||
/**
|
||||
* The grouped shelf picker shared by the scan screen's save sheet and the
|
||||
* detail screen's location picker. Replaces the old flat "Bookcase • Shelf"
|
||||
* [androidx.compose.material3.DropdownMenu] — which stopped being usable once
|
||||
* a library had more than a couple of bookcases — with a scrolling
|
||||
* [ModalBottomSheet] grouped one section per bookcase. A bookcase is not
|
||||
* itself a place a book can sit, so its header is a non-selectable label;
|
||||
* only the shelves listed under it are choices.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShelfPickerSheet(
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
selectedShelfId: String?,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismissRequest, sheetState = rememberModalBottomSheetState()) {
|
||||
ShelfPickerContent(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = { shelfId ->
|
||||
onShelfSelected(shelfId)
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's contents, split out from [ShelfPickerSheet] so it can be rendered
|
||||
* directly (Paparazzi has no real Window/scrim behind a headless [ModalBottomSheet],
|
||||
* same problem [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc
|
||||
* describes for the camera preview). Internal rather than private so that test can
|
||||
* reach it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShelfPickerContent(
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
selectedShelfId: String?,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
) {
|
||||
val recent = resolveRecentShelf(recentShelfId, shelves, bookcases)
|
||||
val recentShelf = recent?.first
|
||||
val recentBookcase = recent?.second
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Omitted entirely when there is no remembered shelf, or it no longer exists
|
||||
// (deleted since it was last used) — a dangling "Recent" entry would be worse
|
||||
// than no shortcut at all.
|
||||
if (recentShelf != null && recentBookcase != null) {
|
||||
SectionHeader(text = "Recent")
|
||||
ShelfRow(
|
||||
label = "${recentBookcase.name} • ${recentShelf.label}",
|
||||
selected = selectedShelfId == recentShelf.id,
|
||||
onClick = { onShelfSelected(recentShelf.id) },
|
||||
)
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
}
|
||||
|
||||
ShelfRow(
|
||||
label = "Not shelved",
|
||||
selected = selectedShelfId == null,
|
||||
onClick = { onShelfSelected(null) },
|
||||
)
|
||||
|
||||
bookcases.sortedBy { it.position }.forEach { bookcase ->
|
||||
GoldDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
SectionHeader(text = bookcase.name)
|
||||
val bookcaseShelves = shelves.filter { it.bookcaseId == bookcase.id }.sortedBy { it.position }
|
||||
if (bookcaseShelves.isEmpty()) {
|
||||
// So an empty bookcase reads as "no shelves yet", not a rendering bug.
|
||||
Text(
|
||||
text = "No shelves yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
bookcaseShelves.forEach { shelf ->
|
||||
ShelfRow(
|
||||
label = shelf.label,
|
||||
selected = selectedShelfId == shelf.id,
|
||||
onClick = { onShelfSelected(shelf.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShelfRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves [recentShelfId] to the shelf + bookcase it names, or null when there is
|
||||
* nothing remembered, or the remembered shelf was deleted since — the two cases the
|
||||
* "Recent" section must be omitted for. Pure so it's directly unit-testable without
|
||||
* standing up Compose.
|
||||
*/
|
||||
internal fun resolveRecentShelf(
|
||||
recentShelfId: String?,
|
||||
shelves: List<ShelfEntity>,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
): Pair<ShelfEntity, BookcaseEntity>? {
|
||||
val shelf = recentShelfId?.let { id -> shelves.find { it.id == id } } ?: return null
|
||||
val bookcase = bookcases.find { it.id == shelf.bookcaseId } ?: return null
|
||||
return shelf to bookcase
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -56,6 +54,7 @@ import org.modg.bookshelf.ui.components.BookCover
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.ShelfPickerSheet
|
||||
|
||||
/**
|
||||
* SPEC.md "detail" screen. [book] filters `deleted = 0`, so once the user
|
||||
@@ -76,6 +75,7 @@ fun DetailScreen(
|
||||
DetailViewModel(
|
||||
bookRepository = container.bookRepository,
|
||||
locationRepository = container.locationRepository,
|
||||
settingsStore = container.settingsStore,
|
||||
bookId = bookId,
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ fun DetailScreen(
|
||||
val book by viewModel.book.collectAsState()
|
||||
val bookcases by viewModel.bookcases.collectAsState()
|
||||
val shelves by viewModel.shelves.collectAsState()
|
||||
val recentShelfId by viewModel.recentShelfId.collectAsState()
|
||||
|
||||
var retainedBook by remember { mutableStateOf<BookEntity?>(null) }
|
||||
LaunchedEffect(book) { book?.let { retainedBook = it } }
|
||||
@@ -163,6 +164,7 @@ fun DetailScreen(
|
||||
book = display,
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = viewModel::saveLocation,
|
||||
)
|
||||
}
|
||||
@@ -271,9 +273,10 @@ internal fun LocationSection(
|
||||
book: BookEntity,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var pickerOpen by remember { mutableStateOf(false) }
|
||||
val currentShelf = shelves.find { it.id == book.shelfId }
|
||||
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
|
||||
val label = if (currentShelf != null && currentBookcase != null) {
|
||||
@@ -291,25 +294,20 @@ internal fun LocationSection(
|
||||
) {
|
||||
Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
TextButton(onClick = { menuExpanded = true }) { Text("Change") }
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Not shelved") },
|
||||
onClick = { onShelfSelected(null); menuExpanded = false },
|
||||
)
|
||||
bookcases.forEach { bookcase ->
|
||||
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${bookcase.name} • ${shelf.label}") },
|
||||
onClick = { onShelfSelected(shelf.id); menuExpanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { pickerOpen = true }) { Text("Change") }
|
||||
}
|
||||
}
|
||||
|
||||
if (pickerOpen) {
|
||||
ShelfPickerSheet(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = book.shelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
onDismissRequest = { pickerOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
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.data.repo.decodeAuthors
|
||||
@@ -44,6 +45,7 @@ data class BookEditForm(
|
||||
class DetailViewModel(
|
||||
private val bookRepository: BookRepository,
|
||||
private val locationRepository: LocationRepository,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val bookId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -61,6 +63,10 @@ class DetailViewModel(
|
||||
val shelves: StateFlow<List<ShelfEntity>> = locationRepository.observeShelves()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
|
||||
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
fun saveNotes(notes: String) {
|
||||
val current = book.value ?: return
|
||||
viewModelScope.launch { bookRepository.save(current.copy(notes = notes.ifBlank { null })) }
|
||||
@@ -68,7 +74,17 @@ class DetailViewModel(
|
||||
|
||||
fun saveLocation(shelfId: String?) {
|
||||
val current = book.value ?: return
|
||||
viewModelScope.launch { bookRepository.save(current.copy(shelfId = shelfId)) }
|
||||
viewModelScope.launch { performSaveLocation(current, shelfId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual save-location logic, split out from [saveLocation] so tests can await it
|
||||
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
|
||||
*/
|
||||
internal suspend fun performSaveLocation(current: BookEntity, shelfId: String?) {
|
||||
bookRepository.save(current.copy(shelfId = shelfId))
|
||||
// "Not shelved" (null) must never overwrite the memory — it isn't a shelf.
|
||||
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
|
||||
}
|
||||
|
||||
fun saveEdit(form: BookEditForm) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Sort
|
||||
import androidx.compose.material.icons.outlined.Warehouse
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -37,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -44,6 +44,7 @@ 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.R
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
@@ -97,7 +98,7 @@ fun LibraryScreen(
|
||||
title = "Bookshelf",
|
||||
actions = {
|
||||
IconButton(onClick = onLocationsClick) {
|
||||
Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves")
|
||||
Icon(painterResource(R.drawable.ic_shelves), contentDescription = "Bookcases & shelves")
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Outlined.Settings, contentDescription = "Settings")
|
||||
@@ -202,20 +203,28 @@ internal fun LibraryToolbar(
|
||||
Icon(Icons.Outlined.FilterList, contentDescription = "Filter by bookcase or shelf")
|
||||
}
|
||||
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("All books") },
|
||||
onClick = { onFilterChange(LibraryFilter.All); filterMenuExpanded = false },
|
||||
)
|
||||
bookcases.forEach { bookcase ->
|
||||
if (bookcases.isEmpty() && shelves.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(bookcase.name, style = MaterialTheme.typography.titleSmall) },
|
||||
onClick = { onFilterChange(LibraryFilter.Bookcase(bookcase.id)); filterMenuExpanded = false },
|
||||
text = { Text("Add a bookcase to enable filtering") },
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("All books") },
|
||||
onClick = { onFilterChange(LibraryFilter.All); filterMenuExpanded = false },
|
||||
)
|
||||
bookcases.forEach { bookcase ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(" ${shelf.label}") },
|
||||
onClick = { onFilterChange(LibraryFilter.Shelf(shelf.id)); filterMenuExpanded = false },
|
||||
text = { Text(bookcase.name, style = MaterialTheme.typography.titleSmall) },
|
||||
onClick = { onFilterChange(LibraryFilter.Bookcase(bookcase.id)); filterMenuExpanded = false },
|
||||
)
|
||||
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(" ${shelf.label}") },
|
||||
onClick = { onFilterChange(LibraryFilter.Shelf(shelf.id)); filterMenuExpanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,15 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -94,7 +97,12 @@ fun LocationsScreen(
|
||||
action = { PrimaryButton(text = "Add a bookcase", onClick = viewModel::openAddBookcase) },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = innerPadding.calculateTopPadding(),
|
||||
bottom = innerPadding.calculateBottomPadding() + 96.dp,
|
||||
),
|
||||
) {
|
||||
items(state.bookcases, key = { it.bookcase.id }) { bookcaseUi ->
|
||||
BookcaseRow(
|
||||
bookcaseUi = bookcaseUi,
|
||||
@@ -268,12 +276,22 @@ private fun BookcaseEditDialog(
|
||||
) {
|
||||
var name by remember { mutableStateOf(editing?.name.orEmpty()) }
|
||||
var note by remember { mutableStateOf(editing?.note.orEmpty()) }
|
||||
val nameFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { nameFocusRequester.requestFocus() }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.focusRequester(nameFocusRequester),
|
||||
)
|
||||
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Note (optional)") }, singleLine = true)
|
||||
}
|
||||
},
|
||||
@@ -292,11 +310,21 @@ private fun ShelfEditDialog(
|
||||
onSubmit: (label: String) -> Unit,
|
||||
) {
|
||||
var label by remember { mutableStateOf(editing?.label.orEmpty()) }
|
||||
val labelFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { labelFocusRequester.requestFocus() }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = title, style = MaterialTheme.typography.titleLarge) },
|
||||
text = {
|
||||
OutlinedTextField(value = label, onValueChange = { label = it }, label = { Text("Label") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
label = { Text("Label") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.focusRequester(labelFocusRequester),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSubmit(label) }, enabled = label.isNotBlank()) { Text("Save") }
|
||||
|
||||
@@ -44,7 +44,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -66,6 +69,7 @@ import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.ShelfPickerSheet
|
||||
|
||||
/**
|
||||
* SPEC.md "scan" screen: camera + reticle, on-hit bottom sheet, continuous
|
||||
@@ -85,6 +89,7 @@ fun ScanScreen(
|
||||
bookRepository = container.bookRepository,
|
||||
locationRepository = container.locationRepository,
|
||||
metadataRepository = container.metadataRepository,
|
||||
settingsStore = container.settingsStore,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -97,6 +102,7 @@ fun ScanScreen(
|
||||
val bookcases by viewModel.bookcases.collectAsState()
|
||||
val shelves by viewModel.shelves.collectAsState()
|
||||
val selectedShelfId by viewModel.selectedShelfId.collectAsState()
|
||||
val recentShelfId by viewModel.recentShelfId.collectAsState()
|
||||
|
||||
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -159,6 +165,7 @@ fun ScanScreen(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = viewModel::selectShelf,
|
||||
onSave = { viewModel.save(state.isbn13, state.metadata) },
|
||||
onSkip = { viewModel.skip() },
|
||||
@@ -174,6 +181,7 @@ fun ScanScreen(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = viewModel::selectShelf,
|
||||
onSave = { title, authors -> viewModel.saveManualEntry(state.isbn13, title, authors) },
|
||||
onSkip = { viewModel.skip() },
|
||||
@@ -185,6 +193,7 @@ fun ScanScreen(
|
||||
) {
|
||||
LookupFailedSheet(
|
||||
isbn13 = state.isbn13,
|
||||
reason = state.reason,
|
||||
onRetry = { viewModel.retryLookup(state.isbn13) },
|
||||
onEnterByHand = { viewModel.enterByHand(state.isbn13) },
|
||||
onSkip = { viewModel.skip() },
|
||||
@@ -260,9 +269,8 @@ 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.
|
||||
* the ISBN it read, because a lone spinner reads as "still working on it" — echoing
|
||||
* the number back is what tells the user the barcode was actually recognised.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SearchingSheet(isbn13: String) {
|
||||
@@ -283,12 +291,6 @@ internal fun SearchingSheet(isbn13: String) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +323,7 @@ internal fun FoundBookSheet(
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
selectedShelfId: String?,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
@@ -353,6 +356,7 @@ internal fun FoundBookSheet(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -375,6 +379,7 @@ internal fun ManualEntrySheet(
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
selectedShelfId: String?,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onSave: (title: String, authors: List<String>) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
@@ -409,6 +414,7 @@ internal fun ManualEntrySheet(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -429,11 +435,15 @@ internal fun ManualEntrySheet(
|
||||
* Shown when a lookup couldn't be completed — SPEC: "a retry affordance, with
|
||||
* manual entry as the escape hatch; it must NOT claim the book is unknown." No
|
||||
* shelf picker here: saving isn't offered from this sheet, only a path onward to
|
||||
* one that does (Retry, or the manual-entry form via "Enter by hand").
|
||||
* one that does (Retry, or the manual-entry form via "Enter by hand"). [reason]
|
||||
* is [MetadataRepository]'s diagnostic string (e.g. "open library: network error;
|
||||
* google books: http 429") — the only channel we have back from a lookup failure
|
||||
* on a real phone, so it must actually reach the screen instead of being dropped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LookupFailedSheet(
|
||||
isbn13: String,
|
||||
reason: String,
|
||||
onRetry: () -> Unit,
|
||||
onEnterByHand: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
@@ -445,7 +455,13 @@ internal fun LookupFailedSheet(
|
||||
"This doesn't mean the book is unknown.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||
)
|
||||
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.fillMaxWidth())
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -478,9 +494,10 @@ internal fun ShelfPicker(
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
selectedShelfId: String?,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var pickerOpen by remember { mutableStateOf(false) }
|
||||
val currentShelf = shelves.find { it.id == selectedShelfId }
|
||||
val currentBookcase = currentShelf?.let { shelf -> bookcases.find { it.id == shelf.bookcaseId } }
|
||||
val label = if (currentShelf != null && currentBookcase != null) {
|
||||
@@ -490,21 +507,18 @@ internal fun ShelfPicker(
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.padding(top = 12.dp)) {
|
||||
SecondaryButton(text = label, onClick = { expanded = true })
|
||||
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text("Not shelved") },
|
||||
onClick = { onShelfSelected(null); expanded = false },
|
||||
)
|
||||
bookcases.forEach { bookcase ->
|
||||
shelves.filter { it.bookcaseId == bookcase.id }.forEach { shelf ->
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text("${bookcase.name} • ${shelf.label}") },
|
||||
onClick = { onShelfSelected(shelf.id); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SecondaryButton(text = label, onClick = { pickerOpen = true })
|
||||
}
|
||||
|
||||
if (pickerOpen) {
|
||||
ShelfPickerSheet(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
onDismissRequest = { pickerOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +531,14 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
|
||||
// screen was just rebuilt to eliminate.
|
||||
val isbn13 = remember(text) { IsbnUtils.toIsbn13(text) }
|
||||
val malformed = text.isNotBlank() && isbn13 == null
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
// The dialog opens with the field unfocused otherwise — this is the only entry
|
||||
// point into the field, so make it ready to type into immediately.
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
keyboardController?.show()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Enter ISBN") },
|
||||
@@ -529,6 +551,7 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit)
|
||||
singleLine = true,
|
||||
isError = malformed,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
if (malformed) {
|
||||
Text(
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
|
||||
@@ -28,6 +29,7 @@ class ScanViewModel(
|
||||
private val bookRepository: BookRepository,
|
||||
locationRepository: LocationRepository,
|
||||
private val metadataRepository: MetadataRepository,
|
||||
private val settingsStore: SettingsStore,
|
||||
val scannerController: ScannerController = ScannerController(),
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -51,6 +53,10 @@ class ScanViewModel(
|
||||
private val _selectedShelfId = MutableStateFlow<String?>(null)
|
||||
val selectedShelfId: StateFlow<String?> = _selectedShelfId.asStateFlow()
|
||||
|
||||
/** The shelf most recently assigned to any book, across sessions — surfaced as the picker's "Recent" shortcut. */
|
||||
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
scannerController.scanResults.collect { isbn13 -> onScanned(isbn13) }
|
||||
@@ -104,35 +110,53 @@ class ScanViewModel(
|
||||
|
||||
/** Save from a successful metadata lookup. */
|
||||
fun save(isbn13: String, metadata: BookMetadata) {
|
||||
viewModelScope.launch {
|
||||
bookRepository.createBook(
|
||||
title = metadata.title ?: "Untitled",
|
||||
subtitle = metadata.subtitle,
|
||||
authors = metadata.authors,
|
||||
isbn13 = metadata.isbn13 ?: isbn13,
|
||||
isbn10 = metadata.isbn10,
|
||||
publisher = metadata.publisher,
|
||||
publishedDate = metadata.publishedDate,
|
||||
pageCount = metadata.pageCount,
|
||||
description = metadata.description,
|
||||
coverSourceUrl = metadata.coverUrl,
|
||||
shelfId = _selectedShelfId.value,
|
||||
)
|
||||
recordSave()
|
||||
}
|
||||
viewModelScope.launch { performSave(isbn13, metadata) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual save-from-metadata logic, split out from [save] so tests can await it
|
||||
* directly (as a plain suspend call) instead of racing [viewModelScope]'s launch.
|
||||
*/
|
||||
internal suspend fun performSave(isbn13: String, metadata: BookMetadata) {
|
||||
val shelfId = _selectedShelfId.value
|
||||
bookRepository.createBook(
|
||||
title = metadata.title ?: "Untitled",
|
||||
subtitle = metadata.subtitle,
|
||||
authors = metadata.authors,
|
||||
isbn13 = metadata.isbn13 ?: isbn13,
|
||||
isbn10 = metadata.isbn10,
|
||||
publisher = metadata.publisher,
|
||||
publishedDate = metadata.publishedDate,
|
||||
pageCount = metadata.pageCount,
|
||||
description = metadata.description,
|
||||
coverSourceUrl = metadata.coverUrl,
|
||||
shelfId = shelfId,
|
||||
)
|
||||
rememberShelf(shelfId)
|
||||
recordSave()
|
||||
}
|
||||
|
||||
/** Save from the manual-entry form shown when metadata lookup misses (SPEC: pre-filled with the scanned ISBN). */
|
||||
fun saveManualEntry(isbn13: String, title: String, authors: List<String>) {
|
||||
viewModelScope.launch {
|
||||
bookRepository.createBook(
|
||||
title = title.ifBlank { "Untitled" },
|
||||
authors = authors,
|
||||
isbn13 = isbn13,
|
||||
shelfId = _selectedShelfId.value,
|
||||
)
|
||||
recordSave()
|
||||
}
|
||||
viewModelScope.launch { performSaveManualEntry(isbn13, title, authors) }
|
||||
}
|
||||
|
||||
/** Same split as [performSave], for [saveManualEntry]. */
|
||||
internal suspend fun performSaveManualEntry(isbn13: String, title: String, authors: List<String>) {
|
||||
val shelfId = _selectedShelfId.value
|
||||
bookRepository.createBook(
|
||||
title = title.ifBlank { "Untitled" },
|
||||
authors = authors,
|
||||
isbn13 = isbn13,
|
||||
shelfId = shelfId,
|
||||
)
|
||||
rememberShelf(shelfId)
|
||||
recordSave()
|
||||
}
|
||||
|
||||
/** "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)
|
||||
}
|
||||
|
||||
private fun recordSave() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material Symbols "shelves" (Apache 2.0). Source viewBox is
|
||||
"0 -960 960 960"; Android has no viewport origin, so the group
|
||||
translate is load-bearing. Do not flatten it. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M120-40v-880h80v80h560v-80h80v880h-80v-80H200v80h-80Zm80-480h80v-160h240v160h240v-240H200v240Zm0 320h240v-160h240v160h80v-240H200v240Zm160-320h80v-80h-80v80Zm160 320h80v-80h-80v80Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -66,25 +66,25 @@ class GoogleBooksClientTest {
|
||||
@Test
|
||||
fun `classify reports Failed for a 404`() {
|
||||
val result = client.classify(404, null)
|
||||
assertEquals(SourceResult.Failed("http 404"), result)
|
||||
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 429 keyless-quota response, distinctly from NotFound`() {
|
||||
val result = client.classify(429, null)
|
||||
assertEquals(SourceResult.Failed("http 429"), result)
|
||||
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 500`() {
|
||||
val result = client.classify(500, "Internal Server Error")
|
||||
assertEquals(SourceResult.Failed("http 500"), result)
|
||||
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a malformed body even on a 2xx status`() {
|
||||
val result = client.classify(200, fixture("malformed.json"))
|
||||
assertEquals(SourceResult.Failed("malformed json"), result)
|
||||
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
|
||||
@@ -125,25 +125,25 @@ class OpenLibraryClientTest {
|
||||
@Test
|
||||
fun `classify reports Failed for a 404`() {
|
||||
val result = client.classify(404, null, "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 404"), result)
|
||||
assertEquals(SourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 429, distinctly from NotFound`() {
|
||||
val result = client.classify(429, null, "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 429"), result)
|
||||
assertEquals(SourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a 500`() {
|
||||
val result = client.classify(500, "Internal Server Error", "9780201558029")
|
||||
assertEquals(SourceResult.Failed("http 500"), result)
|
||||
assertEquals(SourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a malformed body even on a 2xx status`() {
|
||||
val result = client.classify(200, fixture("malformed.json"), "9780201558029")
|
||||
assertEquals(SourceResult.Failed("malformed json"), result)
|
||||
assertEquals(SourceResult.Failed("malformed json", FailureKind.MALFORMED), result)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Covers the retry decision and the loop that acts on it. Every case is driven by
|
||||
* the 2026-09-09 measurement of the live Open Library API recorded in
|
||||
* docs/METADATA-SOURCES.md: fast transient TLS resets (worth repeating) against a
|
||||
* slow but usually-successful long tail (not worth repeating).
|
||||
*
|
||||
* The loop is exercised with an injected clock and sleep, so these assert the real
|
||||
* policy constants with no wall-clock time and no flakiness.
|
||||
*/
|
||||
class RetryPolicyTest {
|
||||
|
||||
private val transport = SourceResult.Failed("tls connection reset", FailureKind.TRANSPORT)
|
||||
private val found = SourceResult.Found(BookMetadata(title = "Dune"))
|
||||
|
||||
// --- which failures are worth repeating ---
|
||||
|
||||
@Test
|
||||
fun `transport and server errors are retryable`() {
|
||||
assertTrue(RetryPolicy.isRetryable(FailureKind.TRANSPORT))
|
||||
assertTrue(RetryPolicy.isRetryable(FailureKind.SERVER_ERROR))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timeout is not retried -- its budget is already spent`() {
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.TIMEOUT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rate limit is not retried -- hammering a quota is how a block becomes permanent`() {
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.RATE_LIMITED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deterministic failures are not retried`() {
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.CLIENT_ERROR))
|
||||
assertFalse(RetryPolicy.isRetryable(FailureKind.MALFORMED))
|
||||
}
|
||||
|
||||
// --- the loop ---
|
||||
|
||||
@Test
|
||||
fun `a first-attempt success is returned without retrying`() = runTest {
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) { calls++; found }
|
||||
assertEquals(found, result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient failure followed by success returns the success`() = runTest {
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) {
|
||||
calls++
|
||||
if (calls == 1) transport else found
|
||||
}
|
||||
assertEquals(found, result)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying stops at MAX_ATTEMPTS and reports how many were made`() = runTest {
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) { calls++; transport }
|
||||
assertEquals(RetryPolicy.MAX_ATTEMPTS, calls)
|
||||
// The attempt count is the whole diagnostic value of the string on a real
|
||||
// phone: one reset and three in a row are different network stories.
|
||||
assertEquals(
|
||||
SourceResult.Failed("tls connection reset, 3 attempts", FailureKind.TRANSPORT),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single failure is reported without an attempt count`() = runTest {
|
||||
val timeout = SourceResult.Failed("timeout", FailureKind.TIMEOUT)
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) { calls++; timeout }
|
||||
assertEquals(1, calls)
|
||||
assertEquals(timeout, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NotFound is authoritative and is never retried`() = runTest {
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) { calls++; SourceResult.NotFound }
|
||||
assertEquals(SourceResult.NotFound, result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an un-retryable failure short-circuits after one attempt`() = runTest {
|
||||
var calls = 0
|
||||
val result = withRetry(sleep = {}) { calls++; SourceResult.fromHttpCode(429) }
|
||||
assertEquals(1, calls)
|
||||
assertEquals(FailureKind.RATE_LIMITED, (result as SourceResult.Failed).kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the budget stops a new attempt but never cancels one in flight`() = runTest {
|
||||
// Clock jumps past the budget during the first attempt. The result of that
|
||||
// attempt must still be honoured, and no second attempt may start.
|
||||
var now = 0L
|
||||
var calls = 0
|
||||
val result = withRetry(
|
||||
budgetMillis = 1_000L,
|
||||
nowMillis = { now },
|
||||
sleep = {},
|
||||
) {
|
||||
calls++
|
||||
now += 5_000L
|
||||
transport
|
||||
}
|
||||
assertEquals(1, calls)
|
||||
assertEquals(transport, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backoff is short and jittered, never zero and never seconds long`() {
|
||||
val r = Random(1234)
|
||||
repeat(200) {
|
||||
val first = RetryPolicy.backoffMillis(2, r)
|
||||
val second = RetryPolicy.backoffMillis(3, r)
|
||||
assertTrue("first retry backoff was $first", first in 250L..349L)
|
||||
assertTrue("second retry backoff was $second", second in 750L..1049L)
|
||||
}
|
||||
}
|
||||
|
||||
// --- exception and status classification ---
|
||||
|
||||
@Test
|
||||
fun `socket timeout and a blown call timeout both classify as TIMEOUT`() {
|
||||
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(SocketTimeoutException()).kind)
|
||||
// OkHttp reports an exceeded callTimeout as a bare InterruptedIOException.
|
||||
assertEquals(FailureKind.TIMEOUT, SourceResult.fromException(InterruptedIOException()).kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the observed live failure -- a TLS-stage reset -- classifies as retryable transport`() {
|
||||
val failed = SourceResult.fromException(SSLException("Connection reset by peer"))
|
||||
assertEquals(FailureKind.TRANSPORT, failed.kind)
|
||||
assertTrue(RetryPolicy.isRetryable(failed.kind))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each transport exception gets its own reason, not a generic one`() {
|
||||
// The reason string is the only diagnostic we get back from a real phone,
|
||||
// so these must stay distinguishable from each other.
|
||||
val reasons = listOf(
|
||||
SourceResult.fromException(UnknownHostException()).reason,
|
||||
SourceResult.fromException(SSLHandshakeException("h")).reason,
|
||||
SourceResult.fromException(SSLException("r")).reason,
|
||||
SourceResult.fromException(ConnectException()).reason,
|
||||
SourceResult.fromException(SocketException()).reason,
|
||||
)
|
||||
assertEquals(reasons.size, reasons.toSet().size)
|
||||
assertTrue(reasons.none { it.isBlank() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised IOException names its own type rather than saying network error`() {
|
||||
val failed = SourceResult.fromException(IOException("boom"))
|
||||
assertEquals(FailureKind.TRANSPORT, failed.kind)
|
||||
assertTrue(failed.reason, failed.reason.contains("IOException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `http statuses map to the kinds that drive retrying`() {
|
||||
assertEquals(FailureKind.RATE_LIMITED, SourceResult.fromHttpCode(429).kind)
|
||||
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(500).kind)
|
||||
assertEquals(FailureKind.SERVER_ERROR, SourceResult.fromHttpCode(503).kind)
|
||||
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(404).kind)
|
||||
assertEquals(FailureKind.CLIENT_ERROR, SourceResult.fromHttpCode(400).kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.modg.bookshelf.data.prefs
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Covers the "remember the most recently used shelf" feature's persistence layer —
|
||||
* see [org.modg.bookshelf.ui.scan.ScanViewModel] and
|
||||
* [org.modg.bookshelf.ui.detail.DetailViewModel] for the callers that decide *when*
|
||||
* to write, and [org.modg.bookshelf.ui.components.resolveRecentShelf] for how the
|
||||
* picker decides whether the remembered shelf is still offerable.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SettingsStoreTest {
|
||||
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
|
||||
// DataStore's backing file lives in the app's real files dir, which Robolectric
|
||||
// does not reset between test methods in this class — start every test from a
|
||||
// known-clean slate instead of depending on method execution order.
|
||||
settingsStore.clearLastShelfId()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `last shelf id is null before anything is remembered`() = runTest {
|
||||
assertNull(settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `last shelf id round-trips through the store`() = runTest {
|
||||
settingsStore.setLastShelfId("sh-top")
|
||||
|
||||
assertEquals("sh-top", settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting a new last shelf id overwrites the previous one`() = runTest {
|
||||
settingsStore.setLastShelfId("sh-top")
|
||||
settingsStore.setLastShelfId("sh-desk")
|
||||
|
||||
assertEquals("sh-desk", settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearLastShelfId drops the remembered shelf`() = runTest {
|
||||
settingsStore.setLastShelfId("sh-top")
|
||||
|
||||
settingsStore.clearLastShelfId()
|
||||
|
||||
assertNull(settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign out clears the remembered shelf so it can't leak to the other account`() = runTest {
|
||||
settingsStore.setLastShelfId("sh-top")
|
||||
|
||||
settingsStore.clearAuth()
|
||||
|
||||
assertNull(settingsStore.lastShelfId.first())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.modg.bookshelf.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
* [ShelfPickerSheet]'s grouped picker, replacing the old flat dropdown (SPEC
|
||||
* task: "the state the whole change exists for"). Renders [ShelfPickerContent]
|
||||
* directly rather than the real [androidx.compose.material3.ModalBottomSheet] —
|
||||
* Paparazzi has no real Window/scrim behind a headless sheet, same problem
|
||||
* [org.modg.bookshelf.ui.screens.ScanScreenPaparazziTest]'s class doc describes.
|
||||
* Two bookcases, one of them empty, plus a remembered "Recent" shelf — the
|
||||
* state this whole component exists for.
|
||||
*/
|
||||
class ShelfPickerSheetPaparazziTest {
|
||||
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||
|
||||
private val livingRoom = BookcaseEntity(
|
||||
id = "bc-living-room", name = "Living Room", note = null, position = 0,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
private val study = BookcaseEntity(
|
||||
id = "bc-study", name = "Study", note = null, position = 1,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
private val topShelf = ShelfEntity(
|
||||
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf — fiction", position = 0,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
private val bottomShelf = ShelfEntity(
|
||||
id = "sh-bottom", bookcaseId = livingRoom.id, label = "Bottom shelf — reference", position = 1,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun shelfPickerGroupedLight() = snapshotBoth("shelf-picker-grouped") {
|
||||
Content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content() = Shell {
|
||||
ShelfPickerContent(
|
||||
bookcases = listOf(livingRoom, study), // "study" has no shelves yet — empty-bookcase state
|
||||
shelves = listOf(topShelf, bottomShelf),
|
||||
selectedShelfId = bottomShelf.id,
|
||||
recentShelfId = topShelf.id, // recent shelf, distinct from the current selection
|
||||
onShelfSelected = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shell(content: @Composable () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.modg.bookshelf.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
|
||||
/**
|
||||
* [resolveRecentShelf] backs [ShelfPickerSheet]'s "Recent" section: it must be
|
||||
* omitted, not shown dangling, when there is nothing remembered or the
|
||||
* remembered shelf no longer exists (deleted since it was last used).
|
||||
*/
|
||||
class ShelfPickerSheetTest {
|
||||
|
||||
private val livingRoom = BookcaseEntity(
|
||||
id = "bc-living-room", name = "Living Room", note = null, position = 0,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
private val topShelf = ShelfEntity(
|
||||
id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf", position = 0,
|
||||
createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `no remembered shelf resolves to null`() {
|
||||
val result = resolveRecentShelf(recentShelfId = null, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a remembered shelf that no longer exists resolves to null`() {
|
||||
val result = resolveRecentShelf(recentShelfId = "sh-deleted", shelves = listOf(topShelf), bookcases = listOf(livingRoom))
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a remembered shelf whose bookcase was deleted resolves to null`() {
|
||||
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = emptyList())
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a remembered shelf that still exists resolves to its shelf and bookcase`() {
|
||||
val result = resolveRecentShelf(recentShelfId = topShelf.id, shelves = listOf(topShelf), bookcases = listOf(livingRoom))
|
||||
|
||||
assertEquals(topShelf to livingRoom, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.modg.bookshelf.ui.detail
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
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.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* SPEC's "remember the most recently used shelf" from the detail screen's side:
|
||||
* [DetailViewModel.saveLocation] must write a non-null shelf to [SettingsStore],
|
||||
* but "Not shelved" (null) must never overwrite what's already remembered.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DetailViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var viewModel: DetailViewModel
|
||||
private lateinit var book: BookEntity
|
||||
private lateinit var shelfId: String
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
val bookRepository = BookRepository(db.bookDao(), context)
|
||||
val locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
val bookId = bookRepository.createBook(title = "Piranesi")
|
||||
book = checkNotNull(bookRepository.getById(bookId))
|
||||
|
||||
viewModel = DetailViewModel(
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
settingsStore = settingsStore,
|
||||
bookId = bookId,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a book to a shelf remembers that shelf`() = runTest {
|
||||
viewModel.performSaveLocation(book, shelfId)
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marking a book Not shelved does not overwrite the remembered shelf`() = runTest {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
|
||||
viewModel.performSaveLocation(book, null)
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.modg.bookshelf.ui.scan
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* SPEC's "remember the most recently used shelf": [ScanViewModel.save] and
|
||||
* [ScanViewModel.saveManualEntry] must write the chosen shelf to [SettingsStore]
|
||||
* so it survives to the next scanning session — except "Not shelved" (null),
|
||||
* which must never overwrite what's already remembered.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ScanViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var viewModel: ScanViewModel
|
||||
private lateinit var locationRepository: LocationRepository
|
||||
private lateinit var shelfId: String
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
|
||||
viewModel = ScanViewModel(
|
||||
bookRepository = BookRepository(db.bookDao(), context),
|
||||
locationRepository = locationRepository,
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
settingsStore = settingsStore,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving a metadata hit to a shelf remembers that shelf`() = runTest {
|
||||
viewModel.selectShelf(shelfId)
|
||||
|
||||
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving a manual entry to a shelf remembers that shelf`() = runTest {
|
||||
viewModel.selectShelf(shelfId)
|
||||
|
||||
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", listOf("Brandon Sanderson"))
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
viewModel.selectShelf(null)
|
||||
|
||||
viewModel.performSave("9780765326355", BookMetadata(title = "The Way of Kings"))
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving a manual entry as Not shelved does not overwrite the remembered shelf`() = runTest {
|
||||
settingsStore.setLastShelfId(shelfId)
|
||||
viewModel.selectShelf(null)
|
||||
|
||||
viewModel.performSaveManualEntry("9780765326355", "The Way of Kings", emptyList())
|
||||
|
||||
assertEquals(shelfId, settingsStore.lastShelfId.first())
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ class DetailScreenPaparazziTest {
|
||||
book = book,
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
recentShelfId = null,
|
||||
onShelfSelected = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
@@ -40,12 +41,25 @@ class LocationsScreenPaparazziTest {
|
||||
@Test
|
||||
fun locationsPopulatedLight() = snapshotBoth("locations-populated") { Populated() }
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell()
|
||||
/**
|
||||
* Regression coverage for the ghost-bookcase bug: with the list branch not
|
||||
* folding in the Scaffold's top app bar inset, a single bookcase's row
|
||||
* rendered underneath the app bar and was invisible. This renders exactly
|
||||
* that one-bookcase state; check the recorded PNG shows the bookcase row
|
||||
* fully below the app bar, not clipped/hidden behind it.
|
||||
*/
|
||||
@Test
|
||||
fun locationsSingleBookcaseLight() = snapshotBoth("locations-single-bookcase") { SingleBookcase() }
|
||||
|
||||
@Composable
|
||||
private fun Shell() {
|
||||
val bookcaseUis = ScreenFixtures.bookcases.map { bookcase ->
|
||||
private fun Populated() = Shell(ScreenFixtures.bookcases)
|
||||
|
||||
@Composable
|
||||
private fun SingleBookcase() = Shell(listOf(ScreenFixtures.livingRoom))
|
||||
|
||||
@Composable
|
||||
private fun Shell(bookcases: List<BookcaseEntity>) {
|
||||
val bookcaseUis = bookcases.map { bookcase ->
|
||||
BookcaseUi(
|
||||
bookcase = bookcase,
|
||||
shelves = ScreenFixtures.shelves
|
||||
|
||||
@@ -96,6 +96,7 @@ class ScanScreenPaparazziTest {
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
selectedShelfId = ScreenFixtures.deskShelf.id,
|
||||
recentShelfId = null,
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onSkip = {},
|
||||
@@ -127,6 +128,7 @@ class ScanScreenPaparazziTest {
|
||||
) {
|
||||
LookupFailedSheet(
|
||||
isbn13 = "9780765326355",
|
||||
reason = "open library: network error; google books: http 429",
|
||||
onRetry = {},
|
||||
onEnterByHand = {},
|
||||
onSkip = {},
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 13 KiB |