Epub reader themes (#97)
* Implemented a custom theme engine for the EPUB reader, allowing users to switch between built-in themes (System, Light, Dark, Sepia, Slate, and OLED). Key changes include: - Added `ReaderThemePanel` and theme selection logic to `EpubReaderScreen`. - Introduced a theme persistence layer using SharedPreferences. - Updated `ChapterWebView` and `epub_reader.js` to dynamically apply background and text colors via JavaScript, including improved contrast adjustment for inline styles. - Enhanced `CssParser` and `ContentStyler` in the paginated reader to adapt CSS colors based on the active theme's background and text luminance. - Integrated theme-specific background and text colors into `PaginatedReaderScreen` and its layout components. - Added a theme palette icon to the reader controls. * Added custom theme builder and texture support to EPUB reader * Refactored color picker(PDF) into shared components and enhanced EPUB theme editor * Updated theme customization UI and improved WebView theme application efficiency * Updated page flip animation to use paper color with a theme-based tint * Improved state restoration during paginated reader reconfiguration
This commit is contained in:
parent
eaf0d4af00
commit
c9dc3ce8c2
16 changed files with 1817 additions and 847 deletions
|
|
@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.Voice
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -56,6 +60,7 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
|
@ -103,9 +108,16 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
|
|
@ -116,13 +128,17 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
|
@ -156,6 +172,8 @@ import java.io.File
|
|||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.graphics.toColorInt
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||
const val summarizeEndpoint = "/summarize"
|
||||
|
|
@ -1717,4 +1735,283 @@ fun DeviceVoiceSettingsSheet(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
|
||||
)
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width.toFloat() - (paddingPx * 2)
|
||||
val activeHeight = size.height.toFloat() - (paddingPx * 2)
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
|
||||
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(h, s)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(touchPadding)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(rainbowColors)
|
||||
)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width - (paddingPx * 2)
|
||||
val activeHeight = size.height - (paddingPx * 2)
|
||||
|
||||
val x = paddingPx + (hue / 360f) * activeWidth
|
||||
val y = paddingPx + saturation * activeHeight
|
||||
|
||||
val pointerRadius = 10.dp.toPx()
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
radius = pointerRadius + 1.dp.toPx(),
|
||||
center = Offset(x, y + 1.dp.toPx())
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val baseColor = remember(hue, saturation) {
|
||||
Color.hsv(hue, saturation, 1f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
fun update(offset: Offset) {
|
||||
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(v)
|
||||
}
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(Color.Black, baseColor)
|
||||
)
|
||||
)
|
||||
|
||||
val x = value * size.width
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 8.dp.toPx(),
|
||||
center = Offset(x, size.height / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val intValue = (value * 255).roundToInt()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.Gray,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
RgbInput(value = intValue, onValueChange = onValueChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RgbInput(
|
||||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
text = value.toString()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
val intVal = newText.toIntOrNull()
|
||||
if (intVal != null) {
|
||||
onValueChange(intVal.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = remember(color) {
|
||||
String.format("%06X", (0xFFFFFF and color.toArgb()))
|
||||
}
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
|
||||
LaunchedEffect(color) {
|
||||
val currentParsed = try {
|
||||
Color(("#$text").toColorInt())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (currentParsed?.toArgb() != color.toArgb()) {
|
||||
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
color = Color.Gray,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
if (uppercased.length == 6) {
|
||||
try {
|
||||
val parsedColorInt = "#$uppercased".toColorInt()
|
||||
val newColor = Color(parsedColorInt)
|
||||
onHexChanged(newColor)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.width(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
|
||||
drawRect(
|
||||
color = oldColor.copy(alpha = 1f),
|
||||
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
|
||||
)
|
||||
drawRect(
|
||||
color = newColor.copy(alpha = 1f),
|
||||
topLeft = Offset(size.width / 2, 0f),
|
||||
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -152,6 +152,7 @@ fun EpubReaderTopBar(
|
|||
onOpenTtsSettings: () -> Unit,
|
||||
onOpenDeviceVoiceSettings: () -> Unit,
|
||||
onOpenDictionarySettings: () -> Unit,
|
||||
onOpenThemeSettings: () -> Unit,
|
||||
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
onToggleReflow: (() -> Unit)? = null,
|
||||
|
|
@ -209,6 +210,13 @@ fun EpubReaderTopBar(
|
|||
contentDescription = "Dictionary Settings"
|
||||
)
|
||||
}
|
||||
TooltipIconButton(
|
||||
text = "Theme",
|
||||
description = "Theme Settings",
|
||||
onClick = onOpenThemeSettings
|
||||
) {
|
||||
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = "Theme Settings")
|
||||
}
|
||||
Box {
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ import android.annotation.SuppressLint
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.aryan.reader.SpectrumBox
|
||||
import com.aryan.reader.BrightnessSlider
|
||||
import com.aryan.reader.ColorComparePill
|
||||
import com.aryan.reader.HexInput
|
||||
import com.aryan.reader.RgbInputColumn
|
||||
import android.graphics.Bitmap
|
||||
import android.media.AudioManager
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.webkit.WebView
|
||||
|
|
@ -51,14 +53,20 @@ import androidx.compose.animation.fadeOut
|
|||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
|
|
@ -71,26 +79,35 @@ import androidx.compose.foundation.layout.offset
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsEndWidth
|
||||
import androidx.compose.foundation.layout.windowInsetsStartWidth
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
|
|
@ -116,19 +133,32 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.view.WindowCompat
|
||||
|
|
@ -143,6 +173,7 @@ import com.aryan.reader.BuildConfig
|
|||
import com.aryan.reader.CustomTopBanner
|
||||
import com.aryan.reader.DeviceVoiceSettingsSheet
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.RenderMode
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SummarizationResult
|
||||
|
|
@ -335,6 +366,94 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
|
|||
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
|
||||
}
|
||||
|
||||
enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) {
|
||||
PAPER("paper", R.drawable.texture_paper, "Paper"),
|
||||
CANVAS("canvas", R.drawable.texture_canvas, "Canvas"),
|
||||
EINK("eink", R.drawable.texture_eink, "E-Ink"),
|
||||
SLATE("slate", R.drawable.texture_slate, "Slate")
|
||||
}
|
||||
|
||||
data class ReaderTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val backgroundColor: Color,
|
||||
val textColor: Color,
|
||||
val isDark: Boolean,
|
||||
val textureId: String? = null,
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
|
||||
val BuiltInThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
)
|
||||
|
||||
private const val PREF_READER_THEME = "reader_theme_id"
|
||||
private const val PREF_CUSTOM_THEMES = "custom_themes_json"
|
||||
|
||||
private fun saveReaderThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PREF_READER_THEME, themeId) }
|
||||
}
|
||||
|
||||
private fun loadReaderThemeId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
|
||||
}
|
||||
|
||||
private fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
val jsonArray = JSONArray()
|
||||
themes.filter { it.isCustom }.forEach { theme ->
|
||||
val obj = JSONObject().apply {
|
||||
put("id", theme.id)
|
||||
put("name", theme.name)
|
||||
put("bgColor", theme.backgroundColor.toArgb())
|
||||
put("textColor", theme.textColor.toArgb())
|
||||
put("isDark", theme.isDark)
|
||||
theme.textureId?.let { put("textureId", it) }
|
||||
}
|
||||
jsonArray.put(obj)
|
||||
}
|
||||
prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) }
|
||||
}
|
||||
|
||||
private fun loadCustomThemes(context: Context): List<ReaderTheme> {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]"
|
||||
val themes = mutableListOf<ReaderTheme>()
|
||||
try {
|
||||
val jsonArray = org.json.JSONArray(jsonString)
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
themes.add(
|
||||
ReaderTheme(
|
||||
id = obj.getString("id"),
|
||||
name = obj.getString("name"),
|
||||
backgroundColor = Color(obj.getInt("bgColor")),
|
||||
textColor = Color(obj.getInt("textColor")),
|
||||
isDark = obj.getBoolean("isDark"),
|
||||
textureId = if (obj.has("textureId")) obj.getString("textureId") else null,
|
||||
isCustom = true
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse custom themes")
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
private fun calculateContrastRatio(color1: Color, color2: Color): Float {
|
||||
val l1 = max(color1.luminance(), color2.luminance())
|
||||
val l2 = min(color1.luminance(), color2.luminance())
|
||||
return (l1 + 0.05f) / (l2 + 0.05f)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
fun EpubReaderScreen(
|
||||
|
|
@ -957,9 +1076,33 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showThemePanel by remember { mutableStateOf(false) }
|
||||
|
||||
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
|
||||
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
||||
|
||||
val activeTheme = remember(currentThemeId, customThemes) {
|
||||
BuiltInThemes.find { it.id == currentThemeId }
|
||||
?: customThemes.find { it.id == currentThemeId }
|
||||
?: BuiltInThemes[0]
|
||||
}
|
||||
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
val isDarkTheme = if (activeTheme.id == "system") systemIsDark else activeTheme.isDark
|
||||
|
||||
val effectiveBg = remember(activeTheme, systemIsDark) {
|
||||
if (activeTheme.id == "system") {
|
||||
if (systemIsDark) Color(0xFF121212) else Color(0xFFFFFFFF)
|
||||
} else activeTheme.backgroundColor
|
||||
}
|
||||
val effectiveText = remember(activeTheme, systemIsDark) {
|
||||
if (activeTheme.id == "system") {
|
||||
if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000)
|
||||
} else activeTheme.textColor
|
||||
}
|
||||
val activeTextureId = activeTheme.textureId
|
||||
|
||||
val currentChapterInPaginatedMode by remember {
|
||||
derivedStateOf {
|
||||
|
|
@ -1892,6 +2035,7 @@ fun EpubReaderHost(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(effectiveBg)
|
||||
.padding(scaffoldPaddingValues)
|
||||
.focusRequester(containerFocusRequester)
|
||||
.focusable()
|
||||
|
|
@ -2080,6 +2224,8 @@ fun EpubReaderHost(
|
|||
key = chapterKeyForWebView,
|
||||
chapterTitle = chapterToRender.title,
|
||||
isDarkTheme = isDarkTheme,
|
||||
effectiveBg = effectiveBg,
|
||||
effectiveText = effectiveText,
|
||||
initialScrollTarget = initialScrollTargetForChapter,
|
||||
initialPageScrollY = currentScrollYPosition,
|
||||
initialCfi = cfiToLoad,
|
||||
|
|
@ -2276,6 +2422,7 @@ fun EpubReaderHost(
|
|||
currentFontFamily = currentFontFamily,
|
||||
customFontPath = currentCustomFontPath,
|
||||
currentTextAlign = currentTextAlign,
|
||||
activeTextureId = activeTextureId,
|
||||
onHighlightClicked = {
|
||||
lastHighlightClickTime = System.currentTimeMillis()
|
||||
showBars = false
|
||||
|
|
@ -2622,6 +2769,8 @@ fun EpubReaderHost(
|
|||
PaginatedReaderScreen(
|
||||
book = epubBook,
|
||||
isDarkTheme = isDarkTheme,
|
||||
effectiveBg = effectiveBg,
|
||||
effectiveText = effectiveText,
|
||||
pagerState = paginatedPagerState,
|
||||
searchQuery = searchState.searchQuery,
|
||||
fontSizeMultiplier = currentFontSizeEm,
|
||||
|
|
@ -2636,6 +2785,7 @@ fun EpubReaderHost(
|
|||
cfi = ttsState.sourceCfi ?: "",
|
||||
offset = ttsState.startOffsetInSource
|
||||
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
|
||||
activeTextureId = activeTextureId,
|
||||
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
|
||||
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
|
||||
onPaginatorReady = { newPaginator ->
|
||||
|
|
@ -3305,6 +3455,7 @@ fun EpubReaderHost(
|
|||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = true },
|
||||
onToggleReflow = if (onToggleReflow != null) {
|
||||
{
|
||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||
|
|
@ -3876,5 +4027,516 @@ fun EpubReaderHost(
|
|||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showThemePanel) {
|
||||
ReaderThemePanel(
|
||||
isVisible = true,
|
||||
currentThemeId = currentThemeId,
|
||||
onThemeSelected = {
|
||||
currentThemeId = it
|
||||
saveReaderThemeId(context, it)
|
||||
showThemePanel = false
|
||||
},
|
||||
onDismiss = { showThemePanel = false },
|
||||
customThemes = customThemes,
|
||||
onCustomThemesUpdated = { customThemes = it; saveCustomThemes(context, it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderThemePanel(
|
||||
isVisible: Boolean,
|
||||
currentThemeId: String,
|
||||
customThemes: List<ReaderTheme>,
|
||||
onThemeSelected: (String) -> Unit,
|
||||
onCustomThemesUpdated: (List<ReaderTheme>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (!isVisible) return
|
||||
var showBuilder by remember { mutableStateOf(false) }
|
||||
var editingTheme by remember { mutableStateOf<ReaderTheme?>(null) }
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
AnimatedContent(targetState = showBuilder, label = "ThemePanelTransition") { isBuilding ->
|
||||
if (isBuilding) {
|
||||
ThemeBuilderView(
|
||||
initialTheme = editingTheme,
|
||||
onSave = { newTheme ->
|
||||
val updatedList = if (editingTheme != null) {
|
||||
customThemes.map { if (it.id == newTheme.id) newTheme else it }
|
||||
} else {
|
||||
customThemes + newTheme
|
||||
}
|
||||
onCustomThemesUpdated(updatedList)
|
||||
onThemeSelected(newTheme.id)
|
||||
showBuilder = false
|
||||
editingTheme = null
|
||||
},
|
||||
onCancel = {
|
||||
showBuilder = false
|
||||
editingTheme = null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.65f)
|
||||
.padding(16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Reading Themes",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text("Presets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ThemeGrid(themes = BuiltInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (customThemes.isEmpty()) {
|
||||
Text("No custom themes yet. Tap '+' to create one.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
ThemeGrid(
|
||||
themes = customThemes,
|
||||
currentThemeId = currentThemeId,
|
||||
onThemeSelected = onThemeSelected,
|
||||
onEdit = { editingTheme = it; showBuilder = true },
|
||||
onDelete = { themeToDelete ->
|
||||
val updated = customThemes.filter { it.id != themeToDelete.id }
|
||||
onCustomThemesUpdated(updated)
|
||||
if (currentThemeId == themeToDelete.id) onThemeSelected("system")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeGrid(
|
||||
themes: List<ReaderTheme>,
|
||||
currentThemeId: String,
|
||||
onThemeSelected: (String) -> Unit,
|
||||
onEdit: ((ReaderTheme) -> Unit)? = null,
|
||||
onDelete: ((ReaderTheme) -> Unit)? = null
|
||||
) {
|
||||
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
|
||||
columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(themes.size) { index ->
|
||||
val theme = themes[index]
|
||||
val isSelected = currentThemeId == theme.id
|
||||
val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor
|
||||
val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor
|
||||
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.background(bgColor, CircleShape)
|
||||
.border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape)
|
||||
.clickable { onThemeSelected(theme.id) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
if (theme.isCustom && onEdit != null && onDelete != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeBuilderView(
|
||||
initialTheme: ReaderTheme?,
|
||||
onSave: (ReaderTheme) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") }
|
||||
var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
|
||||
var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
|
||||
var textureId by remember { mutableStateOf(initialTheme?.textureId) }
|
||||
|
||||
var editingColorType by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val contrast = calculateContrastRatio(bgColor, txtColor)
|
||||
val isDark = bgColor.luminance() < 0.5f
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.65f).padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
Text(if (initialTheme == null) "New Theme" else "Edit Theme", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||
TextButton(onClick = {
|
||||
onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true))
|
||||
}) { Text("Save") }
|
||||
}
|
||||
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme Name") },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Live Preview Card
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(120.dp).padding(vertical = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = bgColor,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
// Draw Texture if selected (kept logic for future)
|
||||
val context = LocalContext.current
|
||||
Box(modifier = Modifier.fillMaxSize().run {
|
||||
val texRes = ReaderTexture.entries.find { it.id == textureId }?.resId
|
||||
if (texRes != null) {
|
||||
val bmp = ImageBitmap.imageResource(context.resources, texRes)
|
||||
this.drawBehind {
|
||||
drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.Multiply, alpha = 0.5f)
|
||||
}
|
||||
} else this
|
||||
}) {
|
||||
Column(Modifier.padding(16.dp).fillMaxWidth()) {
|
||||
Text(
|
||||
text = "So many books, so little time.",
|
||||
color = txtColor,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "- Frank Zappa",
|
||||
color = txtColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animated Contrast Warning
|
||||
AnimatedVisibility(visible = contrast < 4.5f) {
|
||||
Text(
|
||||
"⚠️ Low contrast! This might cause eye strain.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Sleek Color Swatches
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ColorSwatchItem(
|
||||
label = "Page Color",
|
||||
color = bgColor,
|
||||
onClick = { editingColorType = "bg" },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ColorSwatchItem(
|
||||
label = "Text Color",
|
||||
color = txtColor,
|
||||
onClick = { editingColorType = "text" },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Texture UI hidden for now
|
||||
/*
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Texture", style = MaterialTheme.typography.labelMedium)
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
TextureOption("None", null, textureId == null) { textureId = null }
|
||||
ReaderTexture.entries.forEach { tex ->
|
||||
TextureOption(tex.displayName, tex.resId, textureId == tex.id) { textureId = tex.id }
|
||||
}
|
||||
}
|
||||
*/
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
editingColorType?.let { type ->
|
||||
ThemeColorPickerDialog(
|
||||
initialColor = if (type == "bg") bgColor else txtColor,
|
||||
title = if (type == "bg") "Page Color" else "Text Color",
|
||||
bgColor = bgColor,
|
||||
textColor = txtColor,
|
||||
editingColorType = type,
|
||||
onDismiss = { editingColorType = null },
|
||||
onColorChanged = { newColor ->
|
||||
if (type == "bg") bgColor = newColor else txtColor = newColor
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ColorSwatchItem(label: String, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = color,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeColorPickerDialog(
|
||||
initialColor: Color,
|
||||
title: String,
|
||||
bgColor: Color,
|
||||
textColor: Color,
|
||||
editingColorType: String,
|
||||
onDismiss: () -> Unit,
|
||||
onColorChanged: (Color) -> Unit
|
||||
) {
|
||||
val initialHsv = remember(initialColor) {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
|
||||
hsv
|
||||
}
|
||||
|
||||
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
|
||||
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
|
||||
var value by remember { mutableFloatStateOf(initialHsv[2]) }
|
||||
|
||||
val currentColor by remember {
|
||||
derivedStateOf {
|
||||
val hsv = floatArrayOf(hue, saturation, value)
|
||||
val argb = android.graphics.Color.HSVToColor(255, hsv)
|
||||
Color(argb)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentColor) {
|
||||
onColorChanged(currentColor)
|
||||
}
|
||||
|
||||
fun updateFromColor(color: Color) {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
|
||||
hue = hsv[0]
|
||||
saturation = hsv[1]
|
||||
value = hsv[2]
|
||||
}
|
||||
|
||||
androidx.compose.ui.window.Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val liveBgColor = if (editingColorType == "bg") currentColor else bgColor
|
||||
val liveTextColor = if (editingColorType == "text") currentColor else textColor
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = liveBgColor,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Live Preview",
|
||||
color = liveTextColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Reading is dreaming.",
|
||||
color = liveTextColor,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
SpectrumBox(
|
||||
hue = hue,
|
||||
saturation = saturation,
|
||||
currentColor = currentColor,
|
||||
onHueSatChanged = { h, s -> hue = h; saturation = s },
|
||||
modifier = Modifier.fillMaxWidth().height(220.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
BrightnessSlider(
|
||||
hue = hue,
|
||||
saturation = saturation,
|
||||
value = value,
|
||||
onValueChanged = { value = it },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ColorComparePill(
|
||||
oldColor = initialColor,
|
||||
newColor = currentColor,
|
||||
modifier = Modifier.width(64.dp).height(36.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(2.4f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
RgbInputColumn(
|
||||
label = "R", value = currentColor.red,
|
||||
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
RgbInputColumn(
|
||||
label = "G", value = currentColor.green,
|
||||
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
RgbInputColumn(
|
||||
label = "B", value = currentColor.blue,
|
||||
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) {
|
||||
Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run {
|
||||
if (resId != null) {
|
||||
val bmp = ImageBitmap.imageResource(LocalResources.current, resId)
|
||||
this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) }
|
||||
} else this.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
})
|
||||
Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f))
|
||||
Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f))
|
||||
Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,8 @@ class BookPaginator(
|
|||
private val density: Density,
|
||||
private val fontFamilyMap: Map<String, FontFamily>,
|
||||
private val isDarkTheme: Boolean,
|
||||
private val themeBackgroundColor: Color,
|
||||
private val themeTextColor: Color,
|
||||
private val bookId: String,
|
||||
private val initialChapterToPaginate: Int,
|
||||
private val bookCss: Map<String, String>,
|
||||
|
|
@ -127,7 +129,7 @@ class BookPaginator(
|
|||
override var generation by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
override val pageShiftRequest = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val pageShiftRequest = MutableSharedFlow<Int>(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val currentUserChapterIndex = MutableStateFlow(initialChapterToPaginate)
|
||||
|
||||
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
|
||||
|
|
@ -404,6 +406,8 @@ class BookPaginator(
|
|||
fontFamilyMap = fontFamilyMap,
|
||||
density = density,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
chapterAbsPath = chapter.absPath,
|
||||
extractionBasePath = extractionBasePath,
|
||||
userTextAlign = userTextAlign
|
||||
|
|
@ -475,10 +479,10 @@ class BookPaginator(
|
|||
val processedHtml = document.outerHtml()
|
||||
|
||||
var parsingCssRules = OptimizedCssRules()
|
||||
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false)
|
||||
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
|
||||
parsingCssRules = parsingCssRules.merge(uaResult.rules)
|
||||
bookCss.forEach { (path, content) ->
|
||||
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false)
|
||||
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
|
||||
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
||||
}
|
||||
|
||||
|
|
@ -950,8 +954,8 @@ class BookPaginator(
|
|||
return@launch
|
||||
}
|
||||
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
|
|
@ -983,8 +987,8 @@ class BookPaginator(
|
|||
val targetChapterIndex = result.locationInSource
|
||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
|
|
@ -1055,8 +1059,8 @@ class BookPaginator(
|
|||
val targetChapterIndex = locator.chapterIndex
|
||||
Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}")
|
||||
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
|
||||
if (chapterPages.isNullOrEmpty()) {
|
||||
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
|
|
@ -1068,6 +1072,8 @@ class BookPaginator(
|
|||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
if (block.blockIndex == locator.blockIndex) {
|
||||
Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex")
|
||||
|
||||
if (fallbackPageInChapter == -1) {
|
||||
fallbackPageInChapter = pageIndex
|
||||
}
|
||||
|
|
@ -1077,19 +1083,20 @@ class BookPaginator(
|
|||
val startOffsetOnPage = textBlock.startCharOffsetInSource
|
||||
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
|
||||
|
||||
if (locator.charOffset in startOffsetOnPage..<endOffsetOnPage) {
|
||||
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
|
||||
Timber.tag("ThemeReconfig").d("Offset Check: Target ${locator.charOffset} vs Range [$startOffsetOnPage, $endOffsetOnPage]. Inside: $isInside")
|
||||
|
||||
if (isInside) {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.i("Locator navigation SUCCEEDED with offset. Final page index: $finalPageIndex")
|
||||
return finalPageIndex
|
||||
}
|
||||
} else {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.i("Locator navigation SUCCEEDED for non-text block. Final page index: $finalPageIndex")
|
||||
return finalPageIndex
|
||||
return chapterStartPage + pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.tag("ThemeReconfig").e("Block Index NOT FOUND: Could not find block ${locator.blockIndex} in any page of Chapter $targetChapterIndex")
|
||||
|
||||
if (fallbackPageInChapter != -1) {
|
||||
val finalPageIndex = chapterStartPage + fallbackPageInChapter
|
||||
|
|
@ -1120,9 +1127,9 @@ class BookPaginator(
|
|||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
||||
|
||||
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
|
||||
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
|
||||
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ class ContentStyler(
|
|||
private val fontFamilyMap: Map<String, FontFamily>,
|
||||
private val density: Density,
|
||||
private val isDarkTheme: Boolean,
|
||||
private val themeBackgroundColor: Color,
|
||||
private val themeTextColor: Color,
|
||||
private val chapterAbsPath: String,
|
||||
private val extractionBasePath: String,
|
||||
private val userTextAlign: TextAlign?
|
||||
|
|
@ -59,7 +61,6 @@ class ContentStyler(
|
|||
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
|
||||
}
|
||||
|
||||
// ADD this function to group floating blocks, similar to the original parser
|
||||
private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
|
||||
if (blocks.isEmpty()) return emptyList()
|
||||
|
||||
|
|
@ -71,7 +72,6 @@ class ContentStyler(
|
|||
val floatDirection = (currentBlock as? ImageBlock)?.style?.float
|
||||
|
||||
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
|
||||
val floatedImage = currentBlock
|
||||
val paragraphsToWrap = mutableListOf<ParagraphBlock>()
|
||||
|
||||
while (processingQueue.isNotEmpty()) {
|
||||
|
|
@ -86,11 +86,11 @@ class ContentStyler(
|
|||
}
|
||||
}
|
||||
val wrappingBlock = WrappingContentBlock(
|
||||
floatedImage,
|
||||
currentBlock,
|
||||
paragraphsToWrap,
|
||||
elementId = floatedImage.elementId,
|
||||
cfi = floatedImage.cfi,
|
||||
blockIndex = floatedImage.blockIndex
|
||||
elementId = currentBlock.elementId,
|
||||
cfi = currentBlock.cfi,
|
||||
blockIndex = currentBlock.blockIndex
|
||||
)
|
||||
result.add(wrappingBlock)
|
||||
} else {
|
||||
|
|
@ -208,7 +208,7 @@ class ContentStyler(
|
|||
private fun applyThemeToStyle(style: CssStyle): CssStyle {
|
||||
val newSpanStyle = style.spanStyle.let { original ->
|
||||
val newColor = if (original.color.isSpecified) {
|
||||
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false)
|
||||
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
} else {
|
||||
original.color
|
||||
}
|
||||
|
|
@ -217,14 +217,14 @@ class ContentStyler(
|
|||
|
||||
val newBlockStyle = style.blockStyle.let { original ->
|
||||
val newBgColor = if (original.backgroundColor.isSpecified) {
|
||||
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true)
|
||||
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
|
||||
} else {
|
||||
original.backgroundColor
|
||||
}
|
||||
|
||||
fun themeBorder(b: BorderStyle?): BorderStyle? {
|
||||
if (b == null) return null
|
||||
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false)
|
||||
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
return b.copy(color = newColor)
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +474,7 @@ class ContentStyler(
|
|||
}
|
||||
|
||||
private fun toRoman(number: Int): String {
|
||||
if (number < 1 || number > 3999) return number.toString()
|
||||
if (number !in 1..3999) return number.toString()
|
||||
val values = listOf(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
|
||||
val symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
|
||||
val result = StringBuilder()
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@
|
|||
*/
|
||||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
|
|
@ -38,6 +35,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -67,24 +65,64 @@ object CssParser {
|
|||
"thick" to 5.dp
|
||||
)
|
||||
|
||||
internal fun adaptColorForTheme(color: Color, isDarkTheme: Boolean, isBackground: Boolean): Color {
|
||||
internal fun adaptColorForTheme(
|
||||
color: Color,
|
||||
isDarkTheme: Boolean,
|
||||
isBackground: Boolean,
|
||||
themeBackground: Color = Color.Unspecified,
|
||||
themeText: Color = Color.Unspecified
|
||||
): Color {
|
||||
if (!color.isSpecified) return color
|
||||
if (color.alpha < 0.9f) return color
|
||||
if (color.alpha < 0.9f && color != Color.Transparent) return color
|
||||
if (color == Color.Transparent) return color
|
||||
|
||||
val luminance = color.luminance()
|
||||
|
||||
return if (isDarkTheme) {
|
||||
if (isBackground) {
|
||||
if (luminance > 0.9) Color.Transparent else color
|
||||
if (!themeBackground.isSpecified || !themeText.isSpecified) {
|
||||
val luminance = color.luminance()
|
||||
return if (isDarkTheme) {
|
||||
if (isBackground) {
|
||||
if (luminance > 0.9) Color.Transparent else color
|
||||
} else {
|
||||
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
|
||||
}
|
||||
} else {
|
||||
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
|
||||
if (isBackground) {
|
||||
if (luminance < 0.1) Color.Transparent else color
|
||||
} else {
|
||||
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bgLuminance = themeBackground.luminance()
|
||||
val colorLuminance = color.luminance()
|
||||
|
||||
val l1 = maxOf(bgLuminance, colorLuminance)
|
||||
val l2 = minOf(bgLuminance, colorLuminance)
|
||||
val contrast = (l1 + 0.05f) / (l2 + 0.05f)
|
||||
|
||||
if (isBackground) {
|
||||
return if (isDarkTheme && colorLuminance > 0.5f) {
|
||||
Color.Transparent
|
||||
} else if (!isDarkTheme && colorLuminance < 0.2f) {
|
||||
Color.Transparent
|
||||
} else {
|
||||
color
|
||||
}
|
||||
} else {
|
||||
if (isBackground) {
|
||||
if (luminance < 0.1) Color.Transparent else color
|
||||
} else {
|
||||
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
|
||||
if (contrast >= 4.5f) {
|
||||
return color
|
||||
}
|
||||
|
||||
val hsl = FloatArray(3)
|
||||
androidx.core.graphics.ColorUtils.colorToHSL(color.toArgb(), hsl)
|
||||
|
||||
if (bgLuminance < 0.5f) {
|
||||
hsl[2] = hsl[2].coerceAtLeast(0.7f)
|
||||
} else {
|
||||
hsl[2] = hsl[2].coerceAtMost(0.3f)
|
||||
}
|
||||
|
||||
return Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +176,9 @@ object CssParser {
|
|||
baseFontSizeSp: Float,
|
||||
density: Float,
|
||||
constraints: Constraints,
|
||||
isDarkTheme: Boolean
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
): OptimizedCssParseResult {
|
||||
val byTag = mutableMapOf<String, MutableList<CssRule>>()
|
||||
val byClass = mutableMapOf<String, MutableList<CssRule>>()
|
||||
|
|
@ -192,11 +232,11 @@ object CssParser {
|
|||
val specificity = calculateSpecificity(originalSelector)
|
||||
val normalStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
|
||||
isDarkTheme
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
)
|
||||
val importantStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
|
||||
isDarkTheme
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
)
|
||||
|
||||
fun addRule(style: CssStyle, spec: Int) {
|
||||
|
|
@ -335,7 +375,9 @@ object CssParser {
|
|||
density: Float,
|
||||
constraints: Constraints,
|
||||
onlyImportant: Boolean,
|
||||
isDarkTheme: Boolean
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
): CssStyle {
|
||||
var spanStyle = SpanStyle()
|
||||
var paragraphStyle = ParagraphStyle()
|
||||
|
|
@ -427,7 +469,7 @@ object CssParser {
|
|||
styleStr: String?
|
||||
) {
|
||||
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
|
||||
val isExplicitWidth = widthStr != null
|
||||
|
||||
|
|
@ -482,7 +524,7 @@ object CssParser {
|
|||
}
|
||||
"color" -> {
|
||||
parseColor(value)?.let {
|
||||
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false))
|
||||
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor))
|
||||
}
|
||||
}
|
||||
"text-align" -> {
|
||||
|
|
@ -587,7 +629,7 @@ object CssParser {
|
|||
|
||||
"background-color" -> {
|
||||
val originalColor = parseColor(value) ?: Color.Unspecified
|
||||
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
|
||||
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
|
||||
}
|
||||
|
||||
// Border Properties
|
||||
|
|
@ -727,7 +769,7 @@ object CssParser {
|
|||
textEmphasisStyleString = value
|
||||
}
|
||||
"text-emphasis-color", "-epub-text-emphasis-color" -> {
|
||||
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
|
||||
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
}
|
||||
"text-emphasis-position", "-epub-text-emphasis-position" -> {
|
||||
if (value in listOf("over", "under")) {
|
||||
|
|
@ -785,7 +827,7 @@ object CssParser {
|
|||
val finalStyle = style ?: "none"
|
||||
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
|
||||
|
||||
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false)
|
||||
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
|
||||
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
|
||||
return BorderStyle(finalWidth, adaptedColor, finalStyle)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import androidx.compose.ui.platform.LocalTextToolbar
|
|||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.TextToolbar
|
||||
import androidx.compose.ui.platform.TextToolbarStatus
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
|
|
@ -490,6 +491,8 @@ private fun WrappingContentLayout(
|
|||
fun PaginatedReaderScreen(
|
||||
book: EpubBook,
|
||||
isDarkTheme: Boolean,
|
||||
effectiveBg: Color,
|
||||
effectiveText: Color,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
searchQuery: String,
|
||||
|
|
@ -513,7 +516,8 @@ fun PaginatedReaderScreen(
|
|||
onHighlightCreated: (String, String, String) -> Unit,
|
||||
onHighlightDeleted: (String) -> Unit,
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
activeTextureId: String? = null
|
||||
) {
|
||||
LaunchedEffect(userHighlights) {
|
||||
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
||||
|
|
@ -522,10 +526,26 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val context = LocalContext.current
|
||||
val textureBitmap = remember(activeTextureId) {
|
||||
activeTextureId?.let { id ->
|
||||
com.aryan.reader.epubreader.ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
|
||||
androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textureModifier = if (textureBitmap != null) {
|
||||
Modifier.drawBehind {
|
||||
val brush = androidx.compose.ui.graphics.ShaderBrush(
|
||||
androidx.compose.ui.graphics.ImageShader(textureBitmap, androidx.compose.ui.graphics.TileMode.Repeated, androidx.compose.ui.graphics.TileMode.Repeated)
|
||||
)
|
||||
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
|
||||
}
|
||||
} else Modifier
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val textColor = if (isDarkTheme) MaterialTheme.colorScheme.onBackground
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
||||
|
||||
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
|
||||
|
|
@ -536,22 +556,36 @@ fun PaginatedReaderScreen(
|
|||
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
|
||||
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
|
||||
|
||||
val previousConstraints = remember { arrayOf(this.constraints) }
|
||||
if (previousConstraints[0] != this.constraints) {
|
||||
val previousState = remember {
|
||||
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
|
||||
}
|
||||
|
||||
if (previousState[0] != this.constraints ||
|
||||
previousState[1] != isDarkTheme ||
|
||||
previousState[2] != effectiveBg ||
|
||||
previousState[3] != effectiveText
|
||||
) {
|
||||
val activePaginator = currentPaginatorRef.value
|
||||
if (activePaginator is BookPaginator) {
|
||||
val currentPage = pagerState.currentPage
|
||||
val locator = activePaginator.getLocatorForPage(currentPage)
|
||||
if (locator != null) {
|
||||
anchorLocatorForReconfig = locator
|
||||
}
|
||||
anchorLocatorForReconfig = locator
|
||||
|
||||
Timber.tag("ThemeReconfig").d("""
|
||||
RECONFIG DETECTED
|
||||
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
|
||||
- Current Page: $currentPage
|
||||
- Saved Locator: $locator
|
||||
""".trimIndent())
|
||||
}
|
||||
previousConstraints[0] = this.constraints
|
||||
previousState[0] = this.constraints
|
||||
previousState[1] = isDarkTheme
|
||||
previousState[2] = effectiveBg
|
||||
previousState[3] = effectiveText
|
||||
}
|
||||
|
||||
val textStyle = remember(
|
||||
baseTextStyle,
|
||||
textColor,
|
||||
baseTextStyle, effectiveText,
|
||||
debouncedFontSizeMult,
|
||||
debouncedLineHeightMult,
|
||||
debouncedFontFamily
|
||||
|
|
@ -560,7 +594,7 @@ fun PaginatedReaderScreen(
|
|||
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
|
||||
|
||||
baseTextStyle.copy(
|
||||
color = textColor,
|
||||
color = effectiveText,
|
||||
fontSize = adjustedFontSize,
|
||||
lineHeight = adjustedLineHeight,
|
||||
fontFamily = debouncedFontFamily,
|
||||
|
|
@ -636,7 +670,7 @@ fun PaginatedReaderScreen(
|
|||
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
|
||||
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
|
||||
}
|
||||
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign) {
|
||||
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText) {
|
||||
val userAgentStylesheet = UserAgentStylesheet.default
|
||||
var allRules = OptimizedCssRules()
|
||||
val allFontFaces = mutableListOf<FontFaceInfo>()
|
||||
|
|
@ -647,7 +681,9 @@ fun PaginatedReaderScreen(
|
|||
baseFontSizeSp = textStyle.fontSize.value,
|
||||
density = density.density,
|
||||
constraints = textConstraints,
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = effectiveBg,
|
||||
themeTextColor = effectiveText
|
||||
)
|
||||
allRules = allRules.merge(uaResult.rules)
|
||||
allFontFaces.addAll(uaResult.fontFaces)
|
||||
|
|
@ -659,7 +695,9 @@ fun PaginatedReaderScreen(
|
|||
baseFontSizeSp = textStyle.fontSize.value,
|
||||
density = density.density,
|
||||
constraints = textConstraints,
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = effectiveBg,
|
||||
themeTextColor = effectiveText
|
||||
)
|
||||
allRules = allRules.merge(bookCssResult.rules)
|
||||
allFontFaces.addAll(bookCssResult.fontFaces)
|
||||
|
|
@ -687,6 +725,8 @@ fun PaginatedReaderScreen(
|
|||
density = density,
|
||||
fontFamilyMap = fontFamilyMap,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = effectiveBg,
|
||||
themeTextColor = effectiveText,
|
||||
bookId = uniqueBookId,
|
||||
bookCacheDao = bookCacheDao,
|
||||
proto = proto,
|
||||
|
|
@ -707,31 +747,28 @@ fun PaginatedReaderScreen(
|
|||
|
||||
LaunchedEffect(paginator) {
|
||||
if (anchorLocatorForReconfig != null) {
|
||||
Timber.d("Waiting for paginator to initialize before restoring anchor...")
|
||||
Timber.tag("ThemeReconfig").d("Restoration Effect Triggered for Locator: $anchorLocatorForReconfig")
|
||||
|
||||
// Suspend until isLoading becomes false
|
||||
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
||||
|
||||
val targetLocator = anchorLocatorForReconfig
|
||||
if (targetLocator != null) {
|
||||
Timber.d(
|
||||
"Paginator initialized. Restoring anchor: Chapter=${targetLocator.chapterIndex}, Block=${targetLocator.blockIndex}, Offset=${targetLocator.charOffset}"
|
||||
)
|
||||
|
||||
val page = paginator.findPageForLocator(targetLocator)
|
||||
|
||||
Timber.tag("ThemeReconfig").d("""
|
||||
Restoration Progress:
|
||||
- Target Locator: $targetLocator
|
||||
- Paginator found Page: $page
|
||||
- Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]}
|
||||
""".trimIndent())
|
||||
|
||||
if (page != null) {
|
||||
pagerState.scrollToPage(page)
|
||||
Timber.d("Restored to page: $page")
|
||||
} else {
|
||||
val startPage =
|
||||
paginator.chapterStartPageIndices[targetLocator.chapterIndex]
|
||||
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
|
||||
if (startPage != null) {
|
||||
Timber.w(
|
||||
"Exact locator not found. Falling back to start of chapter at page $startPage"
|
||||
)
|
||||
Timber.tag("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage")
|
||||
pagerState.scrollToPage(startPage)
|
||||
} else {
|
||||
Timber.e("Failed to restore position. Chapter start index not found.")
|
||||
}
|
||||
}
|
||||
anchorLocatorForReconfig = null
|
||||
|
|
@ -780,6 +817,7 @@ fun PaginatedReaderScreen(
|
|||
uiState = uiState,
|
||||
pagerState = pagerState,
|
||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||
effectiveBg = effectiveBg,
|
||||
searchQuery = searchQuery,
|
||||
ttsHighlightInfo = ttsHighlightInfo,
|
||||
textStyle = textStyle,
|
||||
|
|
@ -814,7 +852,8 @@ fun PaginatedReaderScreen(
|
|||
onHighlightDeleted = onHighlightDeleted,
|
||||
isDarkTheme = isDarkTheme,
|
||||
activeHighlightPalette = activeHighlightPalette,
|
||||
onUpdatePalette = onUpdatePalette
|
||||
onUpdatePalette = onUpdatePalette,
|
||||
effectiveText = effectiveText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1366,6 +1405,8 @@ internal fun PaginatedReaderContent(
|
|||
uiState: PaginatedReaderUiState,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
effectiveBg: Color,
|
||||
effectiveText: Color,
|
||||
searchQuery: String,
|
||||
ttsHighlightInfo: TtsHighlightInfo?,
|
||||
textStyle: TextStyle,
|
||||
|
|
@ -1526,7 +1567,7 @@ internal fun PaginatedReaderContent(
|
|||
val pageModifier = if (isPageTurnAnimationEnabled) {
|
||||
Modifier
|
||||
.zIndex(zIndex)
|
||||
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED
|
||||
.realisticBookPage(pagerState, pageIndex, effectiveBg, isDarkTheme, pageTurnTouchY)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
|
@ -3396,6 +3437,7 @@ private fun RenderFlexChildBlock(
|
|||
private fun Modifier.realisticBookPage(
|
||||
pagerState: PagerState,
|
||||
pageIndex: Int,
|
||||
paperColor: Color,
|
||||
isDarkTheme: Boolean,
|
||||
touchY: Float?
|
||||
): Modifier = composed {
|
||||
|
|
@ -3419,7 +3461,6 @@ private fun Modifier.realisticBookPage(
|
|||
}
|
||||
.drawWithContent {
|
||||
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
|
||||
|
||||
if (abs(pageOffset) < 0.001f) {
|
||||
drawRect(color = paperColor)
|
||||
|
|
@ -3507,8 +3548,10 @@ private fun Modifier.realisticBookPage(
|
|||
|
||||
clipRect(0f, 0f, w, h) {
|
||||
clipPath(frontPath) {
|
||||
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
||||
drawPath(reflectedScreenPath, color = flapColor)
|
||||
drawPath(reflectedScreenPath, color = paperColor)
|
||||
|
||||
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
||||
drawPath(reflectedScreenPath, color = flapTint)
|
||||
|
||||
val innerShadowWidth = shadowWidth * 0.7f
|
||||
val innerShadowBrush = Brush.linearGradient(
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
textStyle: TextStyle,
|
||||
density: Density,
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: androidx.compose.ui.graphics.Color,
|
||||
themeTextColor: androidx.compose.ui.graphics.Color,
|
||||
context: Context,
|
||||
initialChapterToPaginate: Int?,
|
||||
mathMLRenderer: MathMLRenderer
|
||||
|
|
@ -83,7 +85,7 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
|
||||
// CSS Parsing and Font Loading
|
||||
val userAgentStylesheet = UserAgentStylesheet.default
|
||||
var allRules = OptimizedCssRules() // CHANGED from: mutableListOf<CssRule>()
|
||||
var allRules = OptimizedCssRules()
|
||||
val allFontFaces = mutableListOf<FontFaceInfo>()
|
||||
|
||||
val uaResult = CssParser.parse(
|
||||
|
|
@ -92,9 +94,11 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
baseFontSizeSp = textStyle.fontSize.value,
|
||||
density = density.density,
|
||||
constraints = textConstraints,
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor
|
||||
)
|
||||
allRules = allRules.merge(uaResult.rules) // CHANGED
|
||||
allRules = allRules.merge(uaResult.rules)
|
||||
allFontFaces.addAll(uaResult.fontFaces)
|
||||
|
||||
book.css.forEach { (path, content) ->
|
||||
|
|
@ -104,9 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
baseFontSizeSp = textStyle.fontSize.value,
|
||||
density = density.density,
|
||||
constraints = textConstraints,
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor
|
||||
)
|
||||
allRules = allRules.merge(bookCssResult.rules) // CHANGED
|
||||
allRules = allRules.merge(bookCssResult.rules)
|
||||
allFontFaces.addAll(bookCssResult.fontFaces)
|
||||
}
|
||||
val fontFamilyMap = loadFontFamilies(
|
||||
|
|
@ -125,6 +131,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
density = density,
|
||||
fontFamilyMap = fontFamilyMap,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
bookId = bookId,
|
||||
bookCacheDao = bookCacheDao,
|
||||
proto = proto,
|
||||
|
|
|
|||
|
|
@ -23,10 +23,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -40,8 +37,6 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -54,7 +49,6 @@ import androidx.compose.material3.SwitchDefaults
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
|
|
@ -71,7 +65,6 @@ import androidx.compose.ui.geometry.CornerRadius
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
|
|
@ -79,15 +72,16 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.toColorInt
|
||||
import com.aryan.reader.BrightnessSlider
|
||||
import com.aryan.reader.ColorComparePill
|
||||
import com.aryan.reader.HexInput
|
||||
import com.aryan.reader.RgbInputColumn
|
||||
import com.aryan.reader.SpectrumBox
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -589,285 +583,6 @@ private fun ColorPickerDialog(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
|
||||
)
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width.toFloat() - (paddingPx * 2)
|
||||
val activeHeight = size.height.toFloat() - (paddingPx * 2)
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
|
||||
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(h, s)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(touchPadding)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(rainbowColors)
|
||||
)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width - (paddingPx * 2)
|
||||
val activeHeight = size.height - (paddingPx * 2)
|
||||
|
||||
val x = paddingPx + (hue / 360f) * activeWidth
|
||||
val y = paddingPx + saturation * activeHeight
|
||||
|
||||
val pointerRadius = 10.dp.toPx()
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
radius = pointerRadius + 1.dp.toPx(),
|
||||
center = Offset(x, y + 1.dp.toPx())
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val baseColor = remember(hue, saturation) {
|
||||
Color.hsv(hue, saturation, 1f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
fun update(offset: Offset) {
|
||||
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(v)
|
||||
}
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(Color.Black, baseColor)
|
||||
)
|
||||
)
|
||||
|
||||
val x = value * size.width
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 8.dp.toPx(),
|
||||
center = Offset(x, size.height / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val intValue = (value * 255).roundToInt()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.Gray,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
RgbInput(value = intValue, onValueChange = onValueChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RgbInput(
|
||||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
LaunchedEffect(value) {
|
||||
text = value.toString()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
val intVal = newText.toIntOrNull()
|
||||
if (intVal != null) {
|
||||
onValueChange(intVal.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = remember(color) {
|
||||
String.format("%06X", (0xFFFFFF and color.toArgb()))
|
||||
}
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
|
||||
LaunchedEffect(color) {
|
||||
val currentParsed = try {
|
||||
Color(("#$text").toColorInt())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (currentParsed?.toArgb() != color.toArgb()) {
|
||||
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
color = Color.Gray,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
if (uppercased.length == 6) {
|
||||
try {
|
||||
val parsedColorInt = "#$uppercased".toColorInt()
|
||||
val newColor = Color(parsedColorInt)
|
||||
onHexChanged(newColor)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.width(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
|
||||
drawRect(
|
||||
color = oldColor.copy(alpha = 1f),
|
||||
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
|
||||
)
|
||||
drawRect(
|
||||
color = newColor.copy(alpha = 1f),
|
||||
topLeft = Offset(size.width / 2, 0f),
|
||||
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PenItem(
|
||||
type: PenType,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue