diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dad6550..cb7a4b1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,25 @@ +## LightBooks v1.19 — Your book first, colour on black, and the mode you left in + +**The shelf puts what you're reading first.** Opening a book moves it (or its series stack) to the +front. Long-press a book for **PIN TO TOP** to keep it there no matter what else you open. + +**Three books per row.** Settings → Appearance → "Books per row". Two is still the default. Three +fits more of the shelf on the LP3's short screen at the cost of smaller covers. + +**Colour accents in the Light Phone theme.** Settings → Appearance → "Colour accents", shown when the +theme is Light Phone. The surfaces stay true black and the ORP letter and chapter titles keep the +colours you picked, for phones that show colour (Luma, for one). Want grey? Set saturation to 0. On a +stock LP3 with the `WRITE_SECURE_SETTINGS` grant, the reader lifts the system greyscale so the colour +is visible. Without the grant it stays grey, same as before. + +**Open in last mode.** Settings → Opening a book. Off by default. When it's on, a book opens in +whichever reader you last switched to, FastRead or the page view. Back from FastRead then leaves the +book instead of stopping at the pages first. + +Thanks to the Discord feedback that asked for all four. + +--- + ## LightBooks v1.18 — Volume down turns forward **The volume keys turn the other way now.** Down is forward, up is back. v1.17 matched them to the diff --git a/app/src/main/java/com/lightfastread/MainActivity.kt b/app/src/main/java/com/lightfastread/MainActivity.kt index be1c86d..11c0878 100644 --- a/app/src/main/java/com/lightfastread/MainActivity.kt +++ b/app/src/main/java/com/lightfastread/MainActivity.kt @@ -25,6 +25,9 @@ import com.lightfastread.hw.VolumeBus import com.lightfastread.hw.volumePageStep import com.lightfastread.calibre.ProgressSync import com.lightfastread.data.BookKind +import com.lightfastread.data.BookRepository +import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.remember import com.lightfastread.ui.comic.ComicReader import android.net.Uri import android.content.Intent @@ -210,6 +213,9 @@ class MainActivity : ComponentActivity() { @androidx.compose.runtime.Composable private fun AppNav() { val navController = rememberNavController() + val appContext = LocalContext.current.applicationContext + // Opening a book stamps it, so the shelf puts what you are reading first. + val bookRepo = remember { BookRepository.get(appContext) } NavHost(navController = navController, startDestination = "home") { composable("home") { HomeScreen( @@ -217,6 +223,7 @@ private fun AppNav() { // ReaderScreen: a comic has no words to load, so the text reader should never be // built for one at all. onOpenBook = { book -> + bookRepo.markOpened(book.id) val route = if (book.kind == BookKind.Comic) "comic" else "reader" navController.navigate("$route/${book.id}") }, @@ -238,6 +245,7 @@ private fun AppNav() { SeriesShelf( seriesKey = Uri.decode(backStackEntry.arguments?.getString("seriesKey").orEmpty()), onOpenBook = { book -> + bookRepo.markOpened(book.id) val route = if (book.kind == BookKind.Comic) "comic" else "reader" navController.navigate("$route/${book.id}") }, diff --git a/app/src/main/java/com/lightfastread/data/Book.kt b/app/src/main/java/com/lightfastread/data/Book.kt index 4b76d19..cba2d06 100644 --- a/app/src/main/java/com/lightfastread/data/Book.kt +++ b/app/src/main/java/com/lightfastread/data/Book.kt @@ -37,6 +37,10 @@ data class Book( val totalWords: Int, val currentWordIndex: Int = 0, val addedAtMs: Long = System.currentTimeMillis(), + /** When the book was last opened; 0 if never. The shelf sorts on this so what you're reading is first. */ + val lastOpenedAtMs: Long = 0L, + /** Pinned books sit at the front of the shelf ahead of everything else, newest-read first. */ + val pinned: Boolean = false, val chapters: List = emptyList(), val bookmarks: List = emptyList(), /** diff --git a/app/src/main/java/com/lightfastread/data/BookRepository.kt b/app/src/main/java/com/lightfastread/data/BookRepository.kt index 7ad7713..aba709b 100644 --- a/app/src/main/java/com/lightfastread/data/BookRepository.kt +++ b/app/src/main/java/com/lightfastread/data/BookRepository.kt @@ -179,6 +179,21 @@ class BookRepository private constructor(private val appContext: Context) { fun getBook(id: String): Book? = books.firstOrNull { it.id == id } + /** Stamp a book as just opened, which moves it (or its stack) to the front of the shelf. */ + fun markOpened(id: String) { + val idx = books.indexOfFirst { it.id == id } + if (idx < 0) return + books[idx] = books[idx].copy(lastOpenedAtMs = System.currentTimeMillis()) + persist() + } + + fun setPinned(id: String, pinned: Boolean) { + val idx = books.indexOfFirst { it.id == id } + if (idx < 0 || books[idx].pinned == pinned) return + books[idx] = books[idx].copy(pinned = pinned) + persist() + } + fun updateProgress(id: String, wordIndex: Int) { val idx = books.indexOfFirst { it.id == id } if (idx < 0) return diff --git a/app/src/main/java/com/lightfastread/data/Settings.kt b/app/src/main/java/com/lightfastread/data/Settings.kt index 19e5360..15cb6b5 100644 --- a/app/src/main/java/com/lightfastread/data/Settings.kt +++ b/app/src/main/java/com/lightfastread/data/Settings.kt @@ -121,4 +121,22 @@ data class Settings( * phone for someone who did not, so it is opt-in. Volume down goes forward, as on any e-reader. */ val volumeKeysTurnPages: Boolean = false, + /** + * Keep the title and ORP accents in the colours picked, even in the Light Phone theme. + * + * Off by default: on a stock LP3 the panel is held greyscale, and the default red ORP arrives + * as a grey dark enough to vanish, which is why the theme flattens accents with a lifted floor. + * On a phone already running in colour (Luma, or the adb grant below) that flattening throws + * away exactly what was asked for — so this keeps the true-black surfaces and the hues together. + * Want grey anyway? Set saturation to 0. + */ + val lightPhoneColorAccents: Boolean = false, + /** Covers per shelf row: 2 (the default, art worth looking at) or 3 (more of the shelf on screen). */ + val shelfColumns: Int = 2, + /** + * Open a book in whichever reader was last used — FastRead or the page view — instead of + * always the page view. Off by default; [lastModeFastRead] is what it remembers. + */ + val openInLastMode: Boolean = false, + val lastModeFastRead: Boolean = false, ) diff --git a/app/src/main/java/com/lightfastread/data/Shelf.kt b/app/src/main/java/com/lightfastread/data/Shelf.kt index 99cb95d..a712798 100644 --- a/app/src/main/java/com/lightfastread/data/Shelf.kt +++ b/app/src/main/java/com/lightfastread/data/Shelf.kt @@ -100,6 +100,18 @@ object Shelf { return out } + /** + * The order the shelf is drawn in: pinned books first, then by whichever is later — when the + * book was last opened or when it was added. Feed the result to [entries], which keeps the + * order it is given, so a stack sits wherever its most recent volume does. + * + * Stable, so books with equal timestamps keep the order they were stored in. + */ + fun ordered(books: List): List = books.sortedWith( + compareByDescending { it.pinned } + .thenByDescending { maxOf(it.lastOpenedAtMs, it.addedAtMs) } + ) + /** The volumes of one stack, for the series screen. Empty when nothing matches any more. */ fun volumesOf(books: List, seriesKey: String): List = (entries(books).firstOrNull { diff --git a/app/src/main/java/com/lightfastread/ui/home/BookActions.kt b/app/src/main/java/com/lightfastread/ui/home/BookActions.kt index 068c808..4e25dab 100644 --- a/app/src/main/java/com/lightfastread/ui/home/BookActions.kt +++ b/app/src/main/java/com/lightfastread/ui/home/BookActions.kt @@ -43,6 +43,8 @@ fun BookActions( onFlipDirection: () -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, + /** Null where pinning means nothing (inside a series); the shelf passes it. */ + onTogglePin: (() -> Unit)? = null, ) { val colors = LightThemeTokens.colors var confirmingDelete by remember { mutableStateOf(false) } @@ -84,6 +86,9 @@ fun BookActions( onClick = onFlipDirection, ) ActionRow(text = "FIX NAME", onClick = onRename) + if (onTogglePin != null) { + ActionRow(text = if (book.pinned) "UNPIN" else "PIN TO TOP", onClick = onTogglePin) + } ActionRow(text = "DELETE", onClick = { confirmingDelete = true }) ActionRow(text = "CANCEL", lighten = true, onClick = onDismiss) } else { @@ -91,6 +96,9 @@ fun BookActions( // Directly under FIND COVER, because it is the thing to try when that keeps // failing: a lookup is only as good as the title it is given. ActionRow(text = "FIX NAME", onClick = onRename) + if (onTogglePin != null) { + ActionRow(text = if (book.pinned) "UNPIN" else "PIN TO TOP", onClick = onTogglePin) + } ActionRow(text = "DELETE", onClick = { confirmingDelete = true }) ActionRow(text = "CANCEL", lighten = true, onClick = onDismiss) } diff --git a/app/src/main/java/com/lightfastread/ui/home/HomeScreen.kt b/app/src/main/java/com/lightfastread/ui/home/HomeScreen.kt index 71d9ca2..409df31 100644 --- a/app/src/main/java/com/lightfastread/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/lightfastread/ui/home/HomeScreen.kt @@ -90,7 +90,7 @@ fun HomeScreen( // Recomputed whenever the shelf changes, which is cheap: a parse of each title and a group-by, // over a list that is tens of items long, not thousands. - val entries = remember(books.toList()) { Shelf.entries(books.toList()) } + val entries = remember(books.toList()) { Shelf.entries(Shelf.ordered(books.toList())) } var importing by remember { mutableStateOf(false) } var importStep by remember { mutableStateOf(null) } var status by remember { mutableStateOf(null) } @@ -221,7 +221,7 @@ fun HomeScreen( } } else { LazyVerticalGrid( - columns = GridCells.Fixed(COLUMNS), + columns = GridCells.Fixed(shelfColumns(settings)), state = gridState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -337,6 +337,10 @@ fun HomeScreen( "“${book.title}” now reads right to left" } }, + onTogglePin = { + actionsFor = null + repo.setPinned(book.id, !book.pinned) + }, onDelete = { actionsFor = null repo.deleteBook(book) diff --git a/app/src/main/java/com/lightfastread/ui/home/SeriesShelf.kt b/app/src/main/java/com/lightfastread/ui/home/SeriesShelf.kt index e783bf1..4b43c98 100644 --- a/app/src/main/java/com/lightfastread/ui/home/SeriesShelf.kt +++ b/app/src/main/java/com/lightfastread/ui/home/SeriesShelf.kt @@ -98,7 +98,7 @@ fun SeriesShelf( ) } else { LazyVerticalGrid( - columns = GridCells.Fixed(COLUMNS), + columns = GridCells.Fixed(shelfColumns(settings)), state = gridState, modifier = Modifier.fillMaxSize().navigationBarsPadding(), contentPadding = PaddingValues( diff --git a/app/src/main/java/com/lightfastread/ui/home/ShelfCells.kt b/app/src/main/java/com/lightfastread/ui/home/ShelfCells.kt index 1b71a0d..7f7284b 100644 --- a/app/src/main/java/com/lightfastread/ui/home/ShelfCells.kt +++ b/app/src/main/java/com/lightfastread/ui/home/ShelfCells.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.lightfastread.data.Book +import com.lightfastread.data.Settings import com.lightfastread.data.ShelfEntry import com.lightfastread.ui.light.LightText import com.lightfastread.ui.light.LightTextVariant @@ -33,8 +34,12 @@ import com.lightfastread.ui.light.lightCombinedClickable * file — a series should look exactly like the shelf it came from, because it *is* one. */ -/** Two to a row, as on the main shelf. Anything more and the art stops being worth showing. */ -internal const val COLUMNS = 2 +/** + * Covers per row. Two by default — the art is worth showing — or three, which gets a second full + * row onto the LP3's short screen at the cost of smaller covers. Clamped so a hand-edited or + * future value can't produce a shelf of slivers. + */ +internal fun shelfColumns(settings: Settings): Int = settings.shelfColumns.coerceIn(2, 3) /** Book covers are 2:3, near enough to every trade paperback ever printed. */ internal const val COVER_ASPECT = 2f / 3f diff --git a/app/src/main/java/com/lightfastread/ui/reader/EreaderScreen.kt b/app/src/main/java/com/lightfastread/ui/reader/EreaderScreen.kt index 35a45cc..c176147 100644 --- a/app/src/main/java/com/lightfastread/ui/reader/EreaderScreen.kt +++ b/app/src/main/java/com/lightfastread/ui/reader/EreaderScreen.kt @@ -185,6 +185,8 @@ fun EreaderScreen( onProgress: (wordIndex: Int) -> Unit, /** Hand the reader over to the RSVP screen, at the page currently showing. */ onFastRead: (wordIndex: Int) -> Unit, + /** The book laid out to zero pages; hand over to RSVP. Not a user choice, so kept separate. */ + onCannotPaginate: (wordIndex: Int) -> Unit = onFastRead, /** Leave the book entirely, at the page currently showing. */ onExit: (wordIndex: Int) -> Unit, ) { @@ -396,7 +398,7 @@ fun EreaderScreen( // Nothing to page through. Falling back to the word reader is better // than throwing the reader out to the shelf: there is still text, it // just could not be laid out into pages at this size. - onFastRead(anchor) + onCannotPaginate(anchor) return@LaunchedEffect } PageCache.put(cacheKey, built) diff --git a/app/src/main/java/com/lightfastread/ui/reader/ReaderScreen.kt b/app/src/main/java/com/lightfastread/ui/reader/ReaderScreen.kt index e976b48..eb5753f 100644 --- a/app/src/main/java/com/lightfastread/ui/reader/ReaderScreen.kt +++ b/app/src/main/java/com/lightfastread/ui/reader/ReaderScreen.kt @@ -55,6 +55,7 @@ import com.lightfastread.data.SwipeMode import com.lightfastread.ui.light.LightText import com.lightfastread.ui.light.LightTextVariant import com.lightfastread.ui.theme.LocalIsLightPhone +import com.lightfastread.ui.light.ColourEffect import com.lightfastread.ui.theme.toLightPhoneGrey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -86,13 +87,21 @@ fun ReaderScreen( // Flatten them here instead of at the draw site, so the emulator preview // matches the device exactly and low-luma picks (the default red ORP) don't // vanish against the dimmed context lines. + // + // Unless the user has asked for colour accents: then the surfaces stay true black and the + // hues come through as picked, for phones that show colour (Luma, or the adb grant). val isLightPhone = LocalIsLightPhone.current + val flattenAccents = isLightPhone && !settings.lightPhoneColorAccents val titleColor = Color(settings.titleColorArgb).let { - if (isLightPhone) it.toLightPhoneGrey() else it + if (flattenAccents) it.toLightPhoneGrey() else it } val orpColor = Color(settings.orpColorArgb).let { - if (isLightPhone) it.toLightPhoneGrey(floor = 0.70f) else it + if (flattenAccents) it.toLightPhoneGrey(floor = 0.70f) else it } + // On a stock LP3 with the WRITE_SECURE_SETTINGS grant, lift the system greyscale while + // reading so the accents are actually visible. A no-op on a phone that is already in colour, + // and without the grant. + ColourEffect(enabled = isLightPhone && settings.lightPhoneColorAccents) if (book == null) { LaunchedEffect(Unit) { onBack() } @@ -114,7 +123,27 @@ fun ReaderScreen( // A book opens to its pages, not to the word reader. FastRead is a button in the page // view's top bar, which is itself hidden until you tap the screen — so the app you get // when you open a book is an e-reader, and RSVP is a mode you ask for. - var showEreader by rememberSaveable { mutableStateOf(true) } + // + // Unless "open in last mode" is on: then it opens in whichever the user last switched to. + var showEreader by rememberSaveable { + mutableStateOf(!(settings.openInLastMode && settings.lastModeFastRead)) + } + // Only explicit switches are remembered — not the fallback to RSVP when a book can't be + // paginated, which is about that book rather than a preference. + val rememberMode: (fastRead: Boolean) -> Unit = { fastRead -> + if (settings.openInLastMode && settings.lastModeFastRead != fastRead) { + settingsRepo.update { it.copy(lastModeFastRead = fastRead) } + } + } + // With last-mode on, RSVP is the reader's home rather than a detour from the pages, so back + // leaves the book instead of stepping through the page view (which would also be a switch). + val backFromRsvp: () -> Unit = { + if (settings.openInLastMode) { + onBack() + } else { + showEreader = true + } + } val historyBack = rememberSaveable(bookId, saver = IntListSaver) { mutableStateListOf() } val historyForward = rememberSaveable(bookId, saver = IntListSaver) { mutableStateListOf() } val bookmarks = bookRepo.books.firstOrNull { it.id == bookId }?.bookmarks ?: emptyList() @@ -123,7 +152,7 @@ fun ReaderScreen( // The system gesture agrees with the top bar's own arrow. While the page view is up it owns // back itself (it is a Dialog, with its own window), so this only ever fires in RSVP. - BackHandler(enabled = !showEreader) { showEreader = true } + BackHandler(enabled = !showEreader) { backFromRsvp() } val jumpTo: (Int) -> Unit = { target -> val safe = target.coerceIn(0, (words.size - 1).coerceAtLeast(0)) @@ -402,7 +431,10 @@ fun ReaderScreen( } } != null if (releasedInTime) { - if (!moved) showEreader = true + if (!moved) { + rememberMode(false) + showEreader = true + } return@awaitEachGesture } // Still held past the tap window: fall through to @@ -573,7 +605,7 @@ fun ReaderScreen( // Back out of the word reader is back to the *pages*, not out of the // book: the page view is the book's home screen now, and leaving from // there is one more press. - onBack = { showEreader = true }, + onBack = backFromRsvp, onQuickSettings = { showQuickSettings = true }, ) } @@ -656,6 +688,11 @@ fun ReaderScreen( bookRepo.updateProgress(bookId, currentIndex) }, onFastRead = { newIndex -> + rememberMode(true) + showEreader = false + jumpTo(newIndex) + }, + onCannotPaginate = { newIndex -> showEreader = false jumpTo(newIndex) }, diff --git a/app/src/main/java/com/lightfastread/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lightfastread/ui/settings/SettingsScreen.kt index 2f56623..09922e3 100644 --- a/app/src/main/java/com/lightfastread/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lightfastread/ui/settings/SettingsScreen.kt @@ -395,6 +395,15 @@ fun SettingsScreen(onBack: () -> Unit) { underline = s.titleStyle != TitleStyle.Color, ) + SectionTitle("Opening a book") + ToggleRow( + label = "Open in last mode", + caption = "Books open in FastRead or the page view, whichever you switched to " + + "last. Back from FastRead then leaves the book.", + checked = s.openInLastMode, + onToggle = { v -> repo.update { it.copy(openInLastMode = v) } }, + ) + SectionTitle("Appearance") RowLabel("Theme") Caption( @@ -409,6 +418,31 @@ fun SettingsScreen(onBack: () -> Unit) { onClick = { repo.update { it.copy(themeMode = mode) } }, ) } + if (s.themeMode == ThemeMode.LightPhone) { + Spacer(Modifier.height(12f.designVerticalPxToDp())) + ToggleRow( + label = "Colour accents", + caption = "Keep true black but show the ORP and title colours as picked. " + + "For phones that display colour. Set saturation to 0 for grey.", + checked = s.lightPhoneColorAccents, + onToggle = { v -> repo.update { it.copy(lightPhoneColorAccents = v) } }, + ) { + if (s.lightPhoneColorAccents && !ColorMode.granted(context) && + !ColorMode.phoneIsColour(context) + ) { + Caption("This phone is greyscale. Grant the permission below, or the colours show as grey.") + } + } + } + Spacer(Modifier.height(12f.designVerticalPxToDp())) + RowLabel("Books per row") + listOf(2, 3).forEach { n -> + ChoiceRow( + label = "$n", + selected = s.shelfColumns.coerceIn(2, 3) == n, + onClick = { repo.update { it.copy(shelfColumns = n) } }, + ) + } Spacer(Modifier.height(12f.designVerticalPxToDp())) ToggleRow( label = "Colour covers", diff --git a/app/src/test/java/com/lightfastread/ShelfSeriesTest.kt b/app/src/test/java/com/lightfastread/ShelfSeriesTest.kt index e8cd79a..d69fa1a 100644 --- a/app/src/test/java/com/lightfastread/ShelfSeriesTest.kt +++ b/app/src/test/java/com/lightfastread/ShelfSeriesTest.kt @@ -115,4 +115,23 @@ class ShelfSeriesTest { assertEquals("BECK", Shelf.nameOf(books, key)) assertTrue(Shelf.volumesOf(books, SeriesTitle.key("Nothing")).isEmpty()) } + + @Test + fun `pinned first, then most recently opened or added`() { + val old = book("Old").copy(addedAtMs = 100) + val opened = book("Opened").copy(addedAtMs = 50, lastOpenedAtMs = 500) + val new = book("New").copy(addedAtMs = 300) + val pinned = book("Pinned").copy(addedAtMs = 10, pinned = true) + val order = Shelf.ordered(listOf(new, old, opened, pinned)).map { it.title } + assertEquals(listOf("Pinned", "Opened", "New", "Old"), order) + } + + @Test + fun `a stack follows its most recently opened volume`() { + val loose = book("Dune").copy(addedAtMs = 400) + val v1 = book("BECK, Vol. 1").copy(addedAtMs = 100) + val v2 = book("BECK, Vol. 2").copy(addedAtMs = 110, lastOpenedAtMs = 900) + val entries = Shelf.entries(Shelf.ordered(listOf(loose, v1, v2))) + assertTrue(entries.first() is ShelfEntry.Stack) + } }