Fix shelf legibility in dark mode; open books from shelf; offer to open on duplicate create

This commit is contained in:
2026-10-15 12:38:24 -07:00
parent 5b64d268f0
commit 45b3069b96
8 changed files with 155 additions and 5 deletions

View File

@@ -65,7 +65,7 @@ class MainActivity : AppCompatActivity() {
statusText = findViewById(R.id.ocrStatus)
emptyText = findViewById(R.id.emptyText)
adapter = BookAdapter { book -> startActivity(CaptureActivity.intent(this, book)) }
adapter = BookAdapter { book -> openBook(book) }
findViewById<RecyclerView>(R.id.bookList).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
@@ -124,15 +124,37 @@ class MainActivity : AppCompatActivity() {
return@setPositiveButton
}
try {
val book = repository.create(title)
startActivity(CaptureActivity.intent(this, book))
openBook(repository.create(title))
} catch (exists: BookExistsException) {
Toast.makeText(this, R.string.books_exists, Toast.LENGTH_SHORT).show()
offerToOpen(exists.message.orEmpty())
}
}
.show()
}
/** Tapping a book — or finishing "new book" — opens it for continued work. */
private fun openBook(book: Book) {
startActivity(BookActivity.intent(this, book))
}
/**
* A title we already have should not be a dead end: the user almost always
* means "open the book I started earlier", so offer exactly that.
*/
private fun offerToOpen(key: String) {
val existing = repository.find(key)
if (existing == null) {
Toast.makeText(this, R.string.books_exists, Toast.LENGTH_SHORT).show()
return
}
MaterialAlertDialogBuilder(this)
.setTitle(R.string.books_exists_title)
.setMessage(getString(R.string.books_exists_open, existing.title))
.setNegativeButton(R.string.books_cancel, null)
.setPositiveButton(R.string.books_open) { _, _ -> openBook(existing) }
.show()
}
private companion object {
const val POLL_INTERVAL = 1500L
}

View File

@@ -34,6 +34,7 @@
android:id="@+id/bookMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/ink_muted"
android:textSize="13sp" />
<LinearLayout
@@ -75,6 +76,7 @@
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/book_no_pages"
android:textColor="@color/ink_muted"
android:textSize="15sp"
android:visibility="gone" />
@@ -90,6 +92,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/book_log"
android:textColor="@color/ink"
android:textSize="12sp"
android:textStyle="bold" />
@@ -103,6 +106,7 @@
android:id="@+id/logText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/ink_muted"
android:textIsSelectable="true"
android:textSize="12sp" />
</ScrollView>

View File

@@ -39,6 +39,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ocr_idle"
android:textColor="@color/ink_muted"
android:textSize="13sp" />
<TextView
@@ -48,6 +49,7 @@
android:layout_marginTop="32dp"
android:gravity="center"
android:text="@string/books_empty"
android:textColor="@color/ink_muted"
android:textSize="16sp"
android:visibility="gone" />

View File

@@ -13,6 +13,7 @@
android:id="@+id/bookTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/ink"
android:textSize="17sp"
android:textStyle="bold" />
@@ -21,5 +22,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/ink_muted"
android:textSize="13sp" />
</LinearLayout>

View File

@@ -4,5 +4,6 @@
<color name="accent">#5B8DEF</color>
<color name="paper">#FBF7EF</color>
<color name="ink">#1B1B1B</color>
<color name="ink_muted">#5F5B54</color>
<color name="warn">#B3261E</color>
</resources>

View File

@@ -10,6 +10,9 @@
<string name="books_cancel">Cancel</string>
<string name="books_title_required">Enter a title for the book.</string>
<string name="books_exists">A book with that title already exists.</string>
<string name="books_exists_title">Book already exists</string>
<string name="books_exists_open">A book called “%1$s” already exists. Open it to carry on where you left off?</string>
<string name="books_open">Open</string>
<string name="ocr_idle">OCR queue idle.</string>
<string name="ocr_running">OCR running: %1$d queued, %2$d pages done.</string>

View File

@@ -4,10 +4,19 @@
AppCompat action bar is not inset correctly under the edge-to-edge layout
that targetSdk 36 enforces, so it would overlap the first row of content.
-->
<style name="Theme.PageScribe" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!--
The app is deliberately a *light* "paper" app: windowBackground is the
cream @color/paper in every mode. A DayNight parent would flip the
inherited text colour to white in dark mode, leaving white text on cream
paper — effectively invisible. Pin the parent to the Light theme so
inherited text stays dark whatever the system setting is.
-->
<style name="Theme.PageScribe" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorSecondary">@color/accent</item>
<item name="android:windowBackground">@color/paper</item>
<item name="android:textColorPrimary">@color/ink</item>
<item name="android:textColorSecondary">@color/ink_muted</item>
</style>
</resources>

View File

@@ -0,0 +1,107 @@
package com.bm.pagescribe.ui
import android.app.Application
import android.graphics.Color
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.bm.pagescribe.R
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* The app paints its screens on the cream @color/paper in every mode, so its
* text has to stay dark in every mode too. It once did not: the theme inherited
* from a DayNight parent, so in dark mode the default text colour flipped to
* white while the background stayed cream — white on cream, i.e. invisible rows.
* These tests run in **night** mode and read the colour each view actually ends
* up with, so that regression cannot sneak back in.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "night")
class TextContrastTest {
private val themed = ContextThemeWrapper(
RuntimeEnvironment.getApplication(),
R.style.Theme_PageScribe,
)
private fun inflate(layout: Int): View =
LayoutInflater.from(themed).inflate(layout, null)
@Test
fun `the theme keeps body text dark even in night mode`() {
val typed = themed.obtainStyledAttributes(intArrayOf(android.R.attr.textColorPrimary))
val color = typed.getColor(0, Color.WHITE)
typed.recycle()
assertLegible("theme textColorPrimary", color)
}
@Test
fun `shelf rows are legible on the paper background in night mode`() {
val row = inflate(R.layout.item_book)
val title = row.findViewById<TextView>(R.id.bookTitle).currentTextColor
val subtitle = row.findViewById<TextView>(R.id.bookSubtitle).currentTextColor
assertLegible("shelf title", title)
assertLegible("shelf subtitle", subtitle)
}
@Test
fun `shelf status and empty message are legible in night mode`() {
val screen = inflate(R.layout.activity_main)
val status = screen.findViewById<TextView>(R.id.ocrStatus).currentTextColor
val empty = screen.findViewById<TextView>(R.id.emptyText).currentTextColor
assertLegible("ocr status", status)
assertLegible("empty message", empty)
}
@Test
fun `book screen meta and log are legible in night mode`() {
val screen = inflate(R.layout.activity_book)
val meta = screen.findViewById<TextView>(R.id.bookMeta).currentTextColor
val log = screen.findViewById<TextView>(R.id.logText).currentTextColor
assertLegible("book meta", meta)
assertLegible("book log", log)
}
/** Fails unless [color] stands out from the paper background at WCAG AA. */
private fun assertLegible(what: String, color: Int) {
val paper = ContextCompat.getColor(themed, R.color.paper)
val ratio = contrastRatio(color, paper)
assertTrue(
"$what (#%08X) on paper (#%08X) has contrast %.2f:1, below the 4.5:1 needed to read it"
.format(color, paper, ratio),
ratio >= 4.5,
)
}
/** WCAG 2.1 relative-luminance contrast ratio. */
private fun contrastRatio(a: Int, b: Int): Double {
val la = relativeLuminance(a)
val lb = relativeLuminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
private fun relativeLuminance(color: Int): Double {
fun channel(value: Int): Double {
val s = value / 255.0
return if (s <= 0.03928) s / 12.92 else ((s + 0.055) / 1.055).pow(2.4)
}
return 0.2126 * channel(Color.red(color)) +
0.7152 * channel(Color.green(color)) +
0.0722 * channel(Color.blue(color))
}
}