Fix shelf legibility in dark mode; open books from shelf; offer to open on duplicate create
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
app/src/test/java/com/bm/pagescribe/ui/TextContrastTest.kt
Normal file
107
app/src/test/java/com/bm/pagescribe/ui/TextContrastTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user