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 61df3db..6f7d8de 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -499,9 +499,18 @@ 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 + val saved = db.getAppFlag(LibraryFilterFlag) + when { + saved == null -> { + appendLibraryLog("warning library filter was not saved; using ${LibraryFilter.MyLibrary.name}") + LibraryFilter.MyLibrary + } + else -> runCatching { LibraryFilter.valueOf(saved) } + .getOrElse { + appendLibraryLog("warning invalid saved library filter '$saved'; using ${LibraryFilter.MyLibrary.name}") + LibraryFilter.MyLibrary + } + } } } diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt index d7bc604..916a0f1 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle @@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Modifier import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch @@ -55,7 +58,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { } notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, true) + configureEdgeToEdgeBookWindow() initToreadPlatform(this, this) rememberPlatformOpenBookIntent(intent) @@ -67,6 +70,11 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { requestNotificationPermissionIfNeeded() } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideNavigationBar() + } + override suspend fun chooseDirectory(): String? { val result = CompletableDeferred() runOnUiThread { @@ -175,6 +183,27 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } + private fun configureEdgeToEdgeBookWindow() { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.navigationBarColor = Color.TRANSPARENT + window.statusBarColor = Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor = Color.TRANSPARENT + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + hideNavigationBar() + } + + private fun hideNavigationBar() { + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.navigationBars()) + } + } + private fun downloadsPath(): String = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath ?: Environment.getExternalStorageDirectory().absolutePath @@ -185,7 +214,7 @@ private fun AndroidAppWindow(content: @Composable () -> Unit) { androidx.compose.foundation.layout.Box( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)), + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), ) { content() } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 685f693..183c685 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -104,25 +105,27 @@ fun App() { } MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) { - Box(Modifier.fillMaxSize()) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - if (localePreferenceLoaded) { - BookReaderApp( - onThemeToggle = { - val next = themeMode.next() - themeMode = next - showToast(strings.themeChanged(next)) - scope.launch { - saveThemeMode(next) - } - }, - onShowToast = ::showToast, - ) - } else { - LoadingScreen(strings.loadingOpeningBook) + CompositionLocalProvider(LocalReaderThemeIsDark provides useDark) { + Box(Modifier.fillMaxSize()) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + if (localePreferenceLoaded) { + BookReaderApp( + onThemeToggle = { + val next = themeMode.next() + themeMode = next + showToast(strings.themeChanged(next)) + scope.launch { + saveThemeMode(next) + } + }, + onShowToast = ::showToast, + ) + } else { + LoadingScreen(strings.loadingOpeningBook) + } } + AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter)) } - AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter)) } } } @@ -244,6 +247,13 @@ private fun BookReaderApp( book = book, libraryItems = emptyList(), scanPath = scanPath, + selectedFilter = when (val current = state) { + is AppState.Library -> current.selectedFilter + is AppState.Scan -> current.selectedFilter + is AppState.Reader -> current.selectedFilter + is AppState.BookInfo -> current.selectedFilter + is AppState.Error, AppState.LoadingStartup -> loadLibraryFilter() + }, ) }.getOrElse { AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter()) @@ -357,12 +367,18 @@ private fun BookReaderApp( libraryItems = current.libraryItems, scanPath = current.scanPath, message = current.message, + selectedFilter = current.selectedFilter, ) is AppState.Reader -> { scope.launch { saveActiveReadingFileId(null) } refreshLibraryItem(current.fileId) val backState = libraryBackState?.copy(message = current.message) - ?: AppState.Library(current.libraryItems, current.scanPath, current.message) + ?: AppState.Library( + current.libraryItems, + current.scanPath, + current.message, + current.selectedFilter, + ) libraryBackState = null downloadsRescanRequestGeneration += 1 backState @@ -523,11 +539,12 @@ private fun BookReaderApp( libraryItems = current.libraryItems, scanPath = current.scanPath, message = current.message, + selectedFilter = current.selectedFilter, ) }, onDeleted = { message -> state = libraryBackState?.copy(message = message) - ?: AppState.Library(emptyList(), current.scanPath, message) + ?: AppState.Library(emptyList(), current.scanPath, message, current.selectedFilter) libraryBackState = null }, onDeleteRequested = ::requestDelete, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt index d5e41c4..9183bb9 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt @@ -27,6 +27,7 @@ internal sealed interface AppState { val libraryItems: List, val scanPath: String, val message: String? = null, + val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary, ) : AppState data class BookInfo( @@ -35,6 +36,7 @@ internal sealed interface AppState { val libraryItems: List, val scanPath: String, val message: String? = null, + val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary, ) : AppState data class Error(val message: String) : AppState @@ -59,6 +61,7 @@ internal suspend fun loadStartupState(): AppState { book = book, libraryItems = emptyList(), scanPath = scanPath, + selectedFilter = libraryFilter, ) }.getOrElse { AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter) @@ -77,6 +80,7 @@ internal suspend fun loadStartupState(): AppState { book = parseBookInBackground(bytes, item.storageUri ?: item.title), libraryItems = emptyList(), scanPath = scanPath, + selectedFilter = libraryFilter, ) }.getOrElse { saveActiveReadingFileId(null) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index fca5cbc..8194378 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -70,6 +70,14 @@ data class ReaderFontSettings( val lineHeightSp: Float = DefaultReaderLineHeightSp, val fontWeight: Int = defaultReaderFontWeight(), val fontName: String? = null, + val lightBackgroundArgb: Long? = null, + val lightTextArgb: Long? = null, + val darkBackgroundArgb: Long? = null, + val darkTextArgb: Long? = null, + val lightCustomBackgroundArgb: Long? = null, + val lightCustomTextArgb: Long? = null, + val darkCustomBackgroundArgb: Long? = null, + val darkCustomTextArgb: Long? = null, ) data class BookInfoExtras( @@ -255,6 +263,14 @@ internal fun ReaderFontSettings.coerced(): ReaderFontSettings = lineHeightSp = lineHeightSp.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp), fontWeight = fontWeight.coerceIn(MinReaderFontWeight, MaxReaderFontWeight), fontName = fontName?.takeIf { it.isNotBlank() }, + lightBackgroundArgb = lightBackgroundArgb?.coerceArgb(), + lightTextArgb = lightTextArgb?.coerceArgb(), + darkBackgroundArgb = darkBackgroundArgb?.coerceArgb(), + darkTextArgb = darkTextArgb?.coerceArgb(), + lightCustomBackgroundArgb = lightCustomBackgroundArgb?.coerceArgb(), + lightCustomTextArgb = lightCustomTextArgb?.coerceArgb(), + darkCustomBackgroundArgb = darkCustomBackgroundArgb?.coerceArgb(), + darkCustomTextArgb = darkCustomTextArgb?.coerceArgb(), ) internal fun ReaderFontSettings.adjustFontSize(steps: Int): ReaderFontSettings { @@ -295,6 +311,14 @@ internal fun ReaderFontSettings.toStorageValue(): String = current.lineHeightSp.toString(), current.fontWeight.toString(), current.fontName.orEmpty(), + current.lightBackgroundArgb?.toStorageArgb().orEmpty(), + current.lightTextArgb?.toStorageArgb().orEmpty(), + current.darkBackgroundArgb?.toStorageArgb().orEmpty(), + current.darkTextArgb?.toStorageArgb().orEmpty(), + current.lightCustomBackgroundArgb?.toStorageArgb().orEmpty(), + current.lightCustomTextArgb?.toStorageArgb().orEmpty(), + current.darkCustomBackgroundArgb?.toStorageArgb().orEmpty(), + current.darkCustomTextArgb?.toStorageArgb().orEmpty(), ).joinToString("|") } @@ -306,5 +330,71 @@ internal fun readerFontSettingsFromStorageValue(value: String?): ReaderFontSetti lineHeightSp = parts.getOrNull(1)?.toFloatOrNull() ?: defaults.lineHeightSp, fontWeight = parts.getOrNull(2)?.toIntOrNull() ?: defaults.fontWeight, fontName = parts.getOrNull(3)?.takeIf { it.isNotBlank() } ?: defaults.fontName, + lightBackgroundArgb = parts.getOrNull(4)?.storageArgbOrNull(), + lightTextArgb = parts.getOrNull(5)?.storageArgbOrNull(), + darkBackgroundArgb = parts.getOrNull(6)?.storageArgbOrNull(), + darkTextArgb = parts.getOrNull(7)?.storageArgbOrNull(), + lightCustomBackgroundArgb = parts.getOrNull(8)?.storageArgbOrNull(), + lightCustomTextArgb = parts.getOrNull(9)?.storageArgbOrNull(), + darkCustomBackgroundArgb = parts.getOrNull(10)?.storageArgbOrNull(), + darkCustomTextArgb = parts.getOrNull(11)?.storageArgbOrNull(), ).coerced() } + +internal fun ReaderFontSettings.withThemeBackgroundArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings = + if (useDarkTheme) copy(darkBackgroundArgb = argb?.coerceArgb()) else copy(lightBackgroundArgb = argb?.coerceArgb()) + +internal fun ReaderFontSettings.withThemeTextArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings = + if (useDarkTheme) copy(darkTextArgb = argb?.coerceArgb()) else copy(lightTextArgb = argb?.coerceArgb()) + +internal fun ReaderFontSettings.withThemeCustomBackgroundArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings = + if (useDarkTheme) { + copy(darkCustomBackgroundArgb = argb?.coerceArgb(), darkBackgroundArgb = argb?.coerceArgb()) + } else { + copy(lightCustomBackgroundArgb = argb?.coerceArgb(), lightBackgroundArgb = argb?.coerceArgb()) + } + +internal fun ReaderFontSettings.withThemeCustomTextArgb(useDarkTheme: Boolean, argb: Long?): ReaderFontSettings = + if (useDarkTheme) { + copy(darkCustomTextArgb = argb?.coerceArgb(), darkTextArgb = argb?.coerceArgb()) + } else { + copy(lightCustomTextArgb = argb?.coerceArgb(), lightTextArgb = argb?.coerceArgb()) + } + +internal fun ReaderFontSettings.themeBackgroundArgb(useDarkTheme: Boolean): Long? = + if (useDarkTheme) darkBackgroundArgb else lightBackgroundArgb + +internal fun ReaderFontSettings.themeTextArgb(useDarkTheme: Boolean): Long? = + if (useDarkTheme) darkTextArgb else lightTextArgb + +internal fun ReaderFontSettings.themeCustomBackgroundArgb(useDarkTheme: Boolean): Long? = + if (useDarkTheme) darkCustomBackgroundArgb else lightCustomBackgroundArgb + +internal fun ReaderFontSettings.themeCustomTextArgb(useDarkTheme: Boolean): Long? = + if (useDarkTheme) darkCustomTextArgb else lightCustomTextArgb + +internal fun ReaderFontSettings.resetThemeColorScheme(useDarkTheme: Boolean): ReaderFontSettings = + if (useDarkTheme) { + copy( + darkBackgroundArgb = null, + darkTextArgb = null, + ) + } else { + copy( + lightBackgroundArgb = null, + lightTextArgb = null, + ) + } + +private fun Long.coerceArgb(): Long = + coerceIn(0x00000000L, 0xFFFFFFFFL) + +private fun Long.toStorageArgb(): String = + coerceArgb().toString(16).padStart(8, '0').uppercase() + +private fun String.storageArgbOrNull(): Long? = + trim() + .removePrefix("#") + .takeIf { it.length == 8 } + ?.toLongOrNull(16) + ?.coerceArgb() diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 777a7ea..1d61413 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -530,6 +530,7 @@ internal fun LibraryScreen( libraryItems = readerLibraryItems, scanPath = state.scanPath, message = message, + selectedFilter = state.selectedFilter, ) }.getOrElse { AppState.Library( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index da7c26c..40241be 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -44,6 +44,7 @@ internal open class AppStrings { open val undo = "Undo" open val retry = "Retry" open val done = "Done" + open val cancel = "Cancel" open val libraryError = "Library error" open val couldNotOpenLibrary = "Could not open library." @@ -147,6 +148,19 @@ internal open class AppStrings { open val readerFontWeightIncrease = "Increase font weight" open val readerFontWeightDecrease = "Decrease font weight" open val resetReaderFontSettings = "Reset font settings" + open val revertColorScheme = "Revert color scheme" + open val readerBackgroundColor = "Reader background color" + open val readerTextColor = "Reader text color" + open val extraReaderSettings = "Extra reader settings" + open val customColor = "Custom color" + open val red = "R" + open val green = "G" + open val blue = "B" + open val readerColors = "Colors" + open val readerTypography = "Typography" + open val readerLayout = "Layout" + open val readerNavigation = "Navigation" + open val readerProgress = "Progress" open val closeReaderSettings = "Close reader settings" open val share = "Share" open val viewFile = "View file" @@ -255,6 +269,7 @@ internal object RussianStrings : AppStrings() { override val undo = "Отменить" override val retry = "Повторить" override val done = "Готово" + override val cancel = "Отмена" override val libraryError = "Ошибка библиотеки" override val couldNotOpenLibrary = "Не удалось открыть библиотеку." @@ -358,6 +373,19 @@ internal object RussianStrings : AppStrings() { override val readerFontWeightIncrease = "Увеличить насыщенность шрифта" override val readerFontWeightDecrease = "Уменьшить насыщенность шрифта" override val resetReaderFontSettings = "Сбросить настройки шрифта" + override val revertColorScheme = "Вернуть цветовую схему" + override val readerBackgroundColor = "Цвет фона чтения" + override val readerTextColor = "Цвет текста чтения" + override val extraReaderSettings = "Дополнительные настройки" + override val customColor = "Свой цвет" + override val red = "К" + override val green = "З" + override val blue = "С" + override val readerColors = "Цвета" + override val readerTypography = "Типографика" + override val readerLayout = "Макет" + override val readerNavigation = "Навигация" + override val readerProgress = "Прогресс" override val closeReaderSettings = "Закрыть настройки чтения" override val share = "Поделиться" override val viewFile = "Показать файл" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index b5a8743..edc4a7c 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -57,10 +59,8 @@ 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 -import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -81,8 +81,10 @@ import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withAnnotation import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp @@ -122,6 +124,8 @@ internal fun ContinuousBookReader( contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) }, highlightedSentence: ReadAloudSentence? = null, readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(), + readerBackgroundColor: Color = MaterialTheme.colorScheme.surface, + readerTextColor: Color = MaterialTheme.colorScheme.onSurface, onReaderFontZoom: (Int) -> Unit = {}, onUserScroll: () -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {}, @@ -145,7 +149,7 @@ internal fun ContinuousBookReader( LazyColumn( state = listState, modifier = modifier - .background(MaterialTheme.colorScheme.surface) + .background(readerBackgroundColor) .nestedScroll(userScrollConnection) .readerFontZoomGesture(onReaderFontZoom) .pageTurnOnTouchTap( @@ -202,7 +206,7 @@ internal fun ContinuousBookReader( 0 -> MaterialTheme.typography.headlineMedium 1 -> MaterialTheme.typography.titleLarge else -> MaterialTheme.typography.titleMedium - }, + }.copy(color = readerTextColor), fontWeight = FontWeight.Bold, lineHeight = if (element.depth == 0) 36.sp else 28.sp, modifier = titleModifier, @@ -219,7 +223,7 @@ internal fun ContinuousBookReader( text = element.text, language = book.language, hyphenation = hyphenation, - style = readerParagraphTextStyle(book.language, readerFontSettings), + style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor), highlightedRange = highlightedRange, textAlign = TextAlign.Justify, modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp), @@ -230,7 +234,7 @@ internal fun ContinuousBookReader( text = element.text, language = book.language, hyphenation = hyphenation, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold, color = readerTextColor), highlightedRange = highlightedRange, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), @@ -243,6 +247,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + textColor = readerTextColor, onLinkOpen = onNoteOpen, ) is ReaderElement.Cite -> ReaderCite( @@ -251,6 +256,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + textColor = readerTextColor, onLinkOpen = onNoteOpen, ) is ReaderElement.Poem -> ReaderPoem( @@ -259,6 +265,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + textColor = readerTextColor, onLinkOpen = onNoteOpen, ) } @@ -332,6 +339,8 @@ private fun Modifier.pageTurnOnTouchTap( val change = event.changes.firstOrNull { it.id == down.id } if (change == null) { cancelled = true + } else if (change.isConsumed) { + cancelled = true } else if (change.pressed) { val dx = change.position.x - down.position.x val dy = change.position.y - down.position.y @@ -699,27 +708,19 @@ private fun ReaderText( onLinkOpen: (String) -> Unit = {}, ) { val highlightColor = MaterialTheme.colorScheme.secondaryContainer - val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor) + val annotatedText = text.toAnnotatedString( + language = language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + ) val hasLinks = annotatedText.hasReaderLinks() val needsSoftHyphenPaintWorkaround = isDesktopPlatform var textLayout by remember(annotatedText) { mutableStateOf(null) } - val desktopHyphenColor = MaterialTheme.colorScheme.onSurface + val desktopHyphenColor = if (style.color == Color.Unspecified) MaterialTheme.colorScheme.onSurface else style.color val desktopHyphenGutter = 8.dp val textModifier = modifier .fillMaxWidth() - .then( - if (hasLinks) { - Modifier - .pointerHoverIcon(PointerIcon.Hand) - .readerLinkTapHandler( - annotatedText = annotatedText, - textLayout = textLayout, - onLinkOpen = onLinkOpen, - ) - } else { - Modifier - }, - ) .then( if (needsSoftHyphenPaintWorkaround) { Modifier.drawWithContent { @@ -751,16 +752,26 @@ private fun ReaderText( }, ) - Text( - text = annotatedText, - style = style, - textAlign = textAlign, - modifier = textModifier, - onTextLayout = { - textLayout = it - onTextLayout(it) - }, - ) + Box(modifier = textModifier) { + Text( + text = annotatedText, + style = style, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), + onTextLayout = { + textLayout = it + onTextLayout(it) + }, + ) + val layout = textLayout + if (hasLinks && layout != null) { + ReaderLinkTouchOverlays( + annotatedText = annotatedText, + textLayout = layout, + onLinkOpen = onLinkOpen, + ) + } + } } @Composable @@ -770,6 +781,7 @@ private fun ReaderPoem( hyphenation: HyphenationRegistry, highlightedRange: ReaderSentenceRange?, depth: Int, + textColor: Color = MaterialTheme.colorScheme.onSurface, modifier: Modifier = Modifier, onLinkOpen: (String) -> Unit = {}, ) { @@ -786,6 +798,7 @@ private fun ReaderPoem( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = 0, + textColor = textColor, modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp), onLinkOpen = onLinkOpen, ) @@ -798,7 +811,7 @@ private fun ReaderPoem( text = segment.text, language = language, hyphenation = hyphenation, - style = poemTextStyle(segment.kind, language), + style = poemTextStyle(segment.kind, language).copy(color = textColor), highlightedRange = highlightedRange?.forSegment(segment), textAlign = when (segment.kind) { ReaderPoemSegmentKind.Title, @@ -824,6 +837,7 @@ private fun ReaderEpigraph( hyphenation: HyphenationRegistry, highlightedRange: ReaderSentenceRange?, depth: Int, + textColor: Color = MaterialTheme.colorScheme.onSurface, modifier: Modifier = Modifier, onLinkOpen: (String) -> Unit = {}, ) { @@ -840,6 +854,7 @@ private fun ReaderEpigraph( hyphenation = hyphenation, highlightedRange = null, depth = 0, + textColor = textColor, onLinkOpen = onLinkOpen, ) } @@ -848,7 +863,7 @@ private fun ReaderEpigraph( text = author, language = language, hyphenation = hyphenation, - style = epigraphAuthorTextStyle(language), + style = epigraphAuthorTextStyle(language).copy(color = textColor), highlightedRange = null, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth().padding(top = 2.dp), @@ -865,6 +880,7 @@ private fun ReaderCite( hyphenation: HyphenationRegistry, highlightedRange: ReaderSentenceRange?, depth: Int, + textColor: Color = MaterialTheme.colorScheme.onSurface, modifier: Modifier = Modifier, onLinkOpen: (String) -> Unit = {}, ) { @@ -881,6 +897,7 @@ private fun ReaderCite( hyphenation = hyphenation, highlightedRange = null, depth = 0, + textColor = textColor, onLinkOpen = onLinkOpen, ) } @@ -889,7 +906,7 @@ private fun ReaderCite( text = author, language = language, hyphenation = hyphenation, - style = epigraphAuthorTextStyle(language), + style = epigraphAuthorTextStyle(language).copy(color = textColor), highlightedRange = null, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth().padding(top = 2.dp), @@ -906,6 +923,7 @@ private fun ReaderEpigraphBlock( hyphenation: HyphenationRegistry, highlightedRange: ReaderSentenceRange?, depth: Int, + textColor: Color = MaterialTheme.colorScheme.onSurface, onLinkOpen: (String) -> Unit, ) { when (block) { @@ -914,7 +932,7 @@ private fun ReaderEpigraphBlock( text = block.content, language = language, hyphenation = hyphenation, - style = epigraphTextStyle(language), + style = epigraphTextStyle(language).copy(color = textColor), highlightedRange = highlightedRange, textAlign = TextAlign.Start, onLinkOpen = onLinkOpen, @@ -923,7 +941,7 @@ private fun ReaderEpigraphBlock( text = block.content, language = language, hyphenation = hyphenation, - style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold), + style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold, color = textColor), highlightedRange = highlightedRange, textAlign = TextAlign.Center, onLinkOpen = onLinkOpen, @@ -934,6 +952,7 @@ private fun ReaderEpigraphBlock( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = depth, + textColor = textColor, onLinkOpen = onLinkOpen, ) is Fb2EpigraphBlock.Cite -> ReaderCite( @@ -942,6 +961,7 @@ private fun ReaderEpigraphBlock( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = depth, + textColor = textColor, onLinkOpen = onLinkOpen, ) } @@ -1174,80 +1194,120 @@ private fun Modifier.softImageEdgeFade(): Modifier = ) } -private fun Modifier.readerLinkTapHandler( +@Composable +private fun BoxScope.ReaderLinkTouchOverlays( annotatedText: AnnotatedString, - textLayout: TextLayoutResult?, + textLayout: TextLayoutResult, onLinkOpen: (String) -> Unit, -): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) - val link = textLayout?.readerLinkAt( - text = annotatedText, - position = down.position, - minimumTouchWidthPx = ReaderLinkMinimumTouchWidth.toPx(), - minimumTouchHeightPx = ReaderLinkMinimumTouchHeight.toPx(), - horizontalPaddingPx = ReaderLinkHorizontalTouchPadding.toPx(), - verticalPaddingPx = ReaderLinkVerticalTouchPadding.toPx(), - ) - if (link == null) { - waitForUpOrCancellation(pass = PointerEventPass.Main) - return@awaitEachGesture +) { + val density = LocalDensity.current + val touchAreas = remember(annotatedText, textLayout, density) { + with(density) { + textLayout.readerLinkTouchAreas( + text = annotatedText, + trimLeftPx = ReaderLinkTouchTrimLeft.toPx(), + extendRightPx = ReaderLinkTouchExtendRight.toPx(), + minimumHeightPx = ReaderLinkTouchMinimumHeight.toPx(), + verticalPaddingPx = ReaderLinkTouchVerticalPadding.toPx(), + ) } + } - down.consume() - val up = waitForUpOrCancellation(pass = PointerEventPass.Main) - if (up != null) { - up.consume() - onLinkOpen(link) + Box(Modifier.matchParentSize()) { + touchAreas.forEach { area -> + Box( + Modifier + .offset { + IntOffset( + x = area.rect.left.roundToInt(), + y = area.rect.top.roundToInt(), + ) + } + .width(with(density) { area.rect.width.toDp() }) + .height(with(density) { area.rect.height.toDp() }) + // Link hit-area debugging: uncomment this background to visualize the + // actual touch overlay, including the right-side extension. + // .background(ReaderLinkTouchDebugBackground) + .clickable { onLinkOpen(area.target) }, + ) } } } -private fun TextLayoutResult.readerLinkAt( +private data class ReaderLinkTouchArea( + val target: String, + val rect: Rect, +) + +private data class ReaderLinkLineBounds( + val annotationIndex: Int, + val target: String, + val line: Int, + val rect: Rect, +) + +private fun TextLayoutResult.readerLinkTouchAreas( text: AnnotatedString, - position: Offset, - minimumTouchWidthPx: Float, - minimumTouchHeightPx: Float, - horizontalPaddingPx: Float, + trimLeftPx: Float, + extendRightPx: Float, + minimumHeightPx: Float, verticalPaddingPx: Float, -): String? = - text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length) - .firstOrNull { annotation -> - readerLinkTouchBounds( - start = annotation.start, - end = annotation.end, - minimumTouchWidthPx = minimumTouchWidthPx, - minimumTouchHeightPx = minimumTouchHeightPx, - horizontalPaddingPx = horizontalPaddingPx, - verticalPaddingPx = verticalPaddingPx, - ).any { it.contains(position) } +): List { + val linkBounds = text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length) + .sortedBy { it.start } + .flatMapIndexed { annotationIndex, annotation -> + val safeStart = min(annotation.start, layoutInput.text.length) + val safeEnd = min(annotation.end, layoutInput.text.length) + if (safeStart >= safeEnd) return@flatMapIndexed emptyList() + + val startLine = getLineForOffset(safeStart) + val endLine = getLineForOffset(safeEnd - 1) + + (startLine..endLine).mapNotNull { line -> + val lineStart = max(safeStart, getLineStart(line)) + val lineEnd = min(safeEnd, getLineEnd(line)) + if (lineStart >= lineEnd) return@mapNotNull null + + val rangeBounds = getPathForRange(lineStart, lineEnd).getBounds() + val horizontalBounds = Rect( + left = rangeBounds.left, + top = rangeBounds.top, + right = rangeBounds.right, + bottom = rangeBounds.bottom, + ) + ReaderLinkLineBounds( + annotationIndex = annotationIndex, + target = annotation.item, + line = line, + rect = horizontalBounds, + ) + } } - ?.item -private fun TextLayoutResult.readerLinkTouchBounds( - start: Int, - end: Int, - minimumTouchWidthPx: Float, - minimumTouchHeightPx: Float, - horizontalPaddingPx: Float, - verticalPaddingPx: Float, -): List { - if (start >= end) return emptyList() + return linkBounds.map { bounds -> + val nextLinkLeft = linkBounds + .asSequence() + .filter { + it.line == bounds.line && + it.annotationIndex != bounds.annotationIndex && + it.rect.left >= bounds.rect.right + } + .map { it.rect.left } + .minOrNull() + val rightLimit = nextLinkLeft ?: size.width.toFloat() + val right = minOf(bounds.rect.right + extendRightPx, rightLimit, size.width.toFloat()) + .coerceAtLeast(bounds.rect.right) + val touchHeight = max(bounds.rect.height + verticalPaddingPx * 2f, minimumHeightPx) + val centerY = bounds.rect.center.y - val boundsByLine = linkedMapOf() - val safeEnd = min(end, layoutInput.text.length) - for (offset in start until safeEnd) { - val line = getLineForOffset(offset) - val bounds = getBoundingBox(offset) - boundsByLine[line] = boundsByLine[line]?.union(bounds) ?: bounds - } - - return boundsByLine.values.map { bounds -> - bounds.withCenteredTouchPadding( - minimumWidth = minimumTouchWidthPx, - minimumHeight = minimumTouchHeightPx, - horizontalPadding = horizontalPaddingPx, - verticalPadding = verticalPaddingPx, + ReaderLinkTouchArea( + target = bounds.target, + rect = Rect( + left = min(bounds.rect.left + trimLeftPx, bounds.rect.right), + top = max(0f, centerY - touchHeight / 2f), + right = right, + bottom = centerY + touchHeight / 2f, + ), ) } } @@ -1260,23 +1320,6 @@ private fun Rect.union(other: Rect): Rect = bottom = max(bottom, other.bottom), ) -private fun Rect.withCenteredTouchPadding( - minimumWidth: Float, - minimumHeight: Float, - horizontalPadding: Float, - verticalPadding: Float, -): Rect { - val touchWidth = max(width + horizontalPadding * 2f, minimumWidth) - val touchHeight = max(height + verticalPadding * 2f, minimumHeight) - val center = center - return Rect( - left = center.x - touchWidth / 2f, - top = center.y - touchHeight / 2f, - right = center.x + touchWidth / 2f, - bottom = center.y + touchHeight / 2f, - ) -} - private fun AnnotatedString.hasReaderLinks(): Boolean = getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty() @@ -1295,6 +1338,9 @@ private fun Fb2Text.toAnnotatedString( fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null, fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null, color = if (isLink) LinkTextColor else Color.Unspecified, + // Link hit-area debugging: set this to ReaderLinkDebugBackground for links + // to compare the annotated text range with the touch overlay. + background = Color.Unspecified, textDecoration = when { isLink && Fb2TextStyle.Strikethrough in span.styles -> TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) @@ -1308,12 +1354,11 @@ private fun Fb2Text.toAnnotatedString( else -> null }, ) - span.href?.takeIf { it.isNotBlank() }?.let { - pushStringAnnotation(ReaderLinkAnnotationTag, it) - } if (isLink) { - withStyle(spanStyle) { - appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) + withAnnotation(ReaderLinkAnnotationTag, span.href!!) { + withStyle(spanStyle) { + appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) + } } } else { appendWithDetectedWebLinks( @@ -1326,7 +1371,6 @@ private fun Fb2Text.toAnnotatedString( hyphenation = hyphenation, ) } - if (isLink) pop() plainOffset += span.text.length } } @@ -1356,18 +1400,19 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks( } val url = text.substring(range) - pushStringAnnotation(ReaderLinkAnnotationTag, url) - withStyle(spanStyle.readerLinkStyle()) { - appendWithHighlight( - text = url, - plainOffset = plainOffset + range.start, - highlightedRange = highlightedRange, - highlightColor = highlightColor, - language = null, - hyphenation = hyphenation, - ) + val linkStyle = spanStyle.readerLinkStyle() + withAnnotation(ReaderLinkAnnotationTag, url) { + withStyle(linkStyle) { + appendWithHighlight( + text = url, + plainOffset = plainOffset + range.start, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + language = null, + hyphenation = hyphenation, + ) + } } - pop() cursor = range.endInclusive + 1 } @@ -1388,6 +1433,9 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks( private fun SpanStyle.readerLinkStyle(): SpanStyle = copy( color = LinkTextColor, + // Link hit-area debugging: set this to ReaderLinkDebugBackground to compare + // detected URL text ranges with the touch overlay. + background = Color.Unspecified, textDecoration = when (textDecoration) { TextDecoration.LineThrough -> TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) @@ -1798,14 +1846,18 @@ private const val StarBreakPauseMillis = 1_200L private const val EllipsisPauseAfterMillis = 350L private const val CombiningAcuteAccent = '\u0301' private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" -private const val ReaderLinkAnnotationTag = "fb2-link" private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE) private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’') private val LinkTextColor = Color(0xFF0B57D0) -private val ReaderLinkMinimumTouchWidth = 44.dp -private val ReaderLinkMinimumTouchHeight = 40.dp -private val ReaderLinkHorizontalTouchPadding = 10.dp -private val ReaderLinkVerticalTouchPadding = 6.dp +// Link hit-area debugging colors. Kept here so the temporary backgrounds above can be +// re-enabled quickly when continuing touch overlay investigation. +// private val ReaderLinkDebugBackground = Color(0x33FFB300) +// private val ReaderLinkTouchDebugBackground = Color(0x3300BCD4) +private const val ReaderLinkAnnotationTag = "fb2-link" +private val ReaderLinkTouchTrimLeft = 8.dp +private val ReaderLinkTouchExtendRight = 48.dp +private val ReaderLinkTouchMinimumHeight = 34.dp +private val ReaderLinkTouchVerticalPadding = 4.dp private val ReadAloudHardcodedTextReplacements = listOf( ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true), diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 76fed95..98c043e 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -6,9 +6,11 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement @@ -18,6 +20,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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -66,6 +69,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -87,7 +91,9 @@ 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.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -110,6 +116,11 @@ private data class ReaderBookPreparation( val contentPlan: ReaderContentPlan, ) +private enum class ReaderColorPickerTarget { + BACKGROUND, + TEXT, +} + @Composable @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) internal fun BookView( @@ -137,6 +148,8 @@ internal fun BookView( var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) } + var readerExtraSettingsVisible by remember(fileId) { mutableStateOf(false) } + var readerColorPickerTarget by remember(fileId) { mutableStateOf(null) } var fullScreenReader by remember { mutableStateOf(false) } var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) } var tableOfContentsBackStack by remember(fileId) { mutableStateOf>(emptyList()) } @@ -147,6 +160,7 @@ internal fun BookView( var selectedNoteId by remember(fileId) { mutableStateOf(null) } val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() + val useDarkReaderTheme = LocalReaderThemeIsDark.current LaunchedEffect(book) { preparation = withContext(Dispatchers.Default) { @@ -172,6 +186,12 @@ internal fun BookView( val readingProgress by remember(listState) { derivedStateOf { listState.readerProgress() } } + val readerBackgroundColor = readerFontSettings.themeBackgroundArgb(useDarkReaderTheme) + ?.toComposeColor() + ?: MaterialTheme.colorScheme.surface + val readerTextColor = readerFontSettings.themeTextArgb(useDarkReaderTheme) + ?.toComposeColor() + ?: MaterialTheme.colorScheme.onSurface val activeReadAloudSentence = readAloudState.sentenceIndex ?.let { index -> contentPlan.sentences.getOrNull(index) } ?.takeIf { readAloudState.active } @@ -407,6 +427,9 @@ internal fun BookView( onReaderSettings = { readerSettingsPanelVisible = true }, + onReaderExtraSettings = { + readerExtraSettingsVisible = true + }, showReadAloudAction = showReadAloudAction, onReadAloud = { val position = currentReadingPosition() @@ -449,7 +472,7 @@ internal fun BookView( modifier = Modifier .fillMaxSize() .padding(it) - .background(readerBackground()), + .background(readerBackgroundColor), ) { Column(Modifier.fillMaxSize()) { ContinuousBookReader( @@ -460,6 +483,8 @@ internal fun BookView( contentPlan = contentPlan, highlightedSentence = highlightedSentence, readerFontSettings = readerFontSettings, + readerBackgroundColor = readerBackgroundColor, + readerTextColor = readerTextColor, onReaderFontZoom = { steps -> updateReaderFontSettings { settings -> settings.adjustFontSize(steps) } }, @@ -488,8 +513,35 @@ internal fun BookView( onFontWeightIncrease = { updateReaderFontSettings { settings -> settings.adjustFontWeight(1) } }, - onReset = { - updateReaderFontSettings { defaultReaderFontSettings() } + onBackgroundColorChange = { + updateReaderFontSettings { settings -> + settings.withThemeBackgroundArgb( + useDarkReaderTheme, + nextReaderBackgroundColorArgb( + currentArgb = settings.themeBackgroundArgb(useDarkReaderTheme), + customArgb = settings.themeCustomBackgroundArgb(useDarkReaderTheme), + useDarkTheme = useDarkReaderTheme, + ), + ) + } + }, + onBackgroundColorPick = { + readerColorPickerTarget = ReaderColorPickerTarget.BACKGROUND + }, + onTextColorChange = { + updateReaderFontSettings { settings -> + settings.withThemeTextArgb( + useDarkReaderTheme, + nextReaderTextColorArgb( + currentArgb = settings.themeTextArgb(useDarkReaderTheme), + customArgb = settings.themeCustomTextArgb(useDarkReaderTheme), + useDarkTheme = useDarkReaderTheme, + ), + ) + } + }, + onTextColorPick = { + readerColorPickerTarget = ReaderColorPickerTarget.TEXT }, onClose = { readerSettingsPanelVisible = false }, ) @@ -517,6 +569,8 @@ internal fun BookView( progress = readingProgress, fullScreen = fullScreenReader, fullScreenTitle = fullScreenProgressTitle, + backgroundColor = readerBackgroundColor, + textColor = readerTextColor, onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) }, ) } @@ -532,6 +586,50 @@ internal fun BookView( ) } + if (readerExtraSettingsVisible) { + ReaderExtraSettingsDialog( + onRevertColorScheme = { + updateReaderFontSettings { settings -> settings.resetThemeColorScheme(useDarkReaderTheme) } + }, + onDismiss = { readerExtraSettingsVisible = false }, + ) + } + + readerColorPickerTarget?.let { target -> + ReaderColorPickerDialog( + title = when (target) { + ReaderColorPickerTarget.BACKGROUND -> strings.readerBackgroundColor + ReaderColorPickerTarget.TEXT -> strings.readerTextColor + }, + initialArgb = when (target) { + ReaderColorPickerTarget.BACKGROUND -> + readerFontSettings.themeBackgroundArgb(useDarkReaderTheme) ?: readerBackgroundColor.toReaderArgb() + ReaderColorPickerTarget.TEXT -> + readerFontSettings.themeTextArgb(useDarkReaderTheme) ?: readerTextColor.toReaderArgb() + }, + presets = when (target) { + ReaderColorPickerTarget.BACKGROUND -> readerBackgroundPalette(useDarkReaderTheme) + ReaderColorPickerTarget.TEXT -> readerTextPalette(useDarkReaderTheme) + }, + customArgb = when (target) { + ReaderColorPickerTarget.BACKGROUND -> readerFontSettings.themeCustomBackgroundArgb(useDarkReaderTheme) + ReaderColorPickerTarget.TEXT -> readerFontSettings.themeCustomTextArgb(useDarkReaderTheme) + }, + onDismiss = { readerColorPickerTarget = null }, + onApply = { argb -> + updateReaderFontSettings { settings -> + when (target) { + ReaderColorPickerTarget.BACKGROUND -> + settings.withThemeCustomBackgroundArgb(useDarkReaderTheme, argb) + ReaderColorPickerTarget.TEXT -> + settings.withThemeCustomTextArgb(useDarkReaderTheme, argb) + } + } + readerColorPickerTarget = null + }, + ) + } + selectedNoteId?.let { noteId -> Fb2NoteReferenceDialog( book = book, @@ -577,12 +675,14 @@ private fun ReaderProgressPane( progress: Float, fullScreen: Boolean, fullScreenTitle: String, + backgroundColor: Color, + textColor: Color, 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 trackColor = textColor.copy(alpha = 0.34f) + val readColor = textColor.copy(alpha = 0.84f) + val fullScreenFillColor = fullScreenProgressFillColor(textColor) val contentDescription = strings.progressLabel(progress.toDouble()) val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp) val progressRowHeight by animateDpAsState(24.dp) @@ -593,7 +693,7 @@ private fun ReaderProgressPane( Surface( tonalElevation = 2.dp, shadowElevation = 2.dp, - color = MaterialTheme.colorScheme.surface, + color = backgroundColor, modifier = Modifier .fillMaxWidth() .clickable(onClick = onFullScreenToggle), @@ -628,7 +728,7 @@ private fun ReaderProgressPane( Text( text = fullScreenTitle, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = textColor, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -640,7 +740,7 @@ private fun ReaderProgressPane( text = "$percent%", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = textColor, maxLines = 1, ) } @@ -680,7 +780,7 @@ private fun ReaderProgressPane( text = "$percent%", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = textColor, maxLines = 1, ) } @@ -689,9 +789,21 @@ private fun ReaderProgressPane( } } +private fun fullScreenProgressFillColor(textColor: Color): Color { + val luminance = textColor.readerLuminance() + return if (luminance < 0.55f) { + lerp(textColor, Color.White, 0.45f).copy(alpha = 0.22f) + } else { + textColor.copy(alpha = 0.12f) + } +} + +private fun Color.readerLuminance(): Float = + 0.2126f * red + 0.7152f * green + 0.0722f * blue + private fun Fb2Book.fullScreenProgressTitle(): String { val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor } - return "$author. $title" + return "$title. $author." } private fun LazyListState.readerProgress(): Float { @@ -739,6 +851,7 @@ private fun CompactReaderTopBar( showReadAloudAction: Boolean, onReadAloud: () -> Unit, onReaderSettings: () -> Unit, + onReaderExtraSettings: () -> Unit, onDelete: () -> Unit, onBack: () -> Unit, ) { @@ -795,6 +908,16 @@ private fun CompactReaderTopBar( onReaderSettings() }, ) + DropdownMenuItem( + leadingIcon = { + Icon(Icons.Filled.Settings, contentDescription = null) + }, + text = { Text(strings.extraReaderSettings) }, + onClick = { + menuOpen = false + onReaderExtraSettings() + }, + ) DropdownMenuItem( leadingIcon = { Checkbox(checked = fullScreen, onCheckedChange = null) @@ -1062,9 +1185,16 @@ private fun ReaderFontSettingsPanel( onLineHeightIncrease: () -> Unit, onFontWeightDecrease: () -> Unit, onFontWeightIncrease: () -> Unit, - onReset: () -> Unit, + onBackgroundColorChange: () -> Unit, + onBackgroundColorPick: () -> Unit, + onTextColorChange: () -> Unit, + onTextColorPick: () -> Unit, onClose: () -> Unit, ) { + val useDarkTheme = LocalReaderThemeIsDark.current + val backgroundSwatch = settings.themeBackgroundArgb(useDarkTheme)?.toComposeColor() ?: MaterialTheme.colorScheme.surface + val textSwatch = settings.themeTextArgb(useDarkTheme)?.toComposeColor() ?: MaterialTheme.colorScheme.onSurface + Surface( tonalElevation = 3.dp, shadowElevation = 4.dp, @@ -1074,34 +1204,48 @@ private fun ReaderFontSettingsPanel( Column { Row( modifier = Modifier.fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(bottom = 0.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally), + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { // Text("A ${settings.fontSizeSp.roundToInt()}", style = MaterialTheme.typography.labelMedium) - IconButton(onClick = onFontSizeDecrease) { + ReaderSettingsButton( + contentDescription = strings.readerFontSizeDecrease, + onClick = onFontSizeDecrease, + ) { ReaderLetterChangeIcon( letter = "A", increase = false, contentDescription = strings.readerFontSizeDecrease, ) } - IconButton(onClick = onFontSizeIncrease) { + ReaderSettingsButton( + contentDescription = strings.readerFontSizeIncrease, + onClick = onFontSizeIncrease, + ) { ReaderLetterChangeIcon( letter = "A", increase = true, contentDescription = strings.readerFontSizeIncrease, ) } - IconButton(onClick = onLineHeightDecrease) { - Icon(Icons.Filled.UnfoldLess, contentDescription = strings.readerLineHeightDecrease) + ReaderSettingsButton( + contentDescription = strings.readerLineHeightDecrease, + onClick = onLineHeightDecrease, + ) { + Icon(Icons.Filled.UnfoldLess, contentDescription = null) } - IconButton(onClick = onLineHeightIncrease) { - Icon(Icons.Filled.UnfoldMore, contentDescription = strings.readerLineHeightIncrease) + ReaderSettingsButton( + contentDescription = strings.readerLineHeightIncrease, + onClick = onLineHeightIncrease, + ) { + Icon(Icons.Filled.UnfoldMore, contentDescription = null) } // Text("W ${settings.fontWeight}", style = MaterialTheme.typography.labelMedium) - IconButton(onClick = onFontWeightDecrease) { + ReaderSettingsButton( + contentDescription = strings.readerFontWeightDecrease, + onClick = onFontWeightDecrease, + ) { ReaderLetterChangeIcon( letter = "B", increase = false, @@ -1109,7 +1253,10 @@ private fun ReaderFontSettingsPanel( fontWeight = FontWeight.Bold, ) } - IconButton(onClick = onFontWeightIncrease) { + ReaderSettingsButton( + contentDescription = strings.readerFontWeightIncrease, + onClick = onFontWeightIncrease, + ) { ReaderLetterChangeIcon( letter = "B", increase = true, @@ -1117,11 +1264,31 @@ private fun ReaderFontSettingsPanel( fontWeight = FontWeight.Bold, ) } - IconButton(onClick = onReset) { - Icon(Icons.Filled.RestartAlt, contentDescription = strings.resetReaderFontSettings) + ColorSettingButton( + contentDescription = strings.readerBackgroundColor, + onClick = onBackgroundColorChange, + onLongClick = onBackgroundColorPick, + ) { + ColorSwatchIcon(color = backgroundSwatch) } - IconButton(onClick = onClose) { - Icon(Icons.Filled.Close, contentDescription = strings.closeReaderSettings) + ColorSettingButton( + contentDescription = strings.readerTextColor, + onClick = onTextColorChange, + onLongClick = onTextColorPick, + ) { + ReaderLetterChangeIcon( + letter = "T", + increase = true, + contentDescription = strings.readerTextColor, + fontWeight = FontWeight.Bold, + color = textSwatch, + ) + } + ReaderSettingsButton( + contentDescription = strings.closeReaderSettings, + onClick = onClose, + ) { + Icon(Icons.Filled.Close, contentDescription = null) } } // Row(Modifier.fillMaxWidth().padding(bottom = 2.dp, top = 0.dp), horizontalArrangement = Arrangement.Center) { @@ -1142,17 +1309,320 @@ private fun ReaderLetterChangeIcon( increase: Boolean, contentDescription: String, fontWeight: FontWeight = FontWeight.SemiBold, + color: Color = Color.Unspecified, ) { Text( text = letter + if (increase) "⁺" else "⁻", style = MaterialTheme.typography.titleMedium, fontWeight = fontWeight, + color = color, modifier = Modifier.semantics { this.contentDescription = contentDescription }, ) } +@Composable +private fun RowScope.ReaderSettingsButton( + contentDescription: String, + onClick: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .clickable(onClick = onClick) + .semantics { + this.contentDescription = contentDescription + }, + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RowScope.ColorSettingButton( + contentDescription: String, + onClick: () -> Unit, + onLongClick: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .semantics { + this.contentDescription = contentDescription + }, + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Composable +private fun ColorSwatchIcon(color: Color) { + Box( + modifier = Modifier + .size(22.dp) + .background(color), + ) +} + +@Composable +private fun ReaderExtraSettingsDialog( + onRevertColorScheme: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.extraReaderSettings) }, + text = { + Column(Modifier.fillMaxWidth()) { + ReaderExtraSettingsActionRow( + text = strings.revertColorScheme, + onClick = onRevertColorScheme, + ) + HorizontalDivider() + readerExtraSettingOptions().forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { } + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = option, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = ">", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.outline, + ) + } + HorizontalDivider() + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(strings.done) + } + }, + ) +} + +@Composable +private fun ReaderExtraSettingsActionRow(text: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun ReaderColorPickerDialog( + title: String, + initialArgb: Long, + presets: List, + customArgb: Long?, + onDismiss: () -> Unit, + onApply: (Long) -> Unit, +) { + val initialRgb = remember(initialArgb) { initialArgb.toRgbComponents() } + var red by remember(initialArgb) { mutableStateOf(initialRgb.red) } + var green by remember(initialArgb) { mutableStateOf(initialRgb.green) } + var blue by remember(initialArgb) { mutableStateOf(initialRgb.blue) } + val selectedArgb = rgbToReaderArgb(red, green, blue) + val swatches = remember(presets, customArgb) { presets.withCustomColor(customArgb) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + color = selectedArgb.toComposeColor(), + modifier = Modifier.fillMaxWidth().height(48.dp), + ) {} + if (customArgb != null) { + Text(strings.customColor, style = MaterialTheme.typography.labelMedium) + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + swatches.forEach { argb -> + Box( + modifier = Modifier + .size(36.dp) + .background(argb.toComposeColor()) + .clickable { + val rgb = argb.toRgbComponents() + red = rgb.red + green = rgb.green + blue = rgb.blue + }, + ) + } + } + ReaderColorSlider(label = strings.red, value = red, onValueChange = { red = it }) + ReaderColorSlider(label = strings.green, value = green, onValueChange = { green = it }) + ReaderColorSlider(label = strings.blue, value = blue, onValueChange = { blue = it }) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(strings.cancel) + } + }, + confirmButton = { + TextButton(onClick = { onApply(selectedArgb) }) { + Text(strings.done) + } + }, + ) +} + +@Composable +private fun ReaderColorSlider(label: String, value: Int, onValueChange: (Int) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.size(36.dp), + ) + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt().coerceIn(0, 255)) }, + valueRange = 0f..255f, + modifier = Modifier.weight(1f), + ) + Text( + text = value.toString(), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.End, + modifier = Modifier.size(36.dp), + ) + } +} + +private fun readerExtraSettingOptions(): List = + listOf( + strings.readerColors, + strings.readerTypography, + strings.readerLayout, + strings.readerNavigation, + strings.readerProgress, + ) + +private fun nextReaderBackgroundColorArgb(currentArgb: Long?, customArgb: Long?, useDarkTheme: Boolean): Long? { + val palette = readerBackgroundPalette(useDarkTheme).withCustomColor(customArgb) + return nextReaderPaletteArgb(currentArgb, palette) +} + +private fun nextReaderTextColorArgb(currentArgb: Long?, customArgb: Long?, useDarkTheme: Boolean): Long? { + val palette = readerTextPalette(useDarkTheme).withCustomColor(customArgb) + return nextReaderPaletteArgb(currentArgb, palette) +} + +private fun nextReaderPaletteArgb(currentArgb: Long?, palette: List): Long? { + val index = palette.indexOf(currentArgb) + return when { + index < 0 -> palette.firstOrNull() + index < palette.lastIndex -> palette[index + 1] + else -> null + } +} + +private fun readerBackgroundPalette(useDarkTheme: Boolean): List = + if (useDarkTheme) DarkReaderBackgroundPalette else LightReaderBackgroundPalette + +private fun readerTextPalette(useDarkTheme: Boolean): List = + if (useDarkTheme) DarkReaderTextPalette else LightReaderTextPalette + +private fun List.withCustomColor(customArgb: Long?): List = + if (customArgb == null || customArgb in this) this else this + customArgb + +private data class RgbComponents( + val red: Int, + val green: Int, + val blue: Int, +) + +private fun Long.toRgbComponents(): RgbComponents = + RgbComponents( + red = ((this shr 16) and 0xFF).toInt(), + green = ((this shr 8) and 0xFF).toInt(), + blue = (this and 0xFF).toInt(), + ) + +private fun Color.toReaderArgb(): Long { + fun Float.componentToByte(): Long = (coerceIn(0f, 1f) * 255f).roundToInt().coerceIn(0, 255).toLong() + return (alpha.componentToByte() shl 24) or + (red.componentToByte() shl 16) or + (green.componentToByte() shl 8) or + blue.componentToByte() +} + +private fun rgbToReaderArgb(red: Int, green: Int, blue: Int): Long = + (0xFFL shl 24) or + (red.coerceIn(0, 255).toLong() shl 16) or + (green.coerceIn(0, 255).toLong() shl 8) or + blue.coerceIn(0, 255).toLong() + +private val LightReaderBackgroundPalette = listOf( + 0xFFF7F2EAL, + 0xFFF4F0D8L, + 0xFFEAF3F0L, + 0xFFECEFF5L, +) + +private val DarkReaderBackgroundPalette = listOf( + 0xFF171411L, + 0xFF1B2421L, + 0xFF181E29L, + 0xFF24211DL, +) + +private val LightReaderTextPalette = listOf( + 0xFF005C51L, + 0xFF7A1F1FL, + 0xFF243A8FL, + 0xFF6B2A6BL, +) + +private val DarkReaderTextPalette = listOf( + 0xFFD8E8E2L, + 0xFFE2C58FL, + 0xFFECEFF5L, +) + @Composable private fun ReadAloudPanel( playing: Boolean, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt index ea11c65..0f3ee29 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/SharedUi.kt @@ -141,6 +141,14 @@ internal fun themedTopAppBarColors(): TopAppBarColors = @Composable internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background) +internal fun Long.toComposeColor(): Color = + Color( + alpha = ((this shr 24) and 0xFF).toInt(), + red = ((this shr 16) and 0xFF).toInt(), + green = ((this shr 8) and 0xFF).toInt(), + blue = (this and 0xFF).toInt(), + ) + @Composable internal fun readerImageBackgroundColor(): Color { val surface = MaterialTheme.colorScheme.surface diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt index ba6bbd2..e89d47a 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt @@ -1,7 +1,10 @@ package net.sergeych.toread +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color +internal val LocalReaderThemeIsDark = compositionLocalOf { false } + internal fun ThemeMode.next(): ThemeMode = when (this) { ThemeMode.SYSTEM -> ThemeMode.LIGHT 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 ba42768..617618b 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -475,9 +475,18 @@ 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 + val saved = db.getAppFlag(LibraryFilterFlag) + when { + saved == null -> { + appendLibraryLog("warning library filter was not saved; using ${LibraryFilter.MyLibrary.name}") + LibraryFilter.MyLibrary + } + else -> runCatching { LibraryFilter.valueOf(saved) } + .getOrElse { + appendLibraryLog("warning invalid saved library filter '$saved'; using ${LibraryFilter.MyLibrary.name}") + LibraryFilter.MyLibrary + } + } } } 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 3cf6bd4..9a2cc03 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -95,9 +95,17 @@ 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 + when (val saved = window.localStorage.getItem(LibraryFilterStorageKey)) { + null -> { + warnLibraryFilterFallback("library filter was not saved") + LibraryFilter.MyLibrary + } + else -> runCatching { LibraryFilter.valueOf(saved) } + .getOrElse { + warnLibraryFilterFallback("invalid saved library filter '$saved'") + LibraryFilter.MyLibrary + } + } internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) { window.localStorage.setItem(LibraryFilterStorageKey, filter.name) @@ -136,6 +144,10 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit { actual fun libraryLogPath(): String? = null +private fun warnLibraryFilterFallback(reason: String) { + println("warning $reason; using ${LibraryFilter.MyLibrary.name}") +} + private const val AppLocaleStorageKey = "toread.appLocaleTag" private const val LibraryFilterStorageKey = "toread.libraryFilter" private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"