+better notes click processing

+android & universal UI tweaks
+settings to change text and background color
This commit is contained in:
Sergey Chernov 2026-06-09 21:30:36 +03:00
parent f344befbdd
commit 85d97d5a90
13 changed files with 925 additions and 193 deletions

View File

@ -499,9 +499,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) { internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag) val saved = db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() } when {
?: LibraryFilter.MyLibrary 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
}
}
} }
} }

View File

@ -4,6 +4,7 @@ import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -55,7 +58,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
} }
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, true) configureEdgeToEdgeBookWindow()
initToreadPlatform(this, this) initToreadPlatform(this, this)
rememberPlatformOpenBookIntent(intent) rememberPlatformOpenBookIntent(intent)
@ -67,6 +70,11 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
} }
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideNavigationBar()
}
override suspend fun chooseDirectory(): String? { override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>() val result = CompletableDeferred<String?>()
runOnUiThread { runOnUiThread {
@ -175,6 +183,27 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 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 = private fun downloadsPath(): String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: Environment.getExternalStorageDirectory().absolutePath ?: Environment.getExternalStorageDirectory().absolutePath
@ -185,7 +214,7 @@ private fun AndroidAppWindow(content: @Composable () -> Unit) {
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)), .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
) { ) {
content() content()
} }

View File

@ -13,6 +13,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -104,25 +105,27 @@ fun App() {
} }
MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) { MaterialTheme(colorScheme = if (useDark) darkReaderColorScheme() else lightReaderColorScheme()) {
Box(Modifier.fillMaxSize()) { CompositionLocalProvider(LocalReaderThemeIsDark provides useDark) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Box(Modifier.fillMaxSize()) {
if (localePreferenceLoaded) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
BookReaderApp( if (localePreferenceLoaded) {
onThemeToggle = { BookReaderApp(
val next = themeMode.next() onThemeToggle = {
themeMode = next val next = themeMode.next()
showToast(strings.themeChanged(next)) themeMode = next
scope.launch { showToast(strings.themeChanged(next))
saveThemeMode(next) scope.launch {
} saveThemeMode(next)
}, }
onShowToast = ::showToast, },
) onShowToast = ::showToast,
} else { )
LoadingScreen(strings.loadingOpeningBook) } 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, book = book,
libraryItems = emptyList(), libraryItems = emptyList(),
scanPath = scanPath, 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 { }.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter()) AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter())
@ -357,12 +367,18 @@ private fun BookReaderApp(
libraryItems = current.libraryItems, libraryItems = current.libraryItems,
scanPath = current.scanPath, scanPath = current.scanPath,
message = current.message, message = current.message,
selectedFilter = current.selectedFilter,
) )
is AppState.Reader -> { is AppState.Reader -> {
scope.launch { saveActiveReadingFileId(null) } scope.launch { saveActiveReadingFileId(null) }
refreshLibraryItem(current.fileId) refreshLibraryItem(current.fileId)
val backState = libraryBackState?.copy(message = current.message) 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 libraryBackState = null
downloadsRescanRequestGeneration += 1 downloadsRescanRequestGeneration += 1
backState backState
@ -523,11 +539,12 @@ private fun BookReaderApp(
libraryItems = current.libraryItems, libraryItems = current.libraryItems,
scanPath = current.scanPath, scanPath = current.scanPath,
message = current.message, message = current.message,
selectedFilter = current.selectedFilter,
) )
}, },
onDeleted = { message -> onDeleted = { message ->
state = libraryBackState?.copy(message = message) state = libraryBackState?.copy(message = message)
?: AppState.Library(emptyList(), current.scanPath, message) ?: AppState.Library(emptyList(), current.scanPath, message, current.selectedFilter)
libraryBackState = null libraryBackState = null
}, },
onDeleteRequested = ::requestDelete, onDeleteRequested = ::requestDelete,

View File

@ -27,6 +27,7 @@ internal sealed interface AppState {
val libraryItems: List<LibraryItem>, val libraryItems: List<LibraryItem>,
val scanPath: String, val scanPath: String,
val message: String? = null, val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState ) : AppState
data class BookInfo( data class BookInfo(
@ -35,6 +36,7 @@ internal sealed interface AppState {
val libraryItems: List<LibraryItem>, val libraryItems: List<LibraryItem>,
val scanPath: String, val scanPath: String,
val message: String? = null, val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState ) : AppState
data class Error(val message: String) : AppState data class Error(val message: String) : AppState
@ -59,6 +61,7 @@ internal suspend fun loadStartupState(): AppState {
book = book, book = book,
libraryItems = emptyList(), libraryItems = emptyList(),
scanPath = scanPath, scanPath = scanPath,
selectedFilter = libraryFilter,
) )
}.getOrElse { }.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter) 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), book = parseBookInBackground(bytes, item.storageUri ?: item.title),
libraryItems = emptyList(), libraryItems = emptyList(),
scanPath = scanPath, scanPath = scanPath,
selectedFilter = libraryFilter,
) )
}.getOrElse { }.getOrElse {
saveActiveReadingFileId(null) saveActiveReadingFileId(null)

View File

@ -70,6 +70,14 @@ data class ReaderFontSettings(
val lineHeightSp: Float = DefaultReaderLineHeightSp, val lineHeightSp: Float = DefaultReaderLineHeightSp,
val fontWeight: Int = defaultReaderFontWeight(), val fontWeight: Int = defaultReaderFontWeight(),
val fontName: String? = null, 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( data class BookInfoExtras(
@ -255,6 +263,14 @@ internal fun ReaderFontSettings.coerced(): ReaderFontSettings =
lineHeightSp = lineHeightSp.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp), lineHeightSp = lineHeightSp.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp),
fontWeight = fontWeight.coerceIn(MinReaderFontWeight, MaxReaderFontWeight), fontWeight = fontWeight.coerceIn(MinReaderFontWeight, MaxReaderFontWeight),
fontName = fontName?.takeIf { it.isNotBlank() }, 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 { internal fun ReaderFontSettings.adjustFontSize(steps: Int): ReaderFontSettings {
@ -295,6 +311,14 @@ internal fun ReaderFontSettings.toStorageValue(): String =
current.lineHeightSp.toString(), current.lineHeightSp.toString(),
current.fontWeight.toString(), current.fontWeight.toString(),
current.fontName.orEmpty(), 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("|") ).joinToString("|")
} }
@ -306,5 +330,71 @@ internal fun readerFontSettingsFromStorageValue(value: String?): ReaderFontSetti
lineHeightSp = parts.getOrNull(1)?.toFloatOrNull() ?: defaults.lineHeightSp, lineHeightSp = parts.getOrNull(1)?.toFloatOrNull() ?: defaults.lineHeightSp,
fontWeight = parts.getOrNull(2)?.toIntOrNull() ?: defaults.fontWeight, fontWeight = parts.getOrNull(2)?.toIntOrNull() ?: defaults.fontWeight,
fontName = parts.getOrNull(3)?.takeIf { it.isNotBlank() } ?: defaults.fontName, 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() ).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()

View File

@ -530,6 +530,7 @@ internal fun LibraryScreen(
libraryItems = readerLibraryItems, libraryItems = readerLibraryItems,
scanPath = state.scanPath, scanPath = state.scanPath,
message = message, message = message,
selectedFilter = state.selectedFilter,
) )
}.getOrElse { }.getOrElse {
AppState.Library( AppState.Library(

View File

@ -44,6 +44,7 @@ internal open class AppStrings {
open val undo = "Undo" open val undo = "Undo"
open val retry = "Retry" open val retry = "Retry"
open val done = "Done" open val done = "Done"
open val cancel = "Cancel"
open val libraryError = "Library error" open val libraryError = "Library error"
open val couldNotOpenLibrary = "Could not open library." open val couldNotOpenLibrary = "Could not open library."
@ -147,6 +148,19 @@ internal open class AppStrings {
open val readerFontWeightIncrease = "Increase font weight" open val readerFontWeightIncrease = "Increase font weight"
open val readerFontWeightDecrease = "Decrease font weight" open val readerFontWeightDecrease = "Decrease font weight"
open val resetReaderFontSettings = "Reset font settings" 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 closeReaderSettings = "Close reader settings"
open val share = "Share" open val share = "Share"
open val viewFile = "View file" open val viewFile = "View file"
@ -255,6 +269,7 @@ internal object RussianStrings : AppStrings() {
override val undo = "Отменить" override val undo = "Отменить"
override val retry = "Повторить" override val retry = "Повторить"
override val done = "Готово" override val done = "Готово"
override val cancel = "Отмена"
override val libraryError = "Ошибка библиотеки" override val libraryError = "Ошибка библиотеки"
override val couldNotOpenLibrary = "Не удалось открыть библиотеку." override val couldNotOpenLibrary = "Не удалось открыть библиотеку."
@ -358,6 +373,19 @@ internal object RussianStrings : AppStrings() {
override val readerFontWeightIncrease = "Увеличить насыщенность шрифта" override val readerFontWeightIncrease = "Увеличить насыщенность шрифта"
override val readerFontWeightDecrease = "Уменьшить насыщенность шрифта" override val readerFontWeightDecrease = "Уменьшить насыщенность шрифта"
override val resetReaderFontSettings = "Сбросить настройки шрифта" 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 closeReaderSettings = "Закрыть настройки чтения"
override val share = "Поделиться" override val share = "Поделиться"
override val viewFile = "Показать файл" override val viewFile = "Показать файл"

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass 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.PointerType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 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.LineBreak
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -122,6 +124,8 @@ internal fun ContinuousBookReader(
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) }, contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null, highlightedSentence: ReadAloudSentence? = null,
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(), readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
readerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
readerTextColor: Color = MaterialTheme.colorScheme.onSurface,
onReaderFontZoom: (Int) -> Unit = {}, onReaderFontZoom: (Int) -> Unit = {},
onUserScroll: () -> Unit = {}, onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
@ -145,7 +149,7 @@ internal fun ContinuousBookReader(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surface) .background(readerBackgroundColor)
.nestedScroll(userScrollConnection) .nestedScroll(userScrollConnection)
.readerFontZoomGesture(onReaderFontZoom) .readerFontZoomGesture(onReaderFontZoom)
.pageTurnOnTouchTap( .pageTurnOnTouchTap(
@ -202,7 +206,7 @@ internal fun ContinuousBookReader(
0 -> MaterialTheme.typography.headlineMedium 0 -> MaterialTheme.typography.headlineMedium
1 -> MaterialTheme.typography.titleLarge 1 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium else -> MaterialTheme.typography.titleMedium
}, }.copy(color = readerTextColor),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = if (element.depth == 0) 36.sp else 28.sp, lineHeight = if (element.depth == 0) 36.sp else 28.sp,
modifier = titleModifier, modifier = titleModifier,
@ -219,7 +223,7 @@ internal fun ContinuousBookReader(
text = element.text, text = element.text,
language = book.language, language = book.language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language, readerFontSettings), style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Justify, textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp), modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
@ -230,7 +234,7 @@ internal fun ContinuousBookReader(
text = element.text, text = element.text,
language = book.language, language = book.language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold, color = readerTextColor),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
@ -243,6 +247,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen, onLinkOpen = onNoteOpen,
) )
is ReaderElement.Cite -> ReaderCite( is ReaderElement.Cite -> ReaderCite(
@ -251,6 +256,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen, onLinkOpen = onNoteOpen,
) )
is ReaderElement.Poem -> ReaderPoem( is ReaderElement.Poem -> ReaderPoem(
@ -259,6 +265,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
textColor = readerTextColor,
onLinkOpen = onNoteOpen, onLinkOpen = onNoteOpen,
) )
} }
@ -332,6 +339,8 @@ private fun Modifier.pageTurnOnTouchTap(
val change = event.changes.firstOrNull { it.id == down.id } val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) { if (change == null) {
cancelled = true cancelled = true
} else if (change.isConsumed) {
cancelled = true
} else if (change.pressed) { } else if (change.pressed) {
val dx = change.position.x - down.position.x val dx = change.position.x - down.position.x
val dy = change.position.y - down.position.y val dy = change.position.y - down.position.y
@ -699,27 +708,19 @@ private fun ReaderText(
onLinkOpen: (String) -> Unit = {}, onLinkOpen: (String) -> Unit = {},
) { ) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer 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 hasLinks = annotatedText.hasReaderLinks()
val needsSoftHyphenPaintWorkaround = isDesktopPlatform val needsSoftHyphenPaintWorkaround = isDesktopPlatform
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) } 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 desktopHyphenGutter = 8.dp
val textModifier = modifier val textModifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (hasLinks) {
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.readerLinkTapHandler(
annotatedText = annotatedText,
textLayout = textLayout,
onLinkOpen = onLinkOpen,
)
} else {
Modifier
},
)
.then( .then(
if (needsSoftHyphenPaintWorkaround) { if (needsSoftHyphenPaintWorkaround) {
Modifier.drawWithContent { Modifier.drawWithContent {
@ -751,16 +752,26 @@ private fun ReaderText(
}, },
) )
Text( Box(modifier = textModifier) {
text = annotatedText, Text(
style = style, text = annotatedText,
textAlign = textAlign, style = style,
modifier = textModifier, textAlign = textAlign,
onTextLayout = { modifier = Modifier.fillMaxWidth(),
textLayout = it onTextLayout = {
onTextLayout(it) textLayout = it
}, onTextLayout(it)
) },
)
val layout = textLayout
if (hasLinks && layout != null) {
ReaderLinkTouchOverlays(
annotatedText = annotatedText,
textLayout = layout,
onLinkOpen = onLinkOpen,
)
}
}
} }
@Composable @Composable
@ -770,6 +781,7 @@ private fun ReaderPoem(
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {}, onLinkOpen: (String) -> Unit = {},
) { ) {
@ -786,6 +798,7 @@ private fun ReaderPoem(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = 0, depth = 0,
textColor = textColor,
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp), modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
) )
@ -798,7 +811,7 @@ private fun ReaderPoem(
text = segment.text, text = segment.text,
language = language, language = language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = poemTextStyle(segment.kind, language), style = poemTextStyle(segment.kind, language).copy(color = textColor),
highlightedRange = highlightedRange?.forSegment(segment), highlightedRange = highlightedRange?.forSegment(segment),
textAlign = when (segment.kind) { textAlign = when (segment.kind) {
ReaderPoemSegmentKind.Title, ReaderPoemSegmentKind.Title,
@ -824,6 +837,7 @@ private fun ReaderEpigraph(
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {}, onLinkOpen: (String) -> Unit = {},
) { ) {
@ -840,6 +854,7 @@ private fun ReaderEpigraph(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
textColor = textColor,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
) )
} }
@ -848,7 +863,7 @@ private fun ReaderEpigraph(
text = author, text = author,
language = language, language = language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language), style = epigraphAuthorTextStyle(language).copy(color = textColor),
highlightedRange = null, highlightedRange = null,
textAlign = TextAlign.End, textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp), modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
@ -865,6 +880,7 @@ private fun ReaderCite(
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {}, onLinkOpen: (String) -> Unit = {},
) { ) {
@ -881,6 +897,7 @@ private fun ReaderCite(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
textColor = textColor,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
) )
} }
@ -889,7 +906,7 @@ private fun ReaderCite(
text = author, text = author,
language = language, language = language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language), style = epigraphAuthorTextStyle(language).copy(color = textColor),
highlightedRange = null, highlightedRange = null,
textAlign = TextAlign.End, textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp), modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
@ -906,6 +923,7 @@ private fun ReaderEpigraphBlock(
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
textColor: Color = MaterialTheme.colorScheme.onSurface,
onLinkOpen: (String) -> Unit, onLinkOpen: (String) -> Unit,
) { ) {
when (block) { when (block) {
@ -914,7 +932,7 @@ private fun ReaderEpigraphBlock(
text = block.content, text = block.content,
language = language, language = language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = epigraphTextStyle(language), style = epigraphTextStyle(language).copy(color = textColor),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
@ -923,7 +941,7 @@ private fun ReaderEpigraphBlock(
text = block.content, text = block.content,
language = language, language = language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold), style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold, color = textColor),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
@ -934,6 +952,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = depth, depth = depth,
textColor = textColor,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
) )
is Fb2EpigraphBlock.Cite -> ReaderCite( is Fb2EpigraphBlock.Cite -> ReaderCite(
@ -942,6 +961,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = depth, depth = depth,
textColor = textColor,
onLinkOpen = onLinkOpen, onLinkOpen = onLinkOpen,
) )
} }
@ -1174,80 +1194,120 @@ private fun Modifier.softImageEdgeFade(): Modifier =
) )
} }
private fun Modifier.readerLinkTapHandler( @Composable
private fun BoxScope.ReaderLinkTouchOverlays(
annotatedText: AnnotatedString, annotatedText: AnnotatedString,
textLayout: TextLayoutResult?, textLayout: TextLayoutResult,
onLinkOpen: (String) -> Unit, onLinkOpen: (String) -> Unit,
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) { ) {
awaitEachGesture { val density = LocalDensity.current
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) val touchAreas = remember(annotatedText, textLayout, density) {
val link = textLayout?.readerLinkAt( with(density) {
text = annotatedText, textLayout.readerLinkTouchAreas(
position = down.position, text = annotatedText,
minimumTouchWidthPx = ReaderLinkMinimumTouchWidth.toPx(), trimLeftPx = ReaderLinkTouchTrimLeft.toPx(),
minimumTouchHeightPx = ReaderLinkMinimumTouchHeight.toPx(), extendRightPx = ReaderLinkTouchExtendRight.toPx(),
horizontalPaddingPx = ReaderLinkHorizontalTouchPadding.toPx(), minimumHeightPx = ReaderLinkTouchMinimumHeight.toPx(),
verticalPaddingPx = ReaderLinkVerticalTouchPadding.toPx(), verticalPaddingPx = ReaderLinkTouchVerticalPadding.toPx(),
) )
if (link == null) {
waitForUpOrCancellation(pass = PointerEventPass.Main)
return@awaitEachGesture
} }
}
down.consume() Box(Modifier.matchParentSize()) {
val up = waitForUpOrCancellation(pass = PointerEventPass.Main) touchAreas.forEach { area ->
if (up != null) { Box(
up.consume() Modifier
onLinkOpen(link) .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, text: AnnotatedString,
position: Offset, trimLeftPx: Float,
minimumTouchWidthPx: Float, extendRightPx: Float,
minimumTouchHeightPx: Float, minimumHeightPx: Float,
horizontalPaddingPx: Float,
verticalPaddingPx: Float, verticalPaddingPx: Float,
): String? = ): List<ReaderLinkTouchArea> {
text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length) val linkBounds = text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length)
.firstOrNull { annotation -> .sortedBy { it.start }
readerLinkTouchBounds( .flatMapIndexed { annotationIndex, annotation ->
start = annotation.start, val safeStart = min(annotation.start, layoutInput.text.length)
end = annotation.end, val safeEnd = min(annotation.end, layoutInput.text.length)
minimumTouchWidthPx = minimumTouchWidthPx, if (safeStart >= safeEnd) return@flatMapIndexed emptyList()
minimumTouchHeightPx = minimumTouchHeightPx,
horizontalPaddingPx = horizontalPaddingPx, val startLine = getLineForOffset(safeStart)
verticalPaddingPx = verticalPaddingPx, val endLine = getLineForOffset(safeEnd - 1)
).any { it.contains(position) }
(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( return linkBounds.map { bounds ->
start: Int, val nextLinkLeft = linkBounds
end: Int, .asSequence()
minimumTouchWidthPx: Float, .filter {
minimumTouchHeightPx: Float, it.line == bounds.line &&
horizontalPaddingPx: Float, it.annotationIndex != bounds.annotationIndex &&
verticalPaddingPx: Float, it.rect.left >= bounds.rect.right
): List<Rect> { }
if (start >= end) return emptyList() .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<Int, Rect>() ReaderLinkTouchArea(
val safeEnd = min(end, layoutInput.text.length) target = bounds.target,
for (offset in start until safeEnd) { rect = Rect(
val line = getLineForOffset(offset) left = min(bounds.rect.left + trimLeftPx, bounds.rect.right),
val bounds = getBoundingBox(offset) top = max(0f, centerY - touchHeight / 2f),
boundsByLine[line] = boundsByLine[line]?.union(bounds) ?: bounds right = right,
} bottom = centerY + touchHeight / 2f,
),
return boundsByLine.values.map { bounds ->
bounds.withCenteredTouchPadding(
minimumWidth = minimumTouchWidthPx,
minimumHeight = minimumTouchHeightPx,
horizontalPadding = horizontalPaddingPx,
verticalPadding = verticalPaddingPx,
) )
} }
} }
@ -1260,23 +1320,6 @@ private fun Rect.union(other: Rect): Rect =
bottom = max(bottom, other.bottom), 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 = private fun AnnotatedString.hasReaderLinks(): Boolean =
getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty() getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty()
@ -1295,6 +1338,9 @@ private fun Fb2Text.toAnnotatedString(
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null, fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null, fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
color = if (isLink) LinkTextColor else Color.Unspecified, 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 { textDecoration = when {
isLink && Fb2TextStyle.Strikethrough in span.styles -> isLink && Fb2TextStyle.Strikethrough in span.styles ->
TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
@ -1308,12 +1354,11 @@ private fun Fb2Text.toAnnotatedString(
else -> null else -> null
}, },
) )
span.href?.takeIf { it.isNotBlank() }?.let {
pushStringAnnotation(ReaderLinkAnnotationTag, it)
}
if (isLink) { if (isLink) {
withStyle(spanStyle) { withAnnotation(ReaderLinkAnnotationTag, span.href!!) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) withStyle(spanStyle) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
}
} }
} else { } else {
appendWithDetectedWebLinks( appendWithDetectedWebLinks(
@ -1326,7 +1371,6 @@ private fun Fb2Text.toAnnotatedString(
hyphenation = hyphenation, hyphenation = hyphenation,
) )
} }
if (isLink) pop()
plainOffset += span.text.length plainOffset += span.text.length
} }
} }
@ -1356,18 +1400,19 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
} }
val url = text.substring(range) val url = text.substring(range)
pushStringAnnotation(ReaderLinkAnnotationTag, url) val linkStyle = spanStyle.readerLinkStyle()
withStyle(spanStyle.readerLinkStyle()) { withAnnotation(ReaderLinkAnnotationTag, url) {
appendWithHighlight( withStyle(linkStyle) {
text = url, appendWithHighlight(
plainOffset = plainOffset + range.start, text = url,
highlightedRange = highlightedRange, plainOffset = plainOffset + range.start,
highlightColor = highlightColor, highlightedRange = highlightedRange,
language = null, highlightColor = highlightColor,
hyphenation = hyphenation, language = null,
) hyphenation = hyphenation,
)
}
} }
pop()
cursor = range.endInclusive + 1 cursor = range.endInclusive + 1
} }
@ -1388,6 +1433,9 @@ private fun AnnotatedString.Builder.appendWithDetectedWebLinks(
private fun SpanStyle.readerLinkStyle(): SpanStyle = private fun SpanStyle.readerLinkStyle(): SpanStyle =
copy( copy(
color = LinkTextColor, 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 = when (textDecoration) {
TextDecoration.LineThrough -> TextDecoration.LineThrough ->
TextDecoration.combine(listOf(TextDecoration.Underline, 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 EllipsisPauseAfterMillis = 350L
private const val CombiningAcuteAccent = '\u0301' private const val CombiningAcuteAccent = '\u0301'
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
private const val ReaderLinkAnnotationTag = "fb2-link"
private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE) private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’') private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’')
private val LinkTextColor = Color(0xFF0B57D0) private val LinkTextColor = Color(0xFF0B57D0)
private val ReaderLinkMinimumTouchWidth = 44.dp // Link hit-area debugging colors. Kept here so the temporary backgrounds above can be
private val ReaderLinkMinimumTouchHeight = 40.dp // re-enabled quickly when continuing touch overlay investigation.
private val ReaderLinkHorizontalTouchPadding = 10.dp // private val ReaderLinkDebugBackground = Color(0x33FFB300)
private val ReaderLinkVerticalTouchPadding = 6.dp // 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( private val ReadAloudHardcodedTextReplacements = listOf(
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true), ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),

View File

@ -6,9 +6,11 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement 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.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -66,6 +69,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -87,7 +91,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -110,6 +116,11 @@ private data class ReaderBookPreparation(
val contentPlan: ReaderContentPlan, val contentPlan: ReaderContentPlan,
) )
private enum class ReaderColorPickerTarget {
BACKGROUND,
TEXT,
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
internal fun BookView( internal fun BookView(
@ -137,6 +148,8 @@ internal fun BookView(
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerSettingsPanelVisible 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 fullScreenReader by remember { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) } var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) } var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
@ -147,6 +160,7 @@ internal fun BookView(
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) } var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val useDarkReaderTheme = LocalReaderThemeIsDark.current
LaunchedEffect(book) { LaunchedEffect(book) {
preparation = withContext(Dispatchers.Default) { preparation = withContext(Dispatchers.Default) {
@ -172,6 +186,12 @@ internal fun BookView(
val readingProgress by remember(listState) { val readingProgress by remember(listState) {
derivedStateOf { listState.readerProgress() } 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 val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) } ?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudState.active } ?.takeIf { readAloudState.active }
@ -407,6 +427,9 @@ internal fun BookView(
onReaderSettings = { onReaderSettings = {
readerSettingsPanelVisible = true readerSettingsPanelVisible = true
}, },
onReaderExtraSettings = {
readerExtraSettingsVisible = true
},
showReadAloudAction = showReadAloudAction, showReadAloudAction = showReadAloudAction,
onReadAloud = { onReadAloud = {
val position = currentReadingPosition() val position = currentReadingPosition()
@ -449,7 +472,7 @@ internal fun BookView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it) .padding(it)
.background(readerBackground()), .background(readerBackgroundColor),
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
ContinuousBookReader( ContinuousBookReader(
@ -460,6 +483,8 @@ internal fun BookView(
contentPlan = contentPlan, contentPlan = contentPlan,
highlightedSentence = highlightedSentence, highlightedSentence = highlightedSentence,
readerFontSettings = readerFontSettings, readerFontSettings = readerFontSettings,
readerBackgroundColor = readerBackgroundColor,
readerTextColor = readerTextColor,
onReaderFontZoom = { steps -> onReaderFontZoom = { steps ->
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) } updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
}, },
@ -488,8 +513,35 @@ internal fun BookView(
onFontWeightIncrease = { onFontWeightIncrease = {
updateReaderFontSettings { settings -> settings.adjustFontWeight(1) } updateReaderFontSettings { settings -> settings.adjustFontWeight(1) }
}, },
onReset = { onBackgroundColorChange = {
updateReaderFontSettings { defaultReaderFontSettings() } 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 }, onClose = { readerSettingsPanelVisible = false },
) )
@ -517,6 +569,8 @@ internal fun BookView(
progress = readingProgress, progress = readingProgress,
fullScreen = fullScreenReader, fullScreen = fullScreenReader,
fullScreenTitle = fullScreenProgressTitle, fullScreenTitle = fullScreenProgressTitle,
backgroundColor = readerBackgroundColor,
textColor = readerTextColor,
onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) }, 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 -> selectedNoteId?.let { noteId ->
Fb2NoteReferenceDialog( Fb2NoteReferenceDialog(
book = book, book = book,
@ -577,12 +675,14 @@ private fun ReaderProgressPane(
progress: Float, progress: Float,
fullScreen: Boolean, fullScreen: Boolean,
fullScreenTitle: String, fullScreenTitle: String,
backgroundColor: Color,
textColor: Color,
onFullScreenToggle: () -> Unit, onFullScreenToggle: () -> Unit,
) { ) {
val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt() val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt()
val trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f) val trackColor = textColor.copy(alpha = 0.34f)
val readColor = MaterialTheme.colorScheme.primary val readColor = textColor.copy(alpha = 0.84f)
val fullScreenFillColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) val fullScreenFillColor = fullScreenProgressFillColor(textColor)
val contentDescription = strings.progressLabel(progress.toDouble()) val contentDescription = strings.progressLabel(progress.toDouble())
val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp) val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp)
val progressRowHeight by animateDpAsState(24.dp) val progressRowHeight by animateDpAsState(24.dp)
@ -593,7 +693,7 @@ private fun ReaderProgressPane(
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface, color = backgroundColor,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onFullScreenToggle), .clickable(onClick = onFullScreenToggle),
@ -628,7 +728,7 @@ private fun ReaderProgressPane(
Text( Text(
text = fullScreenTitle, text = fullScreenTitle,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -640,7 +740,7 @@ private fun ReaderProgressPane(
text = "$percent%", text = "$percent%",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface, color = textColor,
maxLines = 1, maxLines = 1,
) )
} }
@ -680,7 +780,7 @@ private fun ReaderProgressPane(
text = "$percent%", text = "$percent%",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface, color = textColor,
maxLines = 1, 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 { private fun Fb2Book.fullScreenProgressTitle(): String {
val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor } val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }
return "$author. $title" return "$title. $author."
} }
private fun LazyListState.readerProgress(): Float { private fun LazyListState.readerProgress(): Float {
@ -739,6 +851,7 @@ private fun CompactReaderTopBar(
showReadAloudAction: Boolean, showReadAloudAction: Boolean,
onReadAloud: () -> Unit, onReadAloud: () -> Unit,
onReaderSettings: () -> Unit, onReaderSettings: () -> Unit,
onReaderExtraSettings: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
@ -795,6 +908,16 @@ private fun CompactReaderTopBar(
onReaderSettings() onReaderSettings()
}, },
) )
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
text = { Text(strings.extraReaderSettings) },
onClick = {
menuOpen = false
onReaderExtraSettings()
},
)
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Checkbox(checked = fullScreen, onCheckedChange = null) Checkbox(checked = fullScreen, onCheckedChange = null)
@ -1062,9 +1185,16 @@ private fun ReaderFontSettingsPanel(
onLineHeightIncrease: () -> Unit, onLineHeightIncrease: () -> Unit,
onFontWeightDecrease: () -> Unit, onFontWeightDecrease: () -> Unit,
onFontWeightIncrease: () -> Unit, onFontWeightIncrease: () -> Unit,
onReset: () -> Unit, onBackgroundColorChange: () -> Unit,
onBackgroundColorPick: () -> Unit,
onTextColorChange: () -> Unit,
onTextColorPick: () -> Unit,
onClose: () -> 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( Surface(
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 4.dp, shadowElevation = 4.dp,
@ -1074,34 +1204,48 @@ private fun ReaderFontSettingsPanel(
Column { Column {
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.horizontalScroll(rememberScrollState()) .padding(horizontal = 2.dp),
.padding(bottom = 0.dp), horizontalArrangement = Arrangement.SpaceEvenly,
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Text("A ${settings.fontSizeSp.roundToInt()}", style = MaterialTheme.typography.labelMedium) // Text("A ${settings.fontSizeSp.roundToInt()}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontSizeDecrease) { ReaderSettingsButton(
contentDescription = strings.readerFontSizeDecrease,
onClick = onFontSizeDecrease,
) {
ReaderLetterChangeIcon( ReaderLetterChangeIcon(
letter = "A", letter = "A",
increase = false, increase = false,
contentDescription = strings.readerFontSizeDecrease, contentDescription = strings.readerFontSizeDecrease,
) )
} }
IconButton(onClick = onFontSizeIncrease) { ReaderSettingsButton(
contentDescription = strings.readerFontSizeIncrease,
onClick = onFontSizeIncrease,
) {
ReaderLetterChangeIcon( ReaderLetterChangeIcon(
letter = "A", letter = "A",
increase = true, increase = true,
contentDescription = strings.readerFontSizeIncrease, contentDescription = strings.readerFontSizeIncrease,
) )
} }
IconButton(onClick = onLineHeightDecrease) { ReaderSettingsButton(
Icon(Icons.Filled.UnfoldLess, contentDescription = strings.readerLineHeightDecrease) contentDescription = strings.readerLineHeightDecrease,
onClick = onLineHeightDecrease,
) {
Icon(Icons.Filled.UnfoldLess, contentDescription = null)
} }
IconButton(onClick = onLineHeightIncrease) { ReaderSettingsButton(
Icon(Icons.Filled.UnfoldMore, contentDescription = strings.readerLineHeightIncrease) contentDescription = strings.readerLineHeightIncrease,
onClick = onLineHeightIncrease,
) {
Icon(Icons.Filled.UnfoldMore, contentDescription = null)
} }
// Text("W ${settings.fontWeight}", style = MaterialTheme.typography.labelMedium) // Text("W ${settings.fontWeight}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontWeightDecrease) { ReaderSettingsButton(
contentDescription = strings.readerFontWeightDecrease,
onClick = onFontWeightDecrease,
) {
ReaderLetterChangeIcon( ReaderLetterChangeIcon(
letter = "B", letter = "B",
increase = false, increase = false,
@ -1109,7 +1253,10 @@ private fun ReaderFontSettingsPanel(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
IconButton(onClick = onFontWeightIncrease) { ReaderSettingsButton(
contentDescription = strings.readerFontWeightIncrease,
onClick = onFontWeightIncrease,
) {
ReaderLetterChangeIcon( ReaderLetterChangeIcon(
letter = "B", letter = "B",
increase = true, increase = true,
@ -1117,11 +1264,31 @@ private fun ReaderFontSettingsPanel(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
IconButton(onClick = onReset) { ColorSettingButton(
Icon(Icons.Filled.RestartAlt, contentDescription = strings.resetReaderFontSettings) contentDescription = strings.readerBackgroundColor,
onClick = onBackgroundColorChange,
onLongClick = onBackgroundColorPick,
) {
ColorSwatchIcon(color = backgroundSwatch)
} }
IconButton(onClick = onClose) { ColorSettingButton(
Icon(Icons.Filled.Close, contentDescription = strings.closeReaderSettings) 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) { // Row(Modifier.fillMaxWidth().padding(bottom = 2.dp, top = 0.dp), horizontalArrangement = Arrangement.Center) {
@ -1142,17 +1309,320 @@ private fun ReaderLetterChangeIcon(
increase: Boolean, increase: Boolean,
contentDescription: String, contentDescription: String,
fontWeight: FontWeight = FontWeight.SemiBold, fontWeight: FontWeight = FontWeight.SemiBold,
color: Color = Color.Unspecified,
) { ) {
Text( Text(
text = letter + if (increase) "" else "", text = letter + if (increase) "" else "",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = fontWeight, fontWeight = fontWeight,
color = color,
modifier = Modifier.semantics { modifier = Modifier.semantics {
this.contentDescription = contentDescription 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 @Composable
private fun ReadAloudPanel( private fun ReadAloudPanel(
playing: Boolean, playing: Boolean,

View File

@ -141,6 +141,14 @@ internal fun themedTopAppBarColors(): TopAppBarColors =
@Composable @Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background) 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 @Composable
internal fun readerImageBackgroundColor(): Color { internal fun readerImageBackgroundColor(): Color {
val surface = MaterialTheme.colorScheme.surface val surface = MaterialTheme.colorScheme.surface

View File

@ -1,7 +1,10 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
internal val LocalReaderThemeIsDark = compositionLocalOf { false }
internal fun ThemeMode.next(): ThemeMode = internal fun ThemeMode.next(): ThemeMode =
when (this) { when (this) {
ThemeMode.SYSTEM -> ThemeMode.LIGHT ThemeMode.SYSTEM -> ThemeMode.LIGHT

View File

@ -475,9 +475,18 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) { internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag) val saved = db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() } when {
?: LibraryFilter.MyLibrary 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
}
}
} }
} }

View File

@ -95,9 +95,17 @@ actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
internal actual suspend fun loadLibraryFilter(): LibraryFilter = internal actual suspend fun loadLibraryFilter(): LibraryFilter =
window.localStorage.getItem(LibraryFilterStorageKey) when (val saved = window.localStorage.getItem(LibraryFilterStorageKey)) {
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() } null -> {
?: LibraryFilter.MyLibrary 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) { internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) {
window.localStorage.setItem(LibraryFilterStorageKey, filter.name) window.localStorage.setItem(LibraryFilterStorageKey, filter.name)
@ -136,6 +144,10 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null 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 AppLocaleStorageKey = "toread.appLocaleTag"
private const val LibraryFilterStorageKey = "toread.libraryFilter" private const val LibraryFilterStorageKey = "toread.libraryFilter"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings" private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"