Reading progress, persist library mode and improve scan feedback

This commit is contained in:
Sergey Chernov 2026-05-31 12:39:13 +03:00
parent e8b18ec472
commit dbf63c351c
13 changed files with 599 additions and 177 deletions

View File

@ -473,6 +473,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
}
}
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@ -485,6 +497,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
}
}
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
}
}
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(LibraryFilterFlag, filter.name)
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
@ -868,6 +894,8 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -27,13 +27,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import net.sergeych.toread.fb2.Fb2Format
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DefaultToastDurationMillis = 1_600L
private const val DeleteUndoDurationMillis = 5_000L
private const val ScanResultToastDurationMillis = 5_000L
private const val BackgroundLibraryPageSize = 50
private data class AppToastData(
@ -220,7 +220,12 @@ private fun BookReaderApp(
is AppState.BookInfo -> current.scanPath
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
}
state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary)
state = AppState.Library(
emptyList(),
scanPath,
requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary,
loadLibraryFilter(),
)
continue
}
val request = requestResult.getOrNull() ?: continue
@ -232,7 +237,7 @@ private fun BookReaderApp(
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
}
val nextState = runCatching {
val book = Fb2Format.parse(request.bytes, request.displayName)
val book = parseBookInBackground(request.bytes, request.displayName)
saveActiveReadingFileId(request.id)
AppState.Reader(
fileId = request.id,
@ -241,11 +246,11 @@ private fun BookReaderApp(
scanPath = scanPath,
)
}.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter())
}
libraryBackState = when (val current = state) {
is AppState.Library -> current
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
is AppState.Reader, is AppState.BookInfo -> libraryBackState
is AppState.Error, AppState.LoadingStartup -> null
}
@ -362,7 +367,7 @@ private fun BookReaderApp(
downloadsRescanRequestGeneration += 1
backState
}
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
is AppState.Error -> AppState.LoadingStartup
is AppState.Library, AppState.LoadingStartup -> current
}
@ -380,7 +385,23 @@ private fun BookReaderApp(
onBack = ::navigateBack,
)
fun startScan(path: String) {
fun setLibraryFilter(filter: LibraryFilter) {
state = when (val current = state) {
is AppState.Library -> current.copy(selectedFilter = filter)
is AppState.Scan -> current.copy(selectedFilter = filter)
else -> current
}
libraryBackState = libraryBackState?.copy(selectedFilter = filter)
}
fun saveAndSetLibraryFilter(filter: LibraryFilter) {
setLibraryFilter(filter)
scope.launch {
saveLibraryFilter(filter)
}
}
fun startScan(path: String, userInitiated: Boolean) {
if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0)
scanJob = scope.launch {
@ -400,19 +421,34 @@ private fun BookReaderApp(
},
onFailure = { it.message ?: strings.scanFailed },
)
val importedFiles = report.getOrNull()?.importedFiles
when {
importedFiles == null -> Unit
importedFiles > 0 -> {
onShowToast(
strings.newBooksAdded(importedFiles),
strings.show,
{ saveAndSetLibraryFilter(LibraryFilter.RecentlyAdded) },
ScanResultToastDurationMillis,
)
}
userInitiated -> {
onShowToast(strings.noNewBooksFound, null, null, DefaultToastDurationMillis)
}
}
scanJob = null
activeScan = null
state = when (val current = state) {
is AppState.Library -> if (report.getOrNull()?.hasLibraryChanges() == true) {
loadLibraryState(message, path)
} else {
current.copy(scanPath = path, message = message)
}
is AppState.Scan -> if (report.getOrNull()?.hasLibraryChanges() == true) {
loadLibraryState(message, path)
} else {
AppState.Library(current.items, path, message)
}
is AppState.Library -> current.copy(
scanPath = path,
message = message,
)
is AppState.Scan -> AppState.Library(
current.items,
path,
message,
selectedFilter = current.selectedFilter,
)
AppState.LoadingStartup -> loadLibraryState(message, path)
is AppState.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message)
@ -426,7 +462,7 @@ private fun BookReaderApp(
if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect
val path = downloadsScanPath() ?: return@LaunchedEffect
state = state.withMessage(strings.scanningDownloads)
startScan(path)
startScan(path, userInitiated = false)
}
Box(Modifier.fillMaxSize()) {
@ -448,9 +484,15 @@ private fun BookReaderApp(
}
state = next
},
onLibraryFilterChange = ::setLibraryFilter,
onNavigateToScan = {
libraryBackState = null
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
state = AppState.Scan(
libraryState.items,
libraryState.scanPath,
libraryState.message,
libraryState.selectedFilter,
)
},
onDeleteRequested = ::requestDelete,
)
@ -464,8 +506,8 @@ private fun BookReaderApp(
activeScan = activeScan,
onStateChange = { state = it },
onStartScan = { path ->
startScan(path)
state = AppState.Library(current.items, path, strings.scanning)
startScan(path, userInitiated = true)
state = AppState.Library(current.items, path, strings.scanning, current.selectedFilter)
},
)
is AppState.Reader -> BookView(
@ -516,9 +558,6 @@ internal data class ViewedBookImage(
val title: String,
)
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
importedFiles > 0
private fun AppState.withMessage(message: String): AppState =
when (this) {
is AppState.Library -> copy(message = message)

View File

@ -2,6 +2,8 @@ package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal sealed interface AppState {
data object LoadingStartup : AppState
@ -9,12 +11,14 @@ internal sealed interface AppState {
val items: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState
data class Scan(
val items: List<LibraryItem>,
val scanPath: String,
val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState
data class Reader(
@ -42,12 +46,13 @@ internal suspend fun loadStartupState(): AppState {
} catch (t: Throwable) {
return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
}
val libraryFilter = loadLibraryFilter()
val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse {
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary)
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary, libraryFilter)
}
platformRequest?.let { request ->
return runCatching {
val book = Fb2Format.parse(request.bytes, request.displayName)
val book = parseBookInBackground(request.bytes, request.displayName)
saveActiveReadingFileId(request.id)
AppState.Reader(
fileId = request.id,
@ -56,36 +61,46 @@ internal suspend fun loadStartupState(): AppState {
scanPath = scanPath,
)
}.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName))
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter)
}
}
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath)
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
val item = loadLibraryItem(activeFileId)
if (item == null) {
saveActiveReadingFileId(null)
return AppState.Library(emptyList(), scanPath)
return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
}
return runCatching {
val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable)
AppState.Reader(
fileId = activeFileId,
book = Fb2Format.parse(bytes, item.storageUri ?: item.title),
book = parseBookInBackground(bytes, item.storageUri ?: item.title),
libraryItems = emptyList(),
scanPath = scanPath,
)
}.getOrElse {
saveActiveReadingFileId(null)
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook())
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook(), libraryFilter)
}
}
internal suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
internal suspend fun loadLibraryState(
message: String? = null,
scanPath: String? = null,
selectedFilter: LibraryFilter? = null,
): AppState =
runCatching {
AppState.Library(
items = emptyList(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message,
selectedFilter = selectedFilter ?: loadLibraryFilter(),
)
}.getOrElse {
AppState.Error(it.message ?: strings.couldNotOpenLibrary)
}
internal suspend fun parseBookInBackground(input: ByteArray, fileName: String? = null): Fb2Book =
withContext(Dispatchers.Default) {
Fb2Format.parse(input, fileName)
}

View File

@ -159,10 +159,18 @@ expect suspend fun loadReaderFontSettings(): ReaderFontSettings
expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings)
expect suspend fun loadReaderFullScreen(): Boolean
expect suspend fun saveReaderFullScreen(fullScreen: Boolean)
expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
internal expect suspend fun loadLibraryFilter(): LibraryFilter
internal expect suspend fun saveLibraryFilter(filter: LibraryFilter)
expect suspend fun loadAppLocaleTag(): String?
expect suspend fun saveAppLocaleTag(localeTag: String?)

View File

@ -83,7 +83,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
@ -97,6 +96,7 @@ internal fun LibraryScreen(
itemRefreshRequest: LibraryItemRefreshRequest?,
hiddenFileIds: Set<String>,
onStateChange: (AppState) -> Unit,
onLibraryFilterChange: (LibraryFilter) -> Unit,
onNavigateToScan: () -> Unit,
onDeleteRequested: (
request: LibraryDeleteRequest,
@ -107,6 +107,7 @@ internal fun LibraryScreen(
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var busy by remember { mutableStateOf(false) }
var openingBook by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
var loadingLibrary by remember(state.items) { mutableStateOf(false) }
@ -119,8 +120,7 @@ internal fun LibraryScreen(
var searchFocused by remember { mutableStateOf(false) }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
var selectedFilter by remember(state.scanPath) { mutableStateOf(LibraryFilter.ReadingNow) }
var filterChosenByUser by remember(state.scanPath) { mutableStateOf(false) }
var selectedFilter by remember(state.scanPath, state.selectedFilter) { mutableStateOf(state.selectedFilter) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank()
val libraryItems = items.filterNot { it.fileId in hiddenFileIds }
@ -302,13 +302,6 @@ internal fun LibraryScreen(
}
}
LaunchedEffect(searchActive, loadingLibrary, libraryItems, recentlyAdded) {
val libraryDataLoaded = libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingLibrary && libraryDataLoaded) {
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
}
}
LaunchedEffect(Unit) {
autoScanDownloads = loadScanDownloadsAutomatically()
}
@ -328,6 +321,7 @@ internal fun LibraryScreen(
}
}
Box(Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
@ -341,8 +335,11 @@ internal fun LibraryScreen(
LibraryFilterSelect(
selected = selectedFilter,
onSelected = {
filterChosenByUser = true
selectedFilter = it
onLibraryFilterChange(it)
scope.launch {
saveLibraryFilter(it)
}
},
)
}
@ -505,10 +502,11 @@ internal fun LibraryScreen(
onOpen = {
scope.launch {
busy = true
openingBook = true
try {
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error(strings.bookFileNotAvailable)
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
val book = parseBookInBackground(bytes, item.storageUri ?: item.title)
var readerLibraryItems = visibleItems
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
items = items.replaceLibraryItem(updatedItem)
@ -534,10 +532,16 @@ internal fun LibraryScreen(
message = message,
)
}.getOrElse {
AppState.Library(visibleItems, state.scanPath, it.message ?: strings.couldNotOpenBook)
AppState.Library(
visibleItems,
state.scanPath,
it.message ?: strings.couldNotOpenBook,
state.selectedFilter,
)
}
onStateChange(next)
} finally {
openingBook = false
busy = false
}
}
@ -676,6 +680,10 @@ internal fun LibraryScreen(
}
}
}
if (openingBook) {
LoadingOverlay(strings.loadingOpeningBook)
}
}
}
@Composable
@ -1061,7 +1069,7 @@ private data class LibraryItemActions(
val onDelete: () -> Unit,
)
private enum class LibraryFilter {
internal enum class LibraryFilter {
ReadingNow,
RecentlyAdded,
MyLibrary,
@ -1178,16 +1186,6 @@ private val LibraryFilter.label: String
LibraryFilter.NotInterested -> strings.notInterested
}
private fun defaultLibraryFilter(
libraryItems: List<LibraryItem>,
recentlyAddedItems: List<LibraryItem>,
): LibraryFilter =
when {
libraryItems.any { it.readingStatus == BookReadingStatus.READING } -> LibraryFilter.ReadingNow
recentlyAddedItems.any { it.readingStatus == BookReadingStatus.NEW } -> LibraryFilter.RecentlyAdded
else -> LibraryFilter.MyLibrary
}
@Composable
private fun LibraryCover(
item: LibraryItem,

View File

@ -139,6 +139,7 @@ internal open class AppStrings {
open val readerMenu = "Book reader menu"
open val info = "Info..."
open val readerSettings = "Settings"
open val fullScreen = "Full screen"
open val readerFontSizeIncrease = "Increase font size"
open val readerFontSizeDecrease = "Decrease font size"
open val readerLineHeightIncrease = "Increase line spacing"
@ -187,6 +188,10 @@ internal open class AppStrings {
open fun couldNotReopenLastBook(): String = "Could not reopen last book."
open fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
"Scanned $scanned, imported $imported, skipped $skipped, failed $failed."
open val noNewBooksFound = "Nothing new has been found."
open val show = "Show"
open fun newBooksAdded(count: Int): String =
if (count == 1) "1 new book added." else "$count new books added."
open fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
"Rescanned $scanned, updated $updated, failed $failed."
open fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =
@ -345,6 +350,7 @@ internal object RussianStrings : AppStrings() {
override val readerMenu = "Меню чтения"
override val info = "Информация..."
override val readerSettings = "Настройки"
override val fullScreen = "На весь экран"
override val readerFontSizeIncrease = "Увеличить шрифт"
override val readerFontSizeDecrease = "Уменьшить шрифт"
override val readerLineHeightIncrease = "Увеличить межстрочный интервал"
@ -393,6 +399,14 @@ internal object RussianStrings : AppStrings() {
override fun couldNotReopenLastBook(): String = "Не удалось открыть последнюю книгу."
override fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
"Проверено: $scanned, импортировано: $imported, пропущено: $skipped, ошибок: $failed."
override val noNewBooksFound = "Ничего нового не найдено."
override val show = "Показать"
override fun newBooksAdded(count: Int): String =
when {
count % 10 == 1 && count % 100 != 11 -> "$count новая книга добавлена."
count % 10 in 2..4 && count % 100 !in 12..14 -> "$count новые книги добавлены."
else -> "$count новых книг добавлено."
}
override fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
"Пересканировано: $scanned, обновлено: $updated, ошибок: $failed."
override fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =

View File

@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@ -52,6 +53,7 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
@ -98,12 +100,19 @@ import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.text.HyphenationRegistry
import net.sergeych.toread.text.SoftHyphen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
private data class DecodedBookImage(
val bitmap: ImageBitmap,
val bytes: ByteArray,
)
@Composable
internal fun ContinuousBookReader(
book: Fb2Book,
@ -1035,9 +1044,18 @@ private fun BookImage(
val binary = remember(book, image) {
image?.let(book::binaryFor)
}
val bitmap = remember(binary) {
binary?.let { decodeBookImage(it) }
var decodedImage by remember(binary) { mutableStateOf<DecodedBookImage?>(null) }
LaunchedEffect(binary) {
decodedImage = withContext(Dispatchers.Default) {
binary?.let {
val bytes = it.imageBytes()
val bitmap = decodeImageBytes(bytes) ?: return@let null
DecodedBookImage(bitmap, bytes)
}
}
}
val currentDecodedImage = decodedImage
val bitmap = currentDecodedImage?.bitmap
val imageTitle = image?.alt?.ifBlank { null } ?: book.title
val imageBackgroundColor = readerImageBackgroundColor()
val imageShape = RoundedCornerShape(8.dp)
@ -1048,12 +1066,12 @@ private fun BookImage(
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.then(
if (bitmap != null && binary != null) {
if (currentDecodedImage != null && binary != null) {
Modifier.clickable {
onOpen(
ViewedBookImage(
bitmap = bitmap,
bytes = binary.imageBytes(),
bitmap = currentDecodedImage.bitmap,
bytes = currentDecodedImage.bytes,
mimeType = binary.contentType,
title = imageTitle,
),

View File

@ -1,6 +1,13 @@
package net.sergeych.toread
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
@ -11,6 +18,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -45,6 +53,7 @@ import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -64,6 +73,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -74,20 +84,32 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
private data class ReaderBookPreparation(
val stats: BookStats,
val contentPlan: ReaderContentPlan,
)
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
internal fun BookView(
@ -105,17 +127,17 @@ internal fun BookView(
) -> Unit,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
val contentPlan = remember(book) { buildReaderContentPlan(book) }
val listState = remember(fileId) { LazyListState() }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var preparation by remember(book) { mutableStateOf<ReaderBookPreparation?>(null) }
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
var fullScreenReader by remember { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
@ -125,9 +147,31 @@ internal fun BookView(
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
LaunchedEffect(book) {
preparation = withContext(Dispatchers.Default) {
ReaderBookPreparation(
stats = BookStats.from(book),
contentPlan = buildReaderContentPlan(book),
)
}
}
val preparedBook = preparation
if (preparedBook == null) {
LoadingScreen(strings.loadingOpeningBook)
return
}
val stats = preparedBook.stats
val contentPlan = preparedBook.contentPlan
val showShareAction = canShareLibraryBookFile()
val showViewFileAction = canViewLibraryBookFile()
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val fullScreenProgressTitle = remember(book) { book.fullScreenProgressTitle() }
val readingProgress by remember(listState) {
derivedStateOf { listState.readerProgress() }
}
val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudState.active }
@ -157,6 +201,14 @@ internal fun BookView(
}
}
fun updateReaderFullScreen(fullScreen: Boolean) {
if (fullScreen == fullScreenReader) return
fullScreenReader = fullScreen
scope.launch {
saveReaderFullScreen(fullScreen)
}
}
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
ReadingPosition(
listState.firstVisibleItemIndex,
@ -225,6 +277,7 @@ internal fun BookView(
LaunchedEffect(Unit) {
readerFontSettings = loadReaderFontSettings()
fullScreenReader = loadReaderFullScreen()
}
DisposableEffect(fileId) {
@ -294,6 +347,11 @@ internal fun BookView(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
AnimatedVisibility(
visible = !fullScreenReader,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
CompactReaderTopBar(
title = book.title,
onThemeToggle = onThemeToggle,
@ -332,6 +390,8 @@ internal fun BookView(
}
}
},
fullScreen = fullScreenReader,
onFullScreenChange = ::updateReaderFullScreen,
showShareAction = showShareAction,
onShare = {
scope.launch {
@ -382,6 +442,7 @@ internal fun BookView(
}
},
)
}
},
) {
Box(
@ -452,6 +513,12 @@ internal fun BookView(
},
)
}
ReaderProgressPane(
progress = readingProgress,
fullScreen = fullScreenReader,
fullScreenTitle = fullScreenProgressTitle,
onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) },
)
}
}
}
@ -505,6 +572,151 @@ internal fun BookView(
}
}
@Composable
private fun ReaderProgressPane(
progress: Float,
fullScreen: Boolean,
fullScreenTitle: String,
onFullScreenToggle: () -> Unit,
) {
val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt()
val trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
val readColor = MaterialTheme.colorScheme.primary
val fullScreenFillColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
val contentDescription = strings.progressLabel(progress.toDouble())
val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp)
val progressRowHeight by animateDpAsState(24.dp)
val canvasHeight by animateDpAsState(if (fullScreen) 12.dp else 8.dp)
val trackStroke by animateDpAsState(1.dp)
val readStroke by animateDpAsState(3.dp)
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onFullScreenToggle),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.then(
if (fullScreen) {
Modifier.drawBehind {
drawRect(
color = fullScreenFillColor,
size = Size(size.width * progress.coerceIn(0f, 1f), size.height),
)
}
} else {
Modifier
},
)
.semantics { this.contentDescription = contentDescription },
) {
if (fullScreen) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(progressRowHeight)
.align(Alignment.Center)
.padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = fullScreenTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
)
Spacer(Modifier.size(10.dp))
Text(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.height(progressRowHeight)
.padding(start = 12.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Canvas(
modifier = Modifier
.weight(1f)
.height(canvasHeight),
) {
val centerY = size.height / 2f
drawLine(
color = trackColor,
start = Offset(0f, centerY),
end = Offset(size.width, centerY),
strokeWidth = trackStroke.toPx(),
cap = StrokeCap.Square,
)
if (progress > 0f) {
drawLine(
color = readColor,
start = Offset(0f, centerY),
end = Offset(size.width * progress.coerceIn(0f, 1f), centerY),
strokeWidth = readStroke.toPx(),
cap = StrokeCap.Square,
)
}
}
Spacer(Modifier.size(10.dp))
Text(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
}
}
}
private fun Fb2Book.fullScreenProgressTitle(): String {
val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }
return "$author. $title"
}
private fun LazyListState.readerProgress(): Float {
val layoutInfo = layoutInfo
val totalItems = layoutInfo.totalItemsCount
if (totalItems <= 1) return 0f
val visibleItems = layoutInfo.visibleItemsInfo
val lastVisibleItem = visibleItems.lastOrNull()
if (
lastVisibleItem?.index == totalItems - 1 &&
lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset
) {
return 1f
}
val firstIndex = firstVisibleItemIndex.coerceIn(0, totalItems - 1)
val firstVisibleItemSize = visibleItems.firstOrNull { it.index == firstIndex }?.size
val itemFraction = firstVisibleItemSize
?.takeIf { it > 0 }
?.let { (firstVisibleItemScrollOffset.toFloat() / it.toFloat()).coerceIn(0f, 1f) }
?: 0f
return ((firstIndex + itemFraction) / (totalItems - 1).toFloat()).coerceIn(0f, 1f)
}
@Composable
private fun CompactReaderTopBar(
title: String,
@ -518,6 +730,8 @@ private fun CompactReaderTopBar(
readingStatus: BookReadingStatus?,
favorite: Boolean,
onFavoriteChange: (Boolean) -> Unit,
fullScreen: Boolean,
onFullScreenChange: (Boolean) -> Unit,
showShareAction: Boolean,
onShare: () -> Unit,
showViewFileAction: Boolean,
@ -581,6 +795,16 @@ private fun CompactReaderTopBar(
onReaderSettings()
},
)
DropdownMenuItem(
leadingIcon = {
Checkbox(checked = fullScreen, onCheckedChange = null)
},
text = { Text(strings.fullScreen) },
onClick = {
menuOpen = false
onFullScreenChange(!fullScreen)
},
)
HorizontalDivider()
DropdownMenuItem(
leadingIcon = {

View File

@ -57,7 +57,7 @@ internal fun ScanScreen(
CenterAlignedTopAppBar(
title = { Text(strings.scan) },
navigationIcon = {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message, state.selectedFilter)) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
}
},

View File

@ -1,5 +1,6 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -31,8 +32,39 @@ import kotlin.math.roundToInt
@Composable
internal fun LoadingScreen(message: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
LoadingPanel(message)
}
}
@Composable
internal fun LoadingOverlay(message: String, modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.58f)),
contentAlignment = Alignment.Center,
) {
LoadingPanel(message)
}
}
@Composable
private fun LoadingPanel(message: String) {
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator()
Text(message, style = MaterialTheme.typography.bodyMedium)
}

View File

@ -449,6 +449,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
}
}
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@ -461,6 +473,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
}
}
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
}
}
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(LibraryFilterFlag, filter.name)
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
@ -715,6 +741,8 @@ private fun String.isSupportedBookFile(): Boolean =
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -83,10 +83,26 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) {
window.localStorage.setItem(ReaderFontSettingsStorageKey, settings.toStorageValue())
}
actual suspend fun loadReaderFullScreen(): Boolean =
window.localStorage.getItem(ReaderFullScreenStorageKey)?.toBooleanStrictOrNull() ?: false
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) {
window.localStorage.setItem(ReaderFullScreenStorageKey, fullScreen.toString())
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
internal actual suspend fun loadLibraryFilter(): LibraryFilter =
window.localStorage.getItem(LibraryFilterStorageKey)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) {
window.localStorage.setItem(LibraryFilterStorageKey, filter.name)
}
actual suspend fun loadAppLocaleTag(): String? =
window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() }
@ -121,7 +137,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null
private const val AppLocaleStorageKey = "toread.appLocaleTag"
private const val LibraryFilterStorageKey = "toread.libraryFilter"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
private const val ReaderFullScreenStorageKey = "toread.readerFullScreen"
actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L

View File

@ -1,5 +1,5 @@
[versions]
agp = "8.11.2"
agp = "8.13.2"
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"