From dbf63c351c2cf87b7798e96fd4cf46fdcc72cbd9 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 31 May 2026 12:39:13 +0300 Subject: [PATCH] Reading progress, persist library mode and improve scan feedback --- .../sergeych/toread/BookPlatform.android.kt | 28 ++ .../kotlin/net/sergeych/toread/App.kt | 87 ++-- .../kotlin/net/sergeych/toread/AppState.kt | 31 +- .../net/sergeych/toread/LibraryPlatform.kt | 8 + .../net/sergeych/toread/LibraryScreen.kt | 96 +++-- .../net/sergeych/toread/Localization.kt | 14 + .../net/sergeych/toread/ReaderContent.kt | 28 +- .../net/sergeych/toread/ReaderScreen.kt | 398 ++++++++++++++---- .../kotlin/net/sergeych/toread/ScanScreen.kt | 2 +- .../kotlin/net/sergeych/toread/SharedUi.kt | 36 +- .../net/sergeych/toread/BookPlatform.jvm.kt | 28 ++ .../net/sergeych/toread/BookPlatform.web.kt | 18 + gradle/libs.versions.toml | 2 +- 13 files changed, 599 insertions(+), 177 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 55383b2..61df3db 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 7e0fae7..685f693 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt index e16f37b..d5e41c4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt @@ -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, val scanPath: String, val message: String? = null, + val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary, ) : AppState data class Scan( val items: List, 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) + } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 63de677..fca5cbc 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -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?) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 900c806..777a7ea 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -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, 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>(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() } 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,7 +321,8 @@ internal fun LibraryScreen( } } - Scaffold( + Box(Modifier.fillMaxSize()) { + Scaffold( topBar = { TopAppBar( title = { @@ -341,8 +335,11 @@ internal fun LibraryScreen( LibraryFilterSelect( selected = selectedFilter, onSelected = { - filterChosenByUser = true selectedFilter = it + onLibraryFilterChange(it) + scope.launch { + saveLibraryFilter(it) + } }, ) } @@ -456,31 +453,31 @@ internal fun LibraryScreen( Icon(Icons.Filled.Add, contentDescription = strings.scanFolder) } }, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(it) - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when { - event.key == Key.Escape && searchText.isNotBlank() -> { - clearSearch() - true - } - event.key == Key.Escape && searchFocused -> { - closeSearch() - true - } - event.key.isEnterKey() && searchFocused && searchText.isBlank() -> { - closeSearch() - true - } - else -> false - } - } - .background(readerBackground()), ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(it) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when { + event.key == Key.Escape && searchText.isNotBlank() -> { + clearSearch() + true + } + event.key == Key.Escape && searchFocused -> { + closeSearch() + true + } + event.key.isEnterKey() && searchFocused && searchText.isBlank() -> { + closeSearch() + true + } + else -> false + } + } + .background(readerBackground()), + ) { val wide = maxWidth >= 800.dp if (visibleItems.isEmpty() && (loadingLibrary || searching)) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -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 } } @@ -674,6 +678,10 @@ internal fun LibraryScreen( .padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp), ) } + } + } + if (openingBook) { + LoadingOverlay(strings.loadingOpeningBook) } } } @@ -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, - recentlyAddedItems: List, -): 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, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index 972c4dc..da7c26c 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -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 = diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 98fc8f2..b5a8743 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -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(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, ), diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 8368825..76fed95 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -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(null) } var restored by remember(fileId) { mutableStateOf(false) } var markedRead by remember(fileId) { mutableStateOf(false) } var libraryItem by remember(fileId) { mutableStateOf(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>(emptyList()) } var tableOfContentsBackPosition by remember(fileId) { mutableStateOf(null) } @@ -125,9 +147,31 @@ internal fun BookView( var selectedNoteId by remember(fileId) { mutableStateOf(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 = tableOfContentsBackStack): ReadingPosition = ReadingPosition( listState.firstVisibleItemIndex, @@ -225,6 +277,7 @@ internal fun BookView( LaunchedEffect(Unit) { readerFontSettings = loadReaderFontSettings() + fullScreenReader = loadReaderFullScreen() } DisposableEffect(fileId) { @@ -294,94 +347,102 @@ internal fun BookView( contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - CompactReaderTopBar( - title = book.title, - onThemeToggle = onThemeToggle, - onBookInfo = { - scope.launch { - saveLibraryReadingPosition( - fileId, - currentReadingPosition(), - ) - onBookInfo() - } - }, - onTableOfContents = ::openTableOfContents, - onMarkAsRead = { - setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) - }, - onMarkToRead = { - setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead()) - }, - onNotInterested = { - setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested()) - }, - onClearMarks = { - setReadingStatus(BookReadingStatus.NEW, strings.removedMarks()) - }, - readingStatus = libraryItem?.readingStatus, - favorite = libraryItem?.favorite == true, - onFavoriteChange = { favorite -> - scope.launch { - if (markLibraryFavorite(fileId, favorite)) { - libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite) - onBookChanged(fileId) - showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites()) - } else { - showMessage(strings.couldNotUpdateBook) + AnimatedVisibility( + visible = !fullScreenReader, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + CompactReaderTopBar( + title = book.title, + onThemeToggle = onThemeToggle, + onBookInfo = { + scope.launch { + saveLibraryReadingPosition( + fileId, + currentReadingPosition(), + ) + onBookInfo() } - } - }, - showShareAction = showShareAction, - onShare = { - scope.launch { - showMessage(shareLibraryBook(fileId)) - } - }, - showViewFileAction = showViewFileAction, - onViewFile = { - scope.launch { - showMessage(viewLibraryBook(fileId)) - } - }, - onReaderSettings = { - readerSettingsPanelVisible = true - }, - showReadAloudAction = showReadAloudAction, - onReadAloud = { - val position = currentReadingPosition() - val startIndex = contentPlan.resumeSentenceIndex(position) - ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) - readAloudPanelVisible = true - ReadAloudPlatform.play() - }, - onDelete = { - onDeleteRequested( - LibraryDeleteRequest(fileId, book.title), - { - scope.launch { - saveActiveReadingFileId(null) + }, + onTableOfContents = ::openTableOfContents, + onMarkAsRead = { + setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) + }, + onMarkToRead = { + setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead()) + }, + onNotInterested = { + setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested()) + }, + onClearMarks = { + setReadingStatus(BookReadingStatus.NEW, strings.removedMarks()) + }, + readingStatus = libraryItem?.readingStatus, + favorite = libraryItem?.favorite == true, + onFavoriteChange = { favorite -> + scope.launch { + if (markLibraryFavorite(fileId, favorite)) { + libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite) + onBookChanged(fileId) + showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites()) + } else { + showMessage(strings.couldNotUpdateBook) } - onDeleted(null) - }, - { - scope.launch { - saveActiveReadingFileId(fileId) - } - }, - ) - }, - onBack = { - scope.launch { - saveLibraryReadingPosition( - fileId, - currentReadingPosition(), + } + }, + fullScreen = fullScreenReader, + onFullScreenChange = ::updateReaderFullScreen, + showShareAction = showShareAction, + onShare = { + scope.launch { + showMessage(shareLibraryBook(fileId)) + } + }, + showViewFileAction = showViewFileAction, + onViewFile = { + scope.launch { + showMessage(viewLibraryBook(fileId)) + } + }, + onReaderSettings = { + readerSettingsPanelVisible = true + }, + showReadAloudAction = showReadAloudAction, + onReadAloud = { + val position = currentReadingPosition() + val startIndex = contentPlan.resumeSentenceIndex(position) + ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) + readAloudPanelVisible = true + ReadAloudPlatform.play() + }, + onDelete = { + onDeleteRequested( + LibraryDeleteRequest(fileId, book.title), + { + scope.launch { + saveActiveReadingFileId(null) + } + onDeleted(null) + }, + { + scope.launch { + saveActiveReadingFileId(fileId) + } + }, ) - saveActiveReadingFileId(null) - onBack() - } - }, - ) + }, + onBack = { + scope.launch { + saveLibraryReadingPosition( + fileId, + currentReadingPosition(), + ) + saveActiveReadingFileId(null) + onBack() + } + }, + ) + } }, ) { 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 = { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt index 744889d..7d37596 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt @@ -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) } }, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt index 53313dd..ea11c65 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt @@ -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) } diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index fef57ce..ba42768 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -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" diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index ece7951..3cf6bd4 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7bc16c5..472124e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.11.2" +agp = "8.13.2" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36"