From 188de1e8fdc6039e377be903ce4d1a14ee1c494b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:20:57 +0000 Subject: [PATCH 1/7] Fix "Continue reading" keeping the previous book's cover On Android the cover image was held in produceState, which restarts its loader when the cover path changes but keeps the old value. When another book became the current one, the hero card (and a shelf's thumbnail) kept the previous book's picture, and never loaded the new one. The image state is now keyed on the path, so it resets in the same pass. On the desktop the card only followed a book once it had progress, so opening a new book didn't move it until a page was turned. It now follows the book opened last, as on Android. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../java/com/readarea/ui/components/Covers.kt | 28 +++++-- .../java/com/readarea/ui/BookCoverTest.kt | 84 +++++++++++++++++++ .../readarea/desktop/ui/home/HomeScreen.kt | 21 +++-- .../com/readarea/desktop/ui/HomeRulesTest.kt | 47 +++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/readarea/ui/BookCoverTest.kt create mode 100644 desktop/src/test/kotlin/com/readarea/desktop/ui/HomeRulesTest.kt diff --git a/app/src/main/java/com/readarea/ui/components/Covers.kt b/app/src/main/java/com/readarea/ui/components/Covers.kt index 9f88ae5..473e52a 100644 --- a/app/src/main/java/com/readarea/ui/components/Covers.kt +++ b/app/src/main/java/com/readarea/ui/components/Covers.kt @@ -18,9 +18,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,17 +76,27 @@ object CoverCache { } } +/** + * The cover image at [path]: null while it loads, and when there is no cover. The state is keyed on [path], so a + * card that stays on screen while its book changes (the home screen's "Continue reading") drops the old cover in the + * same pass instead of keeping it: `produceState` would restart its loader but keep showing the previous value. + */ @Composable -fun BookCover(book: BookEntity, modifier: Modifier = Modifier, corner: Dp = 8.dp, elevation: Dp = 4.dp) { - val path = book.coverPath - val image by produceState(initialValue = path?.let { CoverCache.get(it) }, path) { - if (path != null && value == null) value = withContext(Dispatchers.IO) { CoverCache.load(path) } +private fun rememberCover(path: String?): ImageBitmap? { + var image by remember(path) { mutableStateOf(path?.let { CoverCache.get(it) }) } + LaunchedEffect(path) { + if (path != null && image == null) image = withContext(Dispatchers.IO) { CoverCache.load(path) } } + return image +} + +@Composable +fun BookCover(book: BookEntity, modifier: Modifier = Modifier, corner: Dp = 8.dp, elevation: Dp = 4.dp) { + val image = rememberCover(book.coverPath) val shape = RoundedCornerShape(topStart = corner / 2, bottomStart = corner / 2, topEnd = corner, bottomEnd = corner) Box(modifier.aspectRatio(0.68f).shadow(elevation, shape).clip(shape)) { - val img = image - if (img != null) { - Image(img, contentDescription = book.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + if (image != null) { + Image(image, contentDescription = book.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } else { GeneratedCover(book.title, book.author, BookFormat.byName(book.format), Modifier.fillMaxSize()) } diff --git a/app/src/test/java/com/readarea/ui/BookCoverTest.kt b/app/src/test/java/com/readarea/ui/BookCoverTest.kt new file mode 100644 index 0000000..9a94707 --- /dev/null +++ b/app/src/test/java/com/readarea/ui/BookCoverTest.kt @@ -0,0 +1,84 @@ +package com.readarea.ui + +import android.app.Application +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.readarea.data.db.BookEntity +import com.readarea.ui.components.BookCover +import com.readarea.ui.components.CoverCache +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w393dp-h851dp-xxhdpi") +class BookCoverTest { + @get:Rule + val compose = createComposeRule() + + private val dir get() = File(ApplicationProvider.getApplicationContext().cacheDir, "cover-test").apply { mkdirs() } + + private fun cover(name: String, top: Int, bottom: Int): String { + val file = File(dir, "$name.png").apply { writeBytes(CoverArt.make(name, "Author", top, bottom, 0xFFF7F1E6.toInt(), 3)) } + // Decoded ahead, so the test tells which book's picture is on screen rather than how quickly it loads. + CoverCache.load(file.path) + return file.path + } + + private fun book(id: Long, title: String, coverPath: String?) = + BookEntity(id = id, uri = "file:///$title.epub", fileName = "$title.epub", format = "EPUB", title = title, coverPath = coverPath) + + /** Whether the cover on screen is mostly red or mostly blue, read where the artwork's title and pattern leave it clear. */ + private fun tint(): String { + val image = compose.onNodeWithTag("cover").captureToImage() + val pixel = image.toPixelMap()[image.width / 2, image.height * 4 / 10] + return if (pixel.red > pixel.blue) "red" else "blue" + } + + @Test + fun theCoverFollowsTheBookItBelongsTo() { + val red = book(1, "Red", cover("Red", 0xFFB0463C.toInt(), 0xFF5A1D18.toInt())) + val blue = book(2, "Blue", cover("Blue", 0xFF3F6E8C.toInt(), 0xFF16293A.toInt())) + val plain = book(3, "Plain", null) + var shown by mutableStateOf(red) + compose.setContent { BookCover(shown, Modifier.width(120.dp).testTag("cover")) } + compose.waitForIdle() + assertEquals("red", tint()) + + // Another book takes over the same card, as on the home screen when a different book is opened. + compose.runOnIdle { shown = blue } + compose.waitForIdle() + assertEquals("the first book's cover stayed on screen", "blue", tint()) + compose.onNodeWithContentDescription("Blue").assertExists() + + compose.runOnIdle { shown = red } + compose.waitForIdle() + assertEquals("red", tint()) + + // A book with no cover gets the generated one, not the previous book's picture. + compose.runOnIdle { shown = plain } + compose.waitForIdle() + compose.onNodeWithContentDescription("Plain").assertDoesNotExist() + compose.onNodeWithText("Plain").assertExists() + } +} diff --git a/desktop/src/main/kotlin/com/readarea/desktop/ui/home/HomeScreen.kt b/desktop/src/main/kotlin/com/readarea/desktop/ui/home/HomeScreen.kt index 435398d..bec4f71 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/ui/home/HomeScreen.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/ui/home/HomeScreen.kt @@ -55,6 +55,18 @@ import javax.swing.JPanel import javax.swing.KeyStroke import com.readarea.desktop.ui.components.LeadingBorder +/** + * The book "Continue reading" offers: the one opened last, unless it's finished. Opening a book is enough, without + * turning a page first, so the card follows the reader from one book to the next (the same rule as on Android). + */ +internal fun continueReadingBook(books: List): Book? = + books.filter { it.lastOpenedAt > 0 && it.status != BookStatus.FINISHED }.maxByOrNull { it.lastOpenedAt } + +/** The other books under way, most recently opened first, for the "Also reading" row. */ +internal fun alsoReading(books: List, current: Book?): List = + books.filter { it.id != current?.id && (it.status == BookStatus.READING || (it.progress > 0.005f && it.status != BookStatus.FINISHED)) } + .sortedByDescending { it.lastOpenedAt } + @OptIn(FlowPreview::class) class HomeScreen(private val app: App, private val window: MainWindow) : Screen { private val cards = CardLayout() @@ -101,8 +113,7 @@ class HomeScreen(private val app: App, private val window: MainWindow) : Screen val today = ReadingStats.today() val todayMs = days.firstOrNull { it.day == today }?.ms ?: 0L val streak = ReadingStats.streaks(days, today).current - val reading = books.filter { it.status == BookStatus.READING || (it.progress > 0.005f && it.status != BookStatus.FINISHED) }.sortedByDescending { it.lastOpenedAt } - val hero = reading.firstOrNull() + val hero = continueReadingBook(books) column.removeAll() val greeting = when (LocalTime.now().hour) { @@ -122,7 +133,7 @@ class HomeScreen(private val app: App, private val window: MainWindow) : Screen top.maximumSize = Dimension(Int.MAX_VALUE, 290) column.add(top) - val also = reading.drop(1).take(12) + val also = alsoReading(books, hero).take(12) if (also.isNotEmpty()) { column.add(Ui.gap(26)) column.add(sectionHeader(tr("home_also_reading")).apply { alignmentX = JComponent.LEFT_ALIGNMENT }) @@ -225,9 +236,9 @@ class HomeScreen(private val app: App, private val window: MainWindow) : Screen return card } - /** A row of covers with titles; click to open, right-click for details. */ /** - * A row of covers. Only covers that fit whole are shown; the rest are a click away in the library. + * A row of covers with titles; click to open, right-click for details. Only covers that fit whole are shown; + * the rest are a click away in the library. * Arrow keys move between covers, Enter opens one and the context-menu key shows its details. */ private inner class CoverRow(private val books: List) : Widget(javax.accessibility.AccessibleRole.LIST) { diff --git a/desktop/src/test/kotlin/com/readarea/desktop/ui/HomeRulesTest.kt b/desktop/src/test/kotlin/com/readarea/desktop/ui/HomeRulesTest.kt new file mode 100644 index 0000000..bdfab3d --- /dev/null +++ b/desktop/src/test/kotlin/com/readarea/desktop/ui/HomeRulesTest.kt @@ -0,0 +1,47 @@ +package com.readarea.desktop.ui + +import com.readarea.core.library.BookStatus +import com.readarea.desktop.data.Book +import com.readarea.desktop.ui.home.alsoReading +import com.readarea.desktop.ui.home.continueReadingBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HomeRulesTest { + private fun book(id: Long, opened: Long = 0, status: Int = BookStatus.NEW, progress: Float = 0f) = + Book(id = id, path = "/books/$id.epub", fileName = "$id.epub", format = "EPUB", title = "Book $id", lastOpenedAt = opened, status = status, progress = progress) + + @Test + fun theCardFollowsTheBookOpenedLast() { + val reading = book(1, opened = 1_000, status = BookStatus.READING, progress = 0.4f) + val next = book(2, opened = 2_000) // opened just now, no page turned yet: still new, at 0% + assertEquals(1L, continueReadingBook(listOf(reading))?.id) + assertEquals("opening another book moves the card to it", 2L, continueReadingBook(listOf(reading, next))?.id) + assertEquals(2L, continueReadingBook(listOf(next, reading))?.id) + } + + @Test + fun finishedAndNeverOpenedBooksAreNotOffered() { + val finished = book(1, opened = 5_000, status = BookStatus.FINISHED, progress = 1f) + val unopened = book(2) + val reading = book(3, opened = 1_000, status = BookStatus.READING, progress = 0.2f) + assertNull(continueReadingBook(emptyList())) + assertNull(continueReadingBook(listOf(finished, unopened))) + assertEquals(3L, continueReadingBook(listOf(finished, unopened, reading))?.id) + } + + @Test + fun theOtherBooksUnderWayFollowByRecency() { + val a = book(1, opened = 1_000, status = BookStatus.READING, progress = 0.4f) + val b = book(2, opened = 3_000, status = BookStatus.READING, progress = 0.1f) + val c = book(3, opened = 2_000, status = BookStatus.READING, progress = 0.7f) + val opened = book(4, opened = 9_000) // the card's own book, not repeated in the row + val started = book(5, opened = 500, status = BookStatus.WANT, progress = 0.3f) // set aside part way through + val done = book(6, opened = 8_000, status = BookStatus.FINISHED, progress = 1f) + val all = listOf(a, b, c, opened, started, done) + val current = continueReadingBook(all) + assertEquals(4L, current?.id) + assertEquals(listOf(2L, 3L, 1L, 5L), alsoReading(all, current).map { it.id }) + } +} From 4810a30a3866ac3e4d088060b78a99b3bb6f62bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:20:57 +0000 Subject: [PATCH 2/7] Android: keep the selection bar's count readable on phones Selecting a book showed a close button and up to six 48dp icon buttons beside the "N selected" title, leaving it a few dp on a phone, so it wrapped letter by letter. The title now stays on one line, and below 560dp the bar keeps select all and add to shelf, with details, favorite, mark as and remove in the menu. Wide bars still show every action. Books that a filter or a rescan takes off the screen are also dropped from the selection, so the count and "Remove" can't reach them unseen. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../com/readarea/ui/library/LibraryScreen.kt | 131 ++++++++++++++---- .../com/readarea/qa/LibraryBehaviourTest.kt | 52 ++++++- 2 files changed, 147 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/readarea/ui/library/LibraryScreen.kt b/app/src/main/java/com/readarea/ui/library/LibraryScreen.kt index 1d7d02a..a72c174 100644 --- a/app/src/main/java/com/readarea/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/readarea/ui/library/LibraryScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -43,6 +44,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -61,6 +63,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,6 +72,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.readarea.core.format.BookFormat @@ -102,7 +106,14 @@ fun LibraryScreen(vm: LibraryViewModel, actions: AppActions) { var addMenu by remember { mutableStateOf(false) } var shelfDialog by remember { mutableStateOf(false) } var removeDialog by remember { mutableStateOf(false) } - var statusMenu by remember { mutableStateOf(false) } + // A filter or a rescan can take selected books off the screen. They must not stay selected unseen: the bar would + // still count them and "Remove" would still reach them. + LaunchedEffect(books) { + if (selection.isNotEmpty()) { + val shown = books.mapTo(HashSet()) { it.id } + if (!shown.containsAll(selection)) selection = selection.filterTo(HashSet()) { it in shown } + } + } BackHandler(enabled = selection.isNotEmpty() || searching) { if (selection.isNotEmpty()) selection = emptySet() else { searching = false @@ -114,36 +125,25 @@ fun LibraryScreen(vm: LibraryViewModel, actions: AppActions) { Scaffold( topBar = { if (selection.isNotEmpty()) { - TopAppBar( - navigationIcon = { IconButton(onClick = { selection = emptySet() }) { Icon(Icons.Rounded.Close, stringResource(R.string.clear_selection)) } }, - title = { Text(pluralStringResource(R.plurals.selected_count, selection.size, selection.size)) }, - actions = { - if (selection.size == 1) IconButton(onClick = { actions.showDetails(selection.first()); selection = emptySet() }) { Icon(Icons.Rounded.Info, stringResource(R.string.details)) } - IconButton(onClick = { selection = books.map { it.id }.toSet() }) { Icon(Icons.Rounded.SelectAll, stringResource(R.string.select_all)) } - IconButton(onClick = { - val allFav = books.filter { it.id in selection }.all { it.favorite } - vm.setFavorite(selection.toList(), !allFav) - }) { Icon(Icons.Rounded.Favorite, stringResource(R.string.favorite)) } - IconButton(onClick = { shelfDialog = true }) { Icon(Icons.Rounded.CollectionsBookmark, stringResource(R.string.add_to_shelf)) } - Box { - IconButton(onClick = { statusMenu = true }) { Icon(Icons.Rounded.Check, stringResource(R.string.mark_as)) } - DropdownMenu(statusMenu, { statusMenu = false }) { - listOf(BookStatus.WANT to R.string.mark_want, BookStatus.READING to R.string.mark_reading, BookStatus.FINISHED to R.string.mark_finished).forEach { (st, label) -> - DropdownMenuItem(text = { Text(stringResource(label)) }, onClick = { - vm.setStatus(selection.toList(), st) - statusMenu = false - selection = emptySet() - }) - } - DropdownMenuItem(text = { Text(stringResource(R.string.reset_progress)) }, onClick = { - vm.resetProgress(selection.toList()) - statusMenu = false - selection = emptySet() - }) - } - } - IconButton(onClick = { removeDialog = true }) { Icon(Icons.Rounded.Delete, stringResource(R.string.remove)) } + SelectionBar( + count = selection.size, + onClear = { selection = emptySet() }, + onSelectAll = { selection = books.map { it.id }.toSet() }, + onDetails = if (selection.size == 1) ({ actions.showDetails(selection.first()); selection = emptySet() }) else null, + onFavorite = { + val allFav = books.filter { it.id in selection }.all { it.favorite } + vm.setFavorite(selection.toList(), !allFav) + }, + onShelf = { shelfDialog = true }, + onStatus = { status -> + vm.setStatus(selection.toList(), status) + selection = emptySet() }, + onResetProgress = { + vm.resetProgress(selection.toList()) + selection = emptySet() + }, + onRemove = { removeDialog = true }, ) } else if (searching) { TopAppBar( @@ -296,6 +296,77 @@ fun LibraryScreen(vm: LibraryViewModel, actions: AppActions) { } } +/** Bars narrower than this keep two actions in view and move the rest into the menu, so the count stays readable. */ +private val selectionWideMinWidth = 560.dp + +/** + * The bar shown while books are selected. Every icon button takes 48dp, and a phone is only 320 to 430dp wide: with a + * close button and six actions the count was left a few dp and wrapped letter by letter. So a narrow bar shows + * select all and add to shelf, and keeps the other actions in the ⋮ menu; a wide one shows them all. + */ +@Composable +private fun SelectionBar( + count: Int, + onClear: () -> Unit, + onSelectAll: () -> Unit, + onDetails: (() -> Unit)?, + onFavorite: () -> Unit, + onShelf: () -> Unit, + onStatus: (Int) -> Unit, + onResetProgress: () -> Unit, + onRemove: () -> Unit, +) { + var markMenu by remember { mutableStateOf(false) } + var moreMenu by remember { mutableStateOf(false) } + BoxWithConstraints { + val wide = maxWidth >= selectionWideMinWidth + TopAppBar( + navigationIcon = { IconButton(onClick = onClear) { Icon(Icons.Rounded.Close, stringResource(R.string.clear_selection)) } }, + // One line whatever the language: a count too long for the room is cut short, not wrapped. + title = { Text(pluralStringResource(R.plurals.selected_count, count, count), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + actions = { + if (wide && onDetails != null) IconButton(onClick = onDetails) { Icon(Icons.Rounded.Info, stringResource(R.string.details)) } + IconButton(onClick = onSelectAll) { Icon(Icons.Rounded.SelectAll, stringResource(R.string.select_all)) } + if (wide) IconButton(onClick = onFavorite) { Icon(Icons.Rounded.Favorite, stringResource(R.string.favorite)) } + IconButton(onClick = onShelf) { Icon(Icons.Rounded.CollectionsBookmark, stringResource(R.string.add_to_shelf)) } + if (wide) { + Box { + IconButton(onClick = { markMenu = true }) { Icon(Icons.Rounded.Check, stringResource(R.string.mark_as)) } + DropdownMenu(markMenu, { markMenu = false }) { + MarkAsItems(onStatus = { markMenu = false; onStatus(it) }, onResetProgress = { markMenu = false; onResetProgress() }) + } + } + IconButton(onClick = onRemove) { Icon(Icons.Rounded.Delete, stringResource(R.string.remove)) } + } else { + Box { + IconButton(onClick = { moreMenu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more)) } + DropdownMenu(moreMenu, { moreMenu = false }) { + if (onDetails != null) DropdownMenuItem(text = { Text(stringResource(R.string.details)) }, leadingIcon = { Icon(Icons.Rounded.Info, null) }, onClick = { moreMenu = false; onDetails() }) + DropdownMenuItem(text = { Text(stringResource(R.string.favorite)) }, leadingIcon = { Icon(Icons.Rounded.Favorite, null) }, onClick = { moreMenu = false; onFavorite() }) + HorizontalDivider() + MarkAsItems(onStatus = { moreMenu = false; onStatus(it) }, onResetProgress = { moreMenu = false; onResetProgress() }) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = MaterialTheme.colorScheme.error) }, + onClick = { moreMenu = false; onRemove() }, + ) + } + } + } + }, + ) + } +} + +@Composable +private fun MarkAsItems(onStatus: (Int) -> Unit, onResetProgress: () -> Unit) { + listOf(BookStatus.WANT to R.string.mark_want, BookStatus.READING to R.string.mark_reading, BookStatus.FINISHED to R.string.mark_finished).forEach { (status, label) -> + DropdownMenuItem(text = { Text(stringResource(label)) }, onClick = { onStatus(status) }) + } + DropdownMenuItem(text = { Text(stringResource(R.string.reset_progress)) }, onClick = onResetProgress) +} + @Composable fun AddToShelfDialog(vm: LibraryViewModel, bookIds: List, onDismiss: () -> Unit) { val shelves by vm.collections.collectAsStateWithLifecycle() diff --git a/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt b/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt index 122acba..2c50be8 100644 --- a/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt +++ b/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import com.readarea.MainActivity @@ -122,6 +123,20 @@ class LibraryBehaviourTest { settle() } + /** On a phone the selection bar keeps two actions in view; the others are items in its ⋮ menu. */ + private fun fromMenu(item: String) { + clickIcon("More") + click(item) + } + + /** Whether the text is laid out whole on one line, rather than wrapped over several or cut short. */ + private fun fitsOnOneLine(t: String): Boolean { + val layouts = mutableListOf() + compose.onAllNodesWithText(t, useUnmergedTree = true)[0].fetchSemanticsNode().config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) + val layout = layouts.first() + return layout.lineCount == 1 && !layout.isLineEllipsized(0) + } + private fun type(text: String, index: Int = 0) { compose.onAllNodes(hasSetTextAction())[index].performSemanticsAction(SemanticsActions.SetText) { it(AnnotatedString(text)) } settle() @@ -161,17 +176,17 @@ class LibraryBehaviourTest { longPress("The Harbour Light") assertTrue("long-press selects", has("1 selected")) assertNull("long-press must not open the book", startedReader(sc)) - clickIcon("Remove") + fromMenu("Remove") assertTrue(has("Remove book?")) click("Cancel") assertNotNull(book("harbour")) - clickIcon("Remove") + fromMenu("Remove") click("Remove") waitUntil("removed") { book("harbour") == null } assertTrue("the file is kept", harbour.exists()) waitUntil("gone from list") { !has("The Harbour Light") } longPress("Storm Season") - clickIcon("Remove") + fromMenu("Remove") click("Also delete the file from the device") click("Remove") waitUntil("removed with file") { book("storm") == null } @@ -204,16 +219,41 @@ class LibraryBehaviourTest { compose.onAllNodesWithText("Gulls at Noon", useUnmergedTree = true)[0].performClick() settle() assertTrue(has("2 selected")) - clickIcon("Favorite") + fromMenu("Favorite") waitUntil("favorites saved") { book("harbour")!!.favorite && book("gulls")!!.favorite } assertFalse(book("storm")!!.favorite) - clickIcon("Mark as") - click("Mark as finished") + fromMenu("Mark as finished") waitUntil("status saved") { book("harbour")!!.status == BookStatus.FINISHED && book("gulls")!!.status == BookStatus.FINISHED } assertFalse("selection clears after marking", has("2 selected")) } } + @Test + fun theSelectionBarKeepsTheCountReadableOnAPhone() { + library { _ -> + longPress("The Harbour Light") + assertTrue(has("1 selected")) + // The count once shared the bar with six icon buttons and wrapped letter by letter. + assertTrue("the count sits whole on one line", fitsOnOneLine("1 selected")) + // What the narrow bar moved off it is one tap away. + clickIcon("More") + listOf("Details", "Favorite", "Mark as want to read", "Mark as reading", "Mark as finished", "Reset progress", "Remove").forEach { + assertTrue("$it is in the menu", has(it)) + } + } + } + + @Test + fun aFilterDropsTheBooksItHidesFromTheSelection() { + library { _ -> + longPress("The Harbour Light") + assertTrue(has("1 selected")) + // No book is being read, so the selected one leaves the list: it must not stay selected out of sight. + click("Reading") + waitUntil("the selection follows the list") { !has("1 selected") } + } + } + @Test fun booksCanBePutOnANewShelf() { library { _ -> From a2c434485eb8e986aac0d8ceae22456bdb60e46a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:20:57 +0000 Subject: [PATCH 3/7] Desktop: stop headers and rows overlapping in narrow windows At the smallest window the Library title was printed over by the search and sort controls, the filter chips and selection buttons were cut off (so Favorite, Remove and Clear selection couldn't be reached), Notes overprinted its search box in German, and the Cover size setting lost its title. Screens now use a ScreenHeader that drops the tools under the title when they don't fit, chip and selection rows wrap, and a setting whose control is too wide moves it under the title. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../com/readarea/desktop/ui/components/Kit.kt | 64 ++++++++++++-- .../readarea/desktop/ui/components/Layouts.kt | 67 +++++++++++++++ .../desktop/ui/library/LibraryScreen.kt | 16 ++-- .../readarea/desktop/ui/notes/NotesScreen.kt | 8 +- .../readarea/desktop/ui/ScreenHeaderTest.kt | 84 +++++++++++++++++++ 5 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 desktop/src/test/kotlin/com/readarea/desktop/ui/ScreenHeaderTest.kt diff --git a/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Kit.kt b/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Kit.kt index f7a510f..c5838b4 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Kit.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Kit.kt @@ -34,6 +34,7 @@ import javax.swing.JTextField import javax.swing.JToggleButton import javax.swing.ScrollPaneConstants import javax.swing.SwingConstants +import javax.swing.SwingUtilities /** * Antialiased shapes and smooth image scaling. Text hints are left to the look and feel, which follows @@ -91,12 +92,8 @@ object Ui { fun settingRow(title: String, hint: String?, control: JComponent, titleSize: Float = 13.5f, hintSize: Float = 12f): JPanel { val left = vbox(label(title, titleSize)) if (hint != null) left.add(WrapText(hint, hintSize, colorOf = { pal.onSurfaceVariant })) - val p = object : Transparent(BorderLayout(16, 0)) { - override fun getMaximumSize() = Dimension(Int.MAX_VALUE, preferredSize.height) - } + val p = SettingRow(left, control) p.border = LeadingBorder(6, 0, 6, 0) - p.add(left, BorderLayout.CENTER) - p.add(Transparent(java.awt.GridBagLayout()).apply { add(control) }, BorderLayout.LINE_END) p.alignmentX = Component.LEFT_ALIGNMENT return p } @@ -119,6 +116,63 @@ object Ui { } } +/** + * One setting: its title and hint at the leading edge, its control at the trailing one. A control too wide to leave + * the title a readable width, as in a narrow window, goes under the title instead of squeezing it away. + */ +internal class SettingRow(private val left: Component, private val control: Component) : Transparent(null) { + /** Whether the last preferred height was worked out for stacked parts; it depends on the width given. */ + private var assumedStacked = false + + init { + add(left) + add(control) + } + + private fun stackedAt(room: Int): Boolean = room > 0 && control.preferredSize.width + GAP + MIN_TITLE > room + + override fun getPreferredSize(): Dimension { + val l = left.preferredSize + val c = control.preferredSize + val stacked = stackedAt(width - insets.left - insets.right) + assumedStacked = stacked + val h = if (stacked) l.height + STACK_GAP + c.height else maxOf(l.height, c.height) + return Dimension(l.width + GAP + c.width + insets.left + insets.right, h + insets.top + insets.bottom) + } + + override fun getMinimumSize(): Dimension = Dimension(maxOf(MIN_TITLE, control.preferredSize.width) + insets.left + insets.right, preferredSize.height) + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + + override fun doLayout() { + val ins = insets + val w = width - ins.left - ins.right + val ltr = componentOrientation.isLeftToRight + val c = control.preferredSize + val stacked = stackedAt(w) + // Positions run from the leading edge, so a right-to-left window is the mirror image. + fun put(comp: Component, x: Int, y: Int, cw: Int, ch: Int) = comp.setBounds(if (ltr) ins.left + x else width - ins.right - x - cw, ins.top + y, cw, ch) + if (stacked) { + val lh = left.preferredSize.height + put(left, 0, 0, w, lh) + put(control, 0, lh + STACK_GAP, minOf(c.width, w), c.height) + } else { + val rowHeight = height - ins.top - ins.bottom + val cw = minOf(c.width, w) + put(control, w - cw, (rowHeight - c.height) / 2, cw, c.height) + put(left, 0, 0, (w - cw - GAP).coerceAtLeast(0), rowHeight) + } + // The height changes with the mode: ask the parent to lay this out again with the right one. + if (stacked != assumedStacked) SwingUtilities.invokeLater { revalidate() } + } + + private companion object { + const val GAP = 16 + const val STACK_GAP = 8 + const val MIN_TITLE = 150 + } +} + /** * Base for custom-painted components. Plain JComponents have no accessibility information, so screen * readers would skip them; this gives each one an accessible context with a fitting role. diff --git a/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Layouts.kt b/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Layouts.kt index 8b83b77..78469f4 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Layouts.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/ui/components/Layouts.kt @@ -55,6 +55,73 @@ class WrapLayout(align: Int = LEFT, hgap: Int = 16, vgap: Int = 16) : FlowLayout } } +/** + * The heading row of a screen: the title at the leading edge, its main action at the trailing edge and the tools + * (search, sort, view switches) between them. When they don't fit on one line, as in a narrow window or with a long + * translation, the tools drop to a second row under the title, so nothing is printed over anything else or cut off. + */ +class ScreenHeader(private val title: Component, private val tools: Component, private val action: Component? = null, private val gap: Int = 12) : Transparent(null) { + /** Whether the last preferred height was worked out for two rows; the height depends on the width it is given. */ + private var assumedStacked = false + + init { + add(title) + add(tools) + action?.let { add(it) } + } + + private fun room(): Int = width - insets.left - insets.right + + /** The tools may shrink (a search field gives way) but the title and the action keep their size. */ + private fun fitsOnOneLine(room: Int): Boolean = + title.preferredSize.width + gap + tools.minimumSize.width + (action?.let { gap + it.preferredSize.width } ?: 0) <= room + + override fun getPreferredSize(): Dimension { + val t = title.preferredSize + val tl = tools.preferredSize + val a = action?.preferredSize ?: Dimension() + val stacked = width > 0 && !fitsOnOneLine(room()) + assumedStacked = stacked + val natural = t.width + gap + tl.width + (if (action != null) gap + a.width else 0) + val h = if (stacked) maxOf(t.height, a.height) + gap + tl.height else maxOf(t.height, tl.height, a.height) + return Dimension(natural + insets.left + insets.right, h + insets.top + insets.bottom) + } + + override fun getMinimumSize(): Dimension { + val stackedWidth = maxOf(title.preferredSize.width + (action?.let { gap + it.preferredSize.width } ?: 0), tools.minimumSize.width) + return Dimension(stackedWidth + insets.left + insets.right, preferredSize.height) + } + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + + override fun doLayout() { + val ins = insets + val w = room() + val ltr = componentOrientation.isLeftToRight + val t = title.preferredSize + val tl = tools.preferredSize + val a = action?.preferredSize ?: Dimension() + val stacked = !fitsOnOneLine(w) + // Positions run from the leading edge, so a right-to-left window is the mirror image. + fun put(c: Component, x: Int, y: Int, cw: Int, ch: Int) = c.setBounds(if (ltr) ins.left + x else width - ins.right - x - cw, ins.top + y, cw, ch) + val firstRow = if (stacked) maxOf(t.height, a.height) else maxOf(t.height, tl.height, a.height) + put(title, 0, (firstRow - t.height) / 2, t.width, t.height) + var right = w + if (action != null) { + put(action, w - a.width, (firstRow - a.height) / 2, a.width, a.height) + right = w - a.width - gap + } + if (stacked) { + put(tools, 0, firstRow + gap, minOf(tl.width, w), tl.height) + } else { + val tw = minOf(tl.width, right - t.width - gap).coerceAtLeast(0) + put(tools, right - tw, (firstRow - tl.height) / 2, tw, tl.height) + } + // The height changes with the mode: ask the parent to lay this out again with the right one. + if (stacked != assumedStacked) SwingUtilities.invokeLater { revalidate() } + } +} + /** A short message floating at the bottom of a window for a few seconds. */ class Toast : Widget() { private var text = "" diff --git a/desktop/src/main/kotlin/com/readarea/desktop/ui/library/LibraryScreen.kt b/desktop/src/main/kotlin/com/readarea/desktop/ui/library/LibraryScreen.kt index c6b043e..4496069 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/ui/library/LibraryScreen.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/ui/library/LibraryScreen.kt @@ -12,8 +12,10 @@ import com.readarea.desktop.ui.components.ButtonKind import com.readarea.desktop.ui.components.Chip import com.readarea.desktop.ui.components.IconButton import com.readarea.desktop.ui.components.PillButton +import com.readarea.desktop.ui.components.ScreenHeader import com.readarea.desktop.ui.components.Transparent import com.readarea.desktop.ui.components.Ui +import com.readarea.desktop.ui.components.WrapLayout import com.readarea.desktop.ui.components.emptyState import com.readarea.desktop.ui.components.searchField import kotlinx.coroutines.flow.combine @@ -75,7 +77,7 @@ class LibraryScreen(private val app: App, private val window: MainWindow) : Scre private val gridButton = IconButton("grid", tr("change_view")) private val listButton = IconButton("list", tr("change_view")) private val selectionLabel = Ui.label("", 13.5f, java.awt.Font.BOLD) - private val selectionBar = Transparent(FlowLayout(FlowLayout.LEADING, 8, 0)) + private val selectionBar = Transparent(WrapLayout(FlowLayout.LEADING, 8, 4)) private val body = JPanel(CardLayout()) private var all: List = emptyList() @@ -83,6 +85,7 @@ class LibraryScreen(private val app: App, private val window: MainWindow) : Scre border = LeadingBorder(22, 28, 0, 28) val title = Ui.hbox(Ui.headline(tr("nav_library")), Ui.gap(12), count) search.preferredSize = Dimension(260, 34) + search.minimumSize = Dimension(110, 34) search.maximumSize = Dimension(320, 34) val add = PillButton(tr("add_books"), "add", ButtonKind.PRIMARY, compact = true) add.addActionListener { @@ -96,10 +99,8 @@ class LibraryScreen(private val app: App, private val window: MainWindow) : Scre sortBox.addActionListener { app.settings.updateApp { s -> s.copy(sort = LibrarySort.keys[sortBox.selectedIndex.coerceAtLeast(0)]) } } gridButton.addActionListener { app.settings.updateApp { it.copy(libraryGrid = true) } } listButton.addActionListener { app.settings.updateApp { it.copy(libraryGrid = false) } } - val header = Transparent(BorderLayout()).apply { - add(title, BorderLayout.LINE_START) - add(Ui.hbox(search, Ui.gap(8), sortBox, Ui.gap(4), gridButton, listButton, Ui.gap(8), add), BorderLayout.LINE_END) - } + // Drops the search and sort under the title when the window is too narrow to fit them beside it. + val header = ScreenHeader(title, Ui.hbox(search, Ui.gap(8), sortBox, Ui.gap(4), gridButton, listButton), add) val group = ButtonGroup() chips.forEachIndexed { i, c -> group.add(c) @@ -110,12 +111,13 @@ class LibraryScreen(private val app: App, private val window: MainWindow) : Scre update(query.copy(format = if (i <= 0) null else formats.getOrNull(i - 1))) } formatBox.toolTipText = tr("formats") - val filters = Transparent(FlowLayout(FlowLayout.LEADING, 8, 0)).apply { + // Wraps onto a second line rather than hiding the chips that don't fit. + val filters = Transparent(WrapLayout(FlowLayout.LEADING, 8, 4)).apply { chips.forEach { add(it) } add(formatBox) } buildSelectionBar() - val top = Ui.vbox(header, Ui.gap(14), filters, Ui.gap(10), selectionBar) + val top = Ui.vbox(header, Ui.gap(10), filters, Ui.gap(6), selectionBar) add(top, BorderLayout.NORTH) val scroll = Ui.scroll(grid) scroll.border = LeadingBorder(6, 0, 0, 0) diff --git a/desktop/src/main/kotlin/com/readarea/desktop/ui/notes/NotesScreen.kt b/desktop/src/main/kotlin/com/readarea/desktop/ui/notes/NotesScreen.kt index ae9106d..3266280 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/ui/notes/NotesScreen.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/ui/notes/NotesScreen.kt @@ -17,6 +17,7 @@ import com.readarea.desktop.ui.components.Card import com.readarea.desktop.ui.components.Dialogs import com.readarea.desktop.ui.components.IconButton import com.readarea.desktop.ui.components.PillButton +import com.readarea.desktop.ui.components.ScreenHeader import com.readarea.desktop.ui.components.ScrollableColumn import com.readarea.desktop.ui.components.Segmented import com.readarea.desktop.ui.components.Transparent @@ -59,12 +60,11 @@ class NotesScreen(private val app: App, private val window: MainWindow) : Screen border = LeadingBorder(22, 28, 0, 28) val search = searchField(tr("search_notes")) { q -> query = q; shown = PAGE; render() } search.preferredSize = Dimension(260, 34) + search.minimumSize = Dimension(110, 34) search.maximumSize = Dimension(320, 34) val export = PillButton(tr("export_all"), "export", ButtonKind.TONAL, compact = true).apply { addActionListener { exportAll() } } - val header = Transparent(BorderLayout()).apply { - add(Ui.headline(tr("nav_notes")), BorderLayout.LINE_START) - add(Ui.hbox(search, Ui.gap(8), export), BorderLayout.LINE_END) - } + // The search field gives way before it can overlap the title. + val header = ScreenHeader(Ui.headline(tr("nav_notes")), search, export) tabs.maximumSize = Dimension(420, 34) add(Ui.vbox(header, Ui.gap(14), tabs, Ui.gap(12)), BorderLayout.NORTH) list.border = LeadingBorder(0, 0, 24, 8) diff --git a/desktop/src/test/kotlin/com/readarea/desktop/ui/ScreenHeaderTest.kt b/desktop/src/test/kotlin/com/readarea/desktop/ui/ScreenHeaderTest.kt new file mode 100644 index 0000000..9d1b95f --- /dev/null +++ b/desktop/src/test/kotlin/com/readarea/desktop/ui/ScreenHeaderTest.kt @@ -0,0 +1,84 @@ +package com.readarea.desktop.ui + +import com.readarea.desktop.ui.components.ScreenHeader +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.awt.ComponentOrientation +import java.awt.Dimension +import javax.swing.JComponent + +class ScreenHeaderTest { + /** A stand-in of a known size, like a title, a search field or a button. */ + private fun part(w: Int, h: Int, min: Int = w) = object : JComponent() { + init { + preferredSize = Dimension(w, h) + minimumSize = Dimension(min, h) + } + } + + private class Setup(val header: ScreenHeader, val title: JComponent, val tools: JComponent, val action: JComponent) + + // A 200px title, tools that can shrink from 300px to 100px, and a 120px action, with 10px between them. + private fun setup(width: Int): Setup { + val title = part(200, 40) + val tools = part(300, 34, min = 100) + val action = part(120, 34) + val header = ScreenHeader(title, tools, action, gap = 10) + header.setSize(width, 200) + header.preferredSize // works out which layout this width calls for, as the parent does before laying out + header.doLayout() + return Setup(header, title, tools, action) + } + + @Test + fun everythingSitsOnOneLineWhenThereIsRoom() { + val s = setup(800) + assertEquals(0, s.title.x) + assertEquals(800, s.action.x + s.action.width) + assertTrue(s.title.x + s.title.width <= s.tools.x) + assertTrue(s.tools.x + s.tools.width <= s.action.x) + assertEquals("the tools keep their full size", 300, s.tools.width) + assertEquals("one row", 40, s.header.preferredSize.height) + } + + @Test + fun theToolsShrinkBeforeAnythingOverlaps() { + // 200 + 10 + 120 + 10 leaves 160 of 500 for the tools: less than they'd like, more than they need. + val s = setup(500) + assertEquals(160, s.tools.width) + assertTrue(s.title.x + s.title.width <= s.tools.x) + assertTrue(s.tools.x + s.tools.width <= s.action.x) + assertEquals("still one row", 40, s.header.preferredSize.height) + } + + @Test + fun theToolsDropUnderTheTitleWhenTheyCannotFitBesideIt() { + val s = setup(400) + assertTrue("the title keeps its line", s.title.y + s.title.height <= s.tools.y) + assertEquals(0, s.tools.x) + assertTrue(s.tools.x + s.tools.width <= 400) + assertEquals("the action stays at the far edge of the first line", 400, s.action.x + s.action.width) + assertTrue(s.title.x + s.title.width <= s.action.x) + assertEquals("two rows", 40 + 10 + 34, s.header.preferredSize.height) + } + + @Test + fun aRightToLeftWindowIsTheMirrorImage() { + val ltr = setup(800) + val title = part(200, 40) + val tools = part(300, 34, min = 100) + val action = part(120, 34) + val header = ScreenHeader(title, tools, action, gap = 10) + header.componentOrientation = ComponentOrientation.RIGHT_TO_LEFT + header.setSize(800, 200) + header.preferredSize + header.doLayout() + assertEquals("the title starts at the right", 800, title.x + title.width) + assertEquals("the action ends at the left", 0, action.x) + assertEquals(800 - (ltr.tools.x + ltr.tools.width), tools.x) + assertFalse(title.bounds.intersects(tools.bounds)) + assertFalse(tools.bounds.intersects(action.bounds)) + } +} From c0b1b9632c6910fbfa0b79ab46a6e1033624c5ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:21:06 +0000 Subject: [PATCH 4/7] Clean up cover files, and stop listing the covers folder per book Removing a library folder together with its books deleted the rows but left every cover image behind, on Android and on the desktop. They are now deleted with their books. On Android each new cover also listed the whole covers folder to find the old one, which is quadratic on a large library; it now removes the old cover by its recorded path, as the desktop already did. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../com/readarea/data/LibraryRepository.kt | 21 +++++--- .../com/readarea/desktop/data/Library.kt | 7 ++- .../desktop/data/LibraryFoldersTest.kt | 53 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 desktop/src/test/kotlin/com/readarea/desktop/data/LibraryFoldersTest.kt diff --git a/app/src/main/java/com/readarea/data/LibraryRepository.kt b/app/src/main/java/com/readarea/data/LibraryRepository.kt index a70ff48..e73ebcb 100644 --- a/app/src/main/java/com/readarea/data/LibraryRepository.kt +++ b/app/src/main/java/com/readarea/data/LibraryRepository.kt @@ -80,10 +80,17 @@ class LibraryRepository( scope.launch { settings.updateApp { s -> s.copy(folders = s.folders - tree) } runCatching { context.contentResolver.releasePersistableUriPermission(tree.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION) } - if (removeBooks) db.books().deleteFolder(tree) + if (removeBooks) removeFolderBooks(tree) } } + /** Deletes the books of one library folder along with their cover images, which nothing else would ever clean up. */ + private suspend fun removeFolderBooks(folder: String) { + val covers = db.books().all().filter { it.folderUri == folder }.mapNotNull { it.coverPath } + db.books().deleteFolder(folder) + covers.forEach { path -> File(path).takeIf { SafeFiles.inside(coverDir, it) }?.delete() } + } + fun rescan() { scope.launch { scanAll() } } @@ -91,7 +98,7 @@ class LibraryRepository( fun setDeviceScan(enabled: Boolean, removeBooks: Boolean = false) { scope.launch { settings.updateApp { it.copy(deviceScan = enabled, askedDeviceScan = true, deviceScanOff = !enabled) } - if (enabled) scanAll() else if (removeBooks) db.books().deleteFolder(DeviceStorage.DEVICE) + if (enabled) scanAll() else if (removeBooks) removeFolderBooks(DeviceStorage.DEVICE) } } @@ -388,7 +395,7 @@ class LibraryRepository( is ReflowableBook -> { if (format == BookFormat.CBZ) pages = 0 opened.book.coverBytes()?.let { bytes -> - ImageUtil.decodeSampled(bytes, 360, 540)?.let { bmp -> coverPath = saveCover(book.id, bmp) } + ImageUtil.decodeSampled(bytes, 360, 540)?.let { bmp -> coverPath = saveCover(book.id, bmp, book.coverPath) } } } is FixedBook -> { @@ -398,7 +405,7 @@ class LibraryRepository( val w = 360 val bmp = Bitmap.createBitmap(w, (w / aspect).toInt(), Bitmap.Config.ARGB_8888) opened.source.render(0, bmp, null) - coverPath = saveCover(book.id, bmp) + coverPath = saveCover(book.id, bmp, book.coverPath) } } } @@ -433,9 +440,11 @@ class LibraryRepository( ) } - private fun saveCover(id: Long, bmp: Bitmap): String? { + private fun saveCover(id: Long, bmp: Bitmap, previous: String?): String? { + // Replace the book's old cover by its recorded path; listing the folder for every book would make scanning + // a large library quadratic. + previous?.let { File(it) }?.takeIf { SafeFiles.inside(coverDir, it) }?.delete() val file = File(coverDir, "cover_${id}_${System.currentTimeMillis() % 100000}.jpg") - coverDir.listFiles()?.filter { it.name.startsWith("cover_${id}_") }?.forEach { it.delete() } return runCatching { file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 88, it) } bmp.recycle() diff --git a/desktop/src/main/kotlin/com/readarea/desktop/data/Library.kt b/desktop/src/main/kotlin/com/readarea/desktop/data/Library.kt index c13afa1..4969800 100644 --- a/desktop/src/main/kotlin/com/readarea/desktop/data/Library.kt +++ b/desktop/src/main/kotlin/com/readarea/desktop/data/Library.kt @@ -91,7 +91,12 @@ class Library(private val db: Database, private val settings: SettingsStore, pri fun removeFolder(path: String, removeBooks: Boolean) { settings.updateApp { s -> s.copy(folders = s.folders - path) } - if (removeBooks) scope.launch { write { deleteFolderBooks(path) } } + if (removeBooks) scope.launch { + // Their cover images go too: nothing else would ever clean those up. + val covers = read { books(onlyPresent = false).filter { it.folder == path }.mapNotNull { it.coverPath } } + write { deleteFolderBooks(path) } + withContext(Dispatchers.IO) { covers.forEach { p -> File(p).takeIf { AppDirs.isInside(dirs.covers, it) }?.delete() } } + } } fun rescan(force: Boolean = true) { diff --git a/desktop/src/test/kotlin/com/readarea/desktop/data/LibraryFoldersTest.kt b/desktop/src/test/kotlin/com/readarea/desktop/data/LibraryFoldersTest.kt new file mode 100644 index 0000000..1d4c628 --- /dev/null +++ b/desktop/src/test/kotlin/com/readarea/desktop/data/LibraryFoldersTest.kt @@ -0,0 +1,53 @@ +package com.readarea.desktop.data + +import com.readarea.desktop.platform.AppDirs +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class LibraryFoldersTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun waitFor(what: String, cond: () -> Boolean) { + val end = System.currentTimeMillis() + 10_000 + while (System.currentTimeMillis() < end) { + if (cond()) return + Thread.sleep(20) + } + throw AssertionError("Timed out waiting for $what") + } + + @Test + fun removingAFolderWithItsBooksRemovesTheirCoversToo() { + val dirs = AppDirs(tmp.newFolder("data"), tmp.newFolder("cache")).init() + val db = Database(dirs.database) + val settings = SettingsStore(dirs.settings) + Library(db, settings, dirs).use { library -> + val folder = tmp.newFolder("Books").path + val ownCover = File(dirs.covers, "cover_1_1.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val otherCover = File(dirs.covers, "cover_2_1.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val inFolder = db.insertBook(Book(path = "$folder/a.epub", fileName = "a.epub", folder = folder, format = "EPUB", title = "A", coverPath = ownCover.path)) + val elsewhere = db.insertBook(Book(path = "/elsewhere/b.epub", fileName = "b.epub", folder = "/elsewhere", format = "EPUB", title = "B", coverPath = otherCover.path)) + + library.removeFolder(folder, removeBooks = false) + Thread.sleep(200) + assertNotNull("keeping the books keeps their covers", db.book(inFolder)) + assertTrue(ownCover.exists()) + + library.removeFolder(folder, removeBooks = true) + waitFor("the cover to go") { !ownCover.exists() } + assertNull("the folder's books are gone", db.book(inFolder)) + assertNotNull("other folders are untouched", db.book(elsewhere)) + assertTrue("and so are their covers", otherCover.exists()) + assertFalse(ownCover.exists()) + } + settings.close() + db.close() + } +} From b064e494327bbbe825865fc21008223f654bb24e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:21:06 +0000 Subject: [PATCH 5/7] Android: small fixes to launch, Home, Settings and Stats - The splash screen waited on the "reopen the last book" lookup; if it failed, the app stayed on the splash. It now falls back to the library. - Home rows are keyed, so a row keeps its scroll position when the scan bar or "Also reading" appears above it. - The six accent swatches wrap instead of being cut off on a narrow screen or a large display size. - The stats heatmap opens on the latest weeks rather than the oldest. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../main/java/com/readarea/MainActivity.kt | 3 ++- .../java/com/readarea/ui/home/HomeScreen.kt | 22 ++++++++++--------- .../readarea/ui/settings/SettingsScreen.kt | 4 +++- .../java/com/readarea/ui/stats/StatsScreen.kt | 6 ++++- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/readarea/MainActivity.kt b/app/src/main/java/com/readarea/MainActivity.kt index 04097fd..595fd36 100644 --- a/app/src/main/java/com/readarea/MainActivity.kt +++ b/app/src/main/java/com/readarea/MainActivity.kt @@ -33,7 +33,8 @@ class MainActivity : ComponentActivity() { splash.setKeepOnScreenCondition { vm.allBooks.value == null || !decided } if (!decided) { lifecycleScope.launch { - vm.resumeTarget()?.let { id -> startActivity(ReaderActivity.intent(this@MainActivity, id)) } + // If it can't be worked out, start on the library: the splash must never wait on this forever. + runCatching { vm.resumeTarget() }.getOrNull()?.let { id -> startActivity(ReaderActivity.intent(this@MainActivity, id)) } decided = true } } diff --git a/app/src/main/java/com/readarea/ui/home/HomeScreen.kt b/app/src/main/java/com/readarea/ui/home/HomeScreen.kt index 7b066d8..45fca3b 100644 --- a/app/src/main/java/com/readarea/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/readarea/ui/home/HomeScreen.kt @@ -127,8 +127,10 @@ fun HomeScreen(vm: LibraryViewModel, actions: AppActions) { val want = list.filter { it.status == BookStatus.WANT } val finished = list.filter { it.status == BookStatus.FINISHED }.sortedByDescending { it.finishedAt } LazyColumn(contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = 24.dp + padding.calculateBottomPadding())) { - if (scan.running) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp)) } - item { + // Keyed, so a row keeps its own scroll position when another appears above it: the scan bar comes and goes, + // and "Also reading" shows up once a second book is started. + if (scan.running) item(key = "scan") { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp)) } + item(key = "top") { BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { if (maxWidth >= 640.dp) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -144,18 +146,18 @@ fun HomeScreen(vm: LibraryViewModel, actions: AppActions) { } } if (reading.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.home_also_reading)) } - item { BookRow(reading, actions) } + item(key = "reading-title") { SectionHeader(stringResource(R.string.home_also_reading)) } + item(key = "reading") { BookRow(reading, actions) } } - item { SectionHeader(stringResource(R.string.home_recently_added), stringResource(R.string.see_all)) { actions.navigate(LibraryKey) } } - item { BookRow(recent, actions) } + item(key = "recent-title") { SectionHeader(stringResource(R.string.home_recently_added), stringResource(R.string.see_all)) { actions.navigate(LibraryKey) } } + item(key = "recent") { BookRow(recent, actions) } if (want.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.status_want)) } - item { BookRow(want, actions) } + item(key = "want-title") { SectionHeader(stringResource(R.string.status_want)) } + item(key = "want") { BookRow(want, actions) } } if (finished.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.status_finished)) } - item { BookRow(finished.take(20), actions) } + item(key = "finished-title") { SectionHeader(stringResource(R.string.status_finished)) } + item(key = "finished") { BookRow(finished.take(20), actions) } } } } diff --git a/app/src/main/java/com/readarea/ui/settings/SettingsScreen.kt b/app/src/main/java/com/readarea/ui/settings/SettingsScreen.kt index e278bef..908da3f 100644 --- a/app/src/main/java/com/readarea/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/readarea/ui/settings/SettingsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -105,7 +106,8 @@ fun SettingsScreen(vm: LibraryViewModel, actions: AppActions) { Spacer(Modifier.height(16.dp)) Text(stringResource(R.string.accent), style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Wraps: six swatches need 300dp, more than a narrow screen or a large display size leaves. + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Accents.colors.forEachIndexed { i, c -> val selected = s.accent == i && !s.dynamicColor Box( diff --git a/app/src/main/java/com/readarea/ui/stats/StatsScreen.kt b/app/src/main/java/com/readarea/ui/stats/StatsScreen.kt index 81f7546..7bd20c8 100644 --- a/app/src/main/java/com/readarea/ui/stats/StatsScreen.kt +++ b/app/src/main/java/com/readarea/ui/stats/StatsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -102,7 +103,10 @@ fun StatsScreen(vm: LibraryViewModel, @Suppress("UNUSED_PARAMETER") actions: App } } Text(stringResource(R.string.last_20_weeks), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 24.dp, bottom = 12.dp)) - Box(Modifier.clip(RoundedCornerShape(20.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(16.dp).horizontalScroll(rememberScrollState())) { + val heat = rememberScrollState() + // Opens on the latest weeks: they are the ones people look for, and the ones a phone would cut off. + LaunchedEffect(heat.maxValue) { heat.scrollTo(heat.maxValue) } + Box(Modifier.clip(RoundedCornerShape(20.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(16.dp).horizontalScroll(heat)) { val weeks = 20 val today = LocalDate.now() val start = today.minusDays((today.dayOfWeek.value - 1).toLong()).minusWeeks((weeks - 1).toLong()) From c9ba8aace669bd5ad80aa8cc8cb806505bbfb955 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:21:07 +0000 Subject: [PATCH 6/7] Shorten the README and move developer notes to docs/DEVELOPING.md The README now covers what a visitor needs: what ReadArea is, how to install it, getting started, features, privacy and a few questions. The layout, build, test and release notes moved to docs/DEVELOPING.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- README.md | 160 +++++++++------------------------------------ docs/DEVELOPING.md | 49 ++++++++++++++ 2 files changed, 81 insertions(+), 128 deletions(-) create mode 100644 docs/DEVELOPING.md diff --git a/README.md b/README.md index 71838a0..4ea985e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

ReadArea icon

+

ReadArea icon

# ReadArea @@ -7,158 +7,62 @@ **[⬇ Download the latest version](https://github.com/EL4CTEO/ReadArea/releases/latest)**

- - - - +Home screen +Library +Page curl +Vertical Japanese text

- - - - +Reading on a computer +Library in dark mode on a computer

## Install -Every release on the **[releases page](https://github.com/EL4CTEO/ReadArea/releases/latest)** has a download for each system. Updating is always the same: install the newer version over the old one. Your library, progress and notes are kept. +Each [release](https://github.com/EL4CTEO/ReadArea/releases/latest) has a download for every system. To update, install the new version over the old one: your library, progress and notes are kept. -### Android - -1. On your phone, open the latest release and tap `ReadArea-x.y.z.apk` to download it. -2. Open the downloaded file. If Android asks, allow your browser or file manager to **install unknown apps**. -3. Tap **Install**. If Play Protect warns about an unknown app, tap **Scan app**, or **More details → Install anyway**. - -Needs Android 8.0 or newer. - -### macOS - -1. Download `ReadArea-x.y.z-macos-arm64.dmg` for Apple silicon (M1 and later) or `ReadArea-x.y.z-macos-x64.dmg` for Intel Macs. -2. Open it and drag **ReadArea** into **Applications**. -3. If macOS says it can't check the app for malicious software, open **System Settings → Privacy & Security** and click **Open Anyway**, or right-click ReadArea in Applications and choose **Open**. You only need to do this once. - -### Windows - -1. Download `ReadArea-x.y.z-windows-x64.msi` and open it. It installs just for you, without administrator rights, and adds ReadArea to the Start menu. -2. If Windows shows *Windows protected your PC*, click **More info → Run anyway**. - -Prefer not to install? `ReadArea-x.y.z-windows-x64-portable.zip` runs from any folder: unzip it and open `ReadArea.exe`. - -### Linux - -- **Ubuntu, Debian, Mint:** `sudo apt install ./ReadArea-x.y.z-linux-x64.deb` -- **Fedora, openSUSE:** `sudo dnf install ./ReadArea-x.y.z-linux-x64.rpm` -- **Anything else:** unpack `ReadArea-x.y.z-linux-x64.tar.gz` and run `ReadArea/bin/ReadArea`. - -On ARM computers (like a Raspberry Pi 5 or an ARM laptop), pick the `linux-arm64` files instead. For read aloud, install `espeak-ng` or `speech-dispatcher` if your system doesn't have one. +- **Android** (8.0 or newer): download `ReadArea-x.y.z.apk`, open it and tap **Install**. If Android asks, allow your browser or file manager to *install unknown apps*. If Play Protect warns, tap **Scan app**, or **More details → Install anyway**. +- **macOS:** download the `.dmg` (`arm64` for Apple silicon, `x64` for Intel) and drag ReadArea into Applications. If macOS can't check the app, right-click it and choose **Open** (only the first time). +- **Windows:** download the `.msi` (installs just for you, no administrator rights) or the `-portable.zip` to run from any folder. If you see *Windows protected your PC*, click **More info → Run anyway**. +- **Linux:** `sudo apt install ./ReadArea-x.y.z-linux-x64.deb` (Ubuntu, Debian, Mint), `sudo dnf install ./ReadArea-x.y.z-linux-x64.rpm` (Fedora, openSUSE), or unpack the `.tar.gz` and run `ReadArea/bin/ReadArea`. Pick the `linux-arm64` files on ARM computers. For read aloud, install `espeak-ng` or `speech-dispatcher`. ## Getting started -**On Android** - -1. Tap **Find my books** and allow access. ReadArea lists every book on your phone, including downloads and files people sent you, and keeps the list up to date. -2. Prefer to choose? Tap **Add a folder** or **Open files** instead, or open a book from any file manager with **Open with → ReadArea**. -3. Tap a book to start reading. Tap the middle of a page to open the menu. -4. Close the app while reading and it opens straight back to your page next time. +**On your phone:** tap **Find my books** and allow access (or choose **Add a folder** or **Open files**). Tap a book to read, and tap the middle of a page for the menu. Close the app mid-book and it opens back on your page. -**On a computer** +**On a computer:** add your book folders in **Settings → Library folders** (subfolders included, new books picked up automatically), or drop books onto the window. Double-click a book to read. Turn pages with the arrow keys, the space bar, a click on either side or by dragging a corner. Click the middle of the page or press **Esc** for the tools. -1. Add the folders where you keep your books in **Settings → Library folders**. ReadArea finds every book in them, subfolders included, and keeps watching them for new ones. You can also drop books or folders onto the window. -2. Double-click a book to read. E-books (EPUB, MOBI, FB2, comics) also open straight from Finder, Explorer or your file manager. -3. Turn pages with the arrow keys, the space bar, a click on either side, or by dragging a page corner. Click the middle of the page or press **Esc** for the tools. -4. Widen the window and the book opens into two pages side by side. +## What you get -## What it can do +- **Reads like a book:** page curl (or slide, fade, scroll), themes from Paper to AMOLED black, your font, size and margins, and brightness with a warm light. +- **Notes and tools:** highlights in five colors, notes, bookmarks, search, footnote pop-ups, read aloud and hands-free page turning. +- **Your library:** real covers, shelves, favorites, ratings, filters, and reading stats with streaks and a daily goal. +- **Big screens:** two-page spreads on tablets, foldables and wide windows. On a computer, keyboard shortcuts, several books open at once and your system's voices for read aloud. +- **Your language:** 17 app languages, right-to-left books, and vertical Japanese and Chinese with furigana. +- **Light:** under 3 MB on Android. -- **Reads like a book:** a page curl that follows your finger (or slide, fade, scroll), themes from Paper to AMOLED black, your choice of font, size, spacing and margins, and in-book brightness with a warm light. -- **Notes and tools:** highlights in five colors, notes, bookmarks and export, full-text search, footnote pop-ups, read aloud with the sentence highlighted, and hands-free page turning. -- **Your library:** real covers from each file, shelves, favorites, ratings and reading status, search and filters, and reading stats with streaks and a daily goal. -- **Big screens:** two-page spreads on foldables, tablets and wide desktop windows, with text kept out of the fold. -- **On a computer:** keyboard shortcuts for everything, several books open at once, your system's voices for read aloud, and native look and dark mode. -- **Any language:** 17 app languages, right-to-left books and a mirrored app for Arabic, and vertical Japanese and Chinese with furigana. -- **Light:** under 3 MB on Android, nothing to install alongside the desktop app, and nothing redrawn while you read. - -## Supported formats - -| Books | Documents | Comics | -| --- | --- | --- | -| EPUB, MOBI / AZW, FB2 | PDF, Word (DOCX), LibreOffice (ODT), RTF, TXT, Markdown, HTML | CBZ | +**Formats:** EPUB, MOBI / AZW, FB2, PDF, Word (DOCX), LibreOffice (ODT), RTF, TXT, Markdown, HTML and comics (CBZ). ## Privacy ReadArea never connects to the internet. There are no ads, no tracking and no accounts. -- It only reads files: on Android your whole storage if you use **Find my books**, otherwise just the folders you choose. -- Removing a book from the library never deletes the file unless you tick the box to do so. -- Your library, progress and notes stay on your device. On Android they're only included in Android's backup when that backup is end-to-end encrypted with your screen lock, and when you move to a new phone with a cable or Quick Share. On a computer they're in a folder only your user account can open: - - macOS: `~/Library/Application Support/ReadArea` - - Windows: `%LOCALAPPDATA%\ReadArea` - - Linux: `~/.local/share/readarea` -- Books can't reach out: pictures only ever come from the book itself (or, for Markdown and HTML files, from the folder next to them), never from the web, and fonts inside books aren't loaded at all. -- Links inside books only open web pages and email, and always ask first, showing exactly where they go. -- Every book is treated as untrusted. A damaged or deliberately malicious file just fails to open; it can't hang or crash the app, or reach anything outside it. See [SECURITY.md](SECURITY.md). +- It only reads files: on Android your whole storage if you use **Find my books**, otherwise just the folders you choose. Removing a book never deletes its file unless you tick the box. +- Your library, progress and notes stay on your device. On Android they're backed up only when the backup is end-to-end encrypted with your screen lock, and carried over when you move to a new phone. +- Books can't reach out: their pictures come only from the book itself, and links ask before they open. A damaged or malicious file just fails to open. See [SECURITY.md](SECURITY.md). ## Questions -**Is it free?** Yes. It's open source under the MIT license. - -**A book won't open.** Books bought from stores like Kindle or Kobo are usually DRM-protected, and no other reader app can open those. - -**Where are my notes?** Only on your device. You can export them from the **Notes** tab. - -**Can I change the app language?** Yes: **Settings → App language**. By default it follows your phone or computer. - -**How do I remove the desktop app completely?** Uninstall it like any other app (drag it to the Trash on macOS, *Settings → Apps* on Windows, your package manager on Linux), then delete the data folder listed under Privacy. **Settings → Data folder** shows it. - -
-For developers - -### Layout - -``` -core/ Pure Kotlin, shared by every platform: the format parsers (EPUB, FB2, MOBI, DOCX, ODT, RTF, - Markdown, TXT, HTML), the text and offset model behind progress, search, notes and read aloud, - opening books from files, library rules and the reading themes. -app/ The Android app: Jetpack Compose, Room, a custom page engine and page-curl view. -desktop/ The macOS, Windows and Linux app: Swing with FlatLaf, a Java2D page engine (hyphenation, - justification, bidi, ruby, vertical CJK), page curl, PDFBox and SQLite. -build-logic/ Gradle plugin that turns the desktop app into native installers. -``` - -Both apps read books through the same parsers into the same block model and measure positions in the same text offsets, so a highlight or bookmark means the same thing everywhere. - -### Build and run - -Needs JDK 21. The Android app also needs the Android SDK with platform 37; without one, the build simply leaves the Android module out. - -```bash -./gradlew :desktop:run # the desktop app, with a separate development library -./gradlew :desktop:packageDesktop # installers for this system, in desktop/build/package/dist -./gradlew :app:assembleDebug # the Android app -``` - -Installers are built with the JDK's jpackage on each system: dmg on macOS, msi on Windows (needs WiX 3), deb and rpm on Linux (need `fakeroot` and `rpm`). Each build trims a Java runtime to what the app uses, checks the packaged app with `ReadArea --self-test` through its own launcher, and records a class-data archive for faster start-up (on Linux this needs a display, e.g. `xvfb-run`). - -### Tests - -```bash -./gradlew :core:test # parsers, text model, library rules -xvfb-run -a ./gradlew :desktop:test # engine, rendering, UI walkthroughs, security -./gradlew :app:testDebugUnitTest # Android -READAREA_BENCH=1 ./gradlew :desktop:test --tests '*PerfBench*' -i # timings, on demand -``` - -The desktop UI tests drive the real app on a virtual display, in English and in Arabic, and save screenshots of every screen to `desktop/build/qa-screens` and `qa-screens-rtl`. The security tests attack the parsers with hostile files (decompression bombs, deep nesting, malformed headers) and fuzz every format with thousands of damaged books. Rendering tests write PNGs to `app/build/screens` and `desktop/build/screens`. +**Is it free?** Yes, and open source under the MIT license. -`READAREA_TRACE_STARTUP=1` makes the desktop app print its start-up timings. +**A book won't open.** Books bought from stores like Kindle or Kobo are usually DRM-protected, and no other reader can open those either. -### Release +**Where are my notes?** Only on your device. Export them from the **Notes** tab. -Running the **Release** workflow (Actions → Release → Run workflow) or pushing a tag like `v1.2.0` builds the signed APK and the desktop installers for every system, and publishes them with checksums as a GitHub Release. The Android version code is `major * 10000 + minor * 100 + patch`, so every newer version installs as an update. +**Can I change the language?** Yes: **Settings → App language**. It follows your phone or computer by default. -Repository secrets (Settings → Secrets and variables → Actions): +**How do I remove the desktop app completely?** Uninstall it like any other app, then delete the data folder shown in **Settings → Data folder**: `~/Library/Application Support/ReadArea` on macOS, `%LOCALAPPDATA%\ReadArea` on Windows, `~/.local/share/readarea` on Linux. -- Android, required: `KEYSTORE_BASE64` (the keystore, base64-encoded), `KEYSTORE_PASSWORD`, `KEY_ALIAS` and `KEY_PASSWORD`. Keep a backup of the keystore: Android only accepts updates signed with the same key. -- macOS, optional: with `MACOS_CERTIFICATE` (a Developer ID Application certificate as base64 .p12), `MACOS_CERTIFICATE_PASSWORD`, `MACOS_SIGNING_IDENTITY` (e.g. `Jane Doe (TEAMID)`), `APPLE_ID`, `APPLE_TEAM_ID` and `APPLE_APP_PASSWORD`, the Mac apps are signed and notarized, so they open without warnings. +## Build it yourself -
+You need JDK 21 (and the Android SDK for the phone app). `./gradlew :desktop:run` starts the desktop app, `./gradlew :desktop:packageDesktop` builds an installer for your system and `./gradlew :app:assembleDebug` builds the Android app. More in [docs/DEVELOPING.md](docs/DEVELOPING.md). diff --git a/docs/DEVELOPING.md b/docs/DEVELOPING.md new file mode 100644 index 0000000..176477d --- /dev/null +++ b/docs/DEVELOPING.md @@ -0,0 +1,49 @@ +# Developing ReadArea + +## Layout + +``` +core/ Pure Kotlin, shared by every platform: the format parsers (EPUB, FB2, MOBI, DOCX, ODT, RTF, + Markdown, TXT, HTML), the text and offset model behind progress, search, notes and read aloud, + opening books from files, library rules and the reading themes. +app/ The Android app: Jetpack Compose, Room, a custom page engine and page-curl view. +desktop/ The macOS, Windows and Linux app: Swing with FlatLaf, a Java2D page engine (hyphenation, + justification, bidi, ruby, vertical CJK), page curl, PDFBox and SQLite. +build-logic/ Gradle plugin that turns the desktop app into native installers. +``` + +Both apps read books through the same parsers into the same block model and measure positions in the same text offsets, so a highlight or bookmark means the same thing everywhere. + +## Build and run + +Needs JDK 21. The Android app also needs the Android SDK with platform 37; without one, the build simply leaves the Android module out. + +```bash +./gradlew :desktop:run # the desktop app, with a separate development library +./gradlew :desktop:packageDesktop # installers for this system, in desktop/build/package/dist +./gradlew :app:assembleDebug # the Android app +``` + +Installers are built with the JDK's jpackage on each system: dmg on macOS, msi on Windows (needs WiX 3), deb and rpm on Linux (need `fakeroot` and `rpm`). Each build trims a Java runtime to what the app uses, checks the packaged app with `ReadArea --self-test` through its own launcher, and records a class-data archive for faster start-up (on Linux this needs a display, e.g. `xvfb-run`). + +## Tests + +```bash +./gradlew :core:test # parsers, text model, library rules +xvfb-run -a ./gradlew :desktop:test # engine, rendering, UI walkthroughs, security +./gradlew :app:testDebugUnitTest # Android +READAREA_BENCH=1 ./gradlew :desktop:test --tests '*PerfBench*' -i # timings, on demand +``` + +The desktop UI tests drive the real app on a virtual display, in English and in Arabic, and save screenshots of every screen to `desktop/build/qa-screens` and `qa-screens-rtl`. The security tests attack the parsers with hostile files (decompression bombs, deep nesting, malformed headers) and fuzz every format with thousands of damaged books. Rendering tests write PNGs to `app/build/screens` and `desktop/build/screens`. + +`READAREA_TRACE_STARTUP=1` makes the desktop app print its start-up timings. + +## Release + +Running the **Release** workflow (Actions → Release → Run workflow) or pushing a tag like `v1.2.0` builds the signed APK and the desktop installers for every system, and publishes them with checksums as a GitHub Release. The Android version code is `major * 10000 + minor * 100 + patch`, so every newer version installs as an update. + +Repository secrets (Settings → Secrets and variables → Actions): + +- Android, required: `KEYSTORE_BASE64` (the keystore, base64-encoded), `KEYSTORE_PASSWORD`, `KEY_ALIAS` and `KEY_PASSWORD`. Keep a backup of the keystore: Android only accepts updates signed with the same key. +- macOS, optional: with `MACOS_CERTIFICATE` (a Developer ID Application certificate as base64 .p12), `MACOS_CERTIFICATE_PASSWORD`, `MACOS_SIGNING_IDENTITY` (e.g. `Jane Doe (TEAMID)`), `APPLE_ID`, `APPLE_TEAM_ID` and `APPLE_APP_PASSWORD`, the Mac apps are signed and notarized, so they open without warnings. From b734fc374325b8f07fb610ba2296fb8773d01e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:32:27 +0000 Subject: [PATCH 7/7] Test that a wide setting control stacks under its title Covers the desktop SettingRow: a small control stays beside its title, a control too wide to leave the title 150px goes under it, the boundary between the two, and the mirrored layout for right-to-left languages. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FjEn8xjjwh6yk6MZwhSD2V --- .../com/readarea/desktop/ui/SettingRowTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 desktop/src/test/kotlin/com/readarea/desktop/ui/SettingRowTest.kt diff --git a/desktop/src/test/kotlin/com/readarea/desktop/ui/SettingRowTest.kt b/desktop/src/test/kotlin/com/readarea/desktop/ui/SettingRowTest.kt new file mode 100644 index 0000000..c51265d --- /dev/null +++ b/desktop/src/test/kotlin/com/readarea/desktop/ui/SettingRowTest.kt @@ -0,0 +1,71 @@ +package com.readarea.desktop.ui + +import com.readarea.desktop.ui.components.SettingRow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.awt.ComponentOrientation +import java.awt.Dimension +import javax.swing.JComponent + +class SettingRowTest { + /** A stand-in of a known size, like a title with its hint or a control. */ + private fun part(w: Int, h: Int) = object : JComponent() { + init { + preferredSize = Dimension(w, h) + } + } + + private class Setup(val row: SettingRow, val title: JComponent, val control: JComponent) + + // A 200x36 title block and a control of the given width, in a row given [width], laid out as a parent would: + // first told the width, then asked how tall it wants to be, then given that height. + private fun setup(width: Int, controlWidth: Int, rtl: Boolean = false): Setup { + val title = part(200, 36) + val control = part(controlWidth, 30) + val row = SettingRow(title, control) + if (rtl) row.componentOrientation = ComponentOrientation.RIGHT_TO_LEFT + row.setSize(width, 0) + row.setSize(width, row.preferredSize.height) + row.doLayout() + return Setup(row, title, control) + } + + @Test + fun aSmallControlSitsBesideItsTitle() { + val s = setup(width = 500, controlWidth = 100) + assertEquals("the control ends at the trailing edge", 500, s.control.x + s.control.width) + assertTrue(s.title.x + s.title.width <= s.control.x) + assertEquals("one row, as tall as the taller part", 36, s.row.preferredSize.height) + assertEquals("the control is centered against the title", 3, s.control.y) + } + + @Test + fun aWideControlGoesUnderItsTitleInsteadOfSqueezingItAway() { + val s = setup(width = 400, controlWidth = 300) + assertEquals("the title keeps the whole width", 400, s.title.width) + assertTrue("the control is under the title", s.title.y + s.title.height <= s.control.y) + assertEquals("at the leading edge", 0, s.control.x) + assertEquals("two rows", 36 + 8 + 30, s.row.preferredSize.height) + } + + @Test + fun theTitleAlwaysKeepsAReadableWidthBesideTheControl() { + // 300 for the control, 16 between and 150 for the title is the least that still sits on one line. + val fits = setup(width = 466, controlWidth = 300) + assertEquals(150, fits.title.width) + assertTrue(fits.title.x + fits.title.width <= fits.control.x) + assertEquals(36, fits.row.preferredSize.height) + val tooNarrow = setup(width = 465, controlWidth = 300) + assertEquals("one pixel less and it stacks", 36 + 8 + 30, tooNarrow.row.preferredSize.height) + } + + @Test + fun aRightToLeftRowIsTheMirrorImage() { + val s = setup(width = 500, controlWidth = 100, rtl = true) + assertEquals("the control is at the left", 0, s.control.x) + assertEquals("the title starts at the right", 500, s.title.x + s.title.width) + val stacked = setup(width = 400, controlWidth = 300, rtl = true) + assertEquals("stacked, the control lines up with the title at the right", 400, stacked.control.x + stacked.control.width) + } +}