+better notes click processing
+android & universal UI tweaks +settings to change text and background color
This commit is contained in:
parent
f344befbdd
commit
85d97d5a90
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<String?>()
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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,6 +105,7 @@ fun App() {
|
||||
}
|
||||
|
||||
MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) {
|
||||
CompositionLocalProvider(LocalReaderThemeIsDark provides useDark) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
if (localePreferenceLoaded) {
|
||||
@ -125,6 +127,7 @@ fun App() {
|
||||
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -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,
|
||||
|
||||
@ -27,6 +27,7 @@ internal sealed interface AppState {
|
||||
val libraryItems: List<LibraryItem>,
|
||||
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<LibraryItem>,
|
||||
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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -530,6 +530,7 @@ internal fun LibraryScreen(
|
||||
libraryItems = readerLibraryItems,
|
||||
scanPath = state.scanPath,
|
||||
message = message,
|
||||
selectedFilter = state.selectedFilter,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(
|
||||
|
||||
@ -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 = "Показать файл"
|
||||
|
||||
@ -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<TextLayoutResult?>(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(
|
||||
},
|
||||
)
|
||||
|
||||
Box(modifier = textModifier) {
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = style,
|
||||
textAlign = textAlign,
|
||||
modifier = textModifier,
|
||||
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(
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val touchAreas = remember(annotatedText, textLayout, density) {
|
||||
with(density) {
|
||||
textLayout.readerLinkTouchAreas(
|
||||
text = annotatedText,
|
||||
position = down.position,
|
||||
minimumTouchWidthPx = ReaderLinkMinimumTouchWidth.toPx(),
|
||||
minimumTouchHeightPx = ReaderLinkMinimumTouchHeight.toPx(),
|
||||
horizontalPaddingPx = ReaderLinkHorizontalTouchPadding.toPx(),
|
||||
verticalPaddingPx = ReaderLinkVerticalTouchPadding.toPx(),
|
||||
trimLeftPx = ReaderLinkTouchTrimLeft.toPx(),
|
||||
extendRightPx = ReaderLinkTouchExtendRight.toPx(),
|
||||
minimumHeightPx = ReaderLinkTouchMinimumHeight.toPx(),
|
||||
verticalPaddingPx = ReaderLinkTouchVerticalPadding.toPx(),
|
||||
)
|
||||
if (link == null) {
|
||||
waitForUpOrCancellation(pass = PointerEventPass.Main)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
|
||||
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<ReaderLinkTouchArea> {
|
||||
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<Rect> {
|
||||
if (start >= end) return emptyList()
|
||||
|
||||
val boundsByLine = linkedMapOf<Int, Rect>()
|
||||
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,
|
||||
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
|
||||
|
||||
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,13 +1354,12 @@ private fun Fb2Text.toAnnotatedString(
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
span.href?.takeIf { it.isNotBlank() }?.let {
|
||||
pushStringAnnotation(ReaderLinkAnnotationTag, it)
|
||||
}
|
||||
if (isLink) {
|
||||
withAnnotation(ReaderLinkAnnotationTag, span.href!!) {
|
||||
withStyle(spanStyle) {
|
||||
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appendWithDetectedWebLinks(
|
||||
text = span.text,
|
||||
@ -1326,7 +1371,6 @@ private fun Fb2Text.toAnnotatedString(
|
||||
hyphenation = hyphenation,
|
||||
)
|
||||
}
|
||||
if (isLink) pop()
|
||||
plainOffset += span.text.length
|
||||
}
|
||||
}
|
||||
@ -1356,8 +1400,9 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
|
||||
}
|
||||
|
||||
val url = text.substring(range)
|
||||
pushStringAnnotation(ReaderLinkAnnotationTag, url)
|
||||
withStyle(spanStyle.readerLinkStyle()) {
|
||||
val linkStyle = spanStyle.readerLinkStyle()
|
||||
withAnnotation(ReaderLinkAnnotationTag, url) {
|
||||
withStyle(linkStyle) {
|
||||
appendWithHighlight(
|
||||
text = url,
|
||||
plainOffset = plainOffset + range.start,
|
||||
@ -1367,7 +1412,7 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
|
||||
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),
|
||||
|
||||
@ -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<ReaderColorPickerTarget?>(null) }
|
||||
var fullScreenReader by remember { mutableStateOf(false) }
|
||||
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
|
||||
@ -147,6 +160,7 @@ internal fun BookView(
|
||||
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(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<Long>,
|
||||
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<String> =
|
||||
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>): 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<Long> =
|
||||
if (useDarkTheme) DarkReaderBackgroundPalette else LightReaderBackgroundPalette
|
||||
|
||||
private fun readerTextPalette(useDarkTheme: Boolean): List<Long> =
|
||||
if (useDarkTheme) DarkReaderTextPalette else LightReaderTextPalette
|
||||
|
||||
private fun List<Long>.withCustomColor(customArgb: Long?): List<Long> =
|
||||
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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user