diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 3584f19..4121089 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -189,7 +189,7 @@ window.VIEWPORT_PADDING_BOTTOM = bottom || 0; }; - window.applyReaderTheme = function (isDark) { + window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) { var styleId = "readerThemeStyle"; var themeStyleElement = document.getElementById(styleId); @@ -199,65 +199,185 @@ document.head.appendChild(themeStyleElement); } - // Set a class on the root element for theme state var themeClassName = isDark ? "dark-theme" : "light-theme"; var oppositeThemeClassName = isDark ? "light-theme" : "dark-theme"; document.documentElement.classList.remove(oppositeThemeClassName); document.documentElement.classList.add(themeClassName); - var css = ""; + var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF'); + var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000'); - if (isDark) { - css = ` html.dark-theme, html.dark-theme body { - background-color: #121212 !important; - color: #E0E0E0 !important; - } + var textureCss = textureBase64 + ? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;` + : 'background-image: none;'; - html.dark-theme a { - color: #BB86FC !important; - } + var css = ` + :root { + --reader-bg: ${effectiveBg}; + --reader-text: ${effectiveText}; + } + html.${themeClassName}, html.${themeClassName} body { + background-color: var(--reader-bg) !important; + color: var(--reader-text) !important; + ${textureCss} + } - html.dark-theme a p, - html.dark-theme a div, - html.dark-theme a span, - html.dark-theme a li, - html.dark-theme a h1, - html.dark-theme a h2, - html.dark-theme a h3, - html.dark-theme a h4, - html.dark-theme a h5, - html.dark-theme a h6 { - color: #E0E0E0 !important; - background-color: transparent !important; - } + html.${themeClassName} a { + color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important; + } - html.dark-theme blockquote, html.dark-theme pre, - html.dark-theme figcaption, html.dark-theme caption, - html.dark-theme label, html.dark-theme dt, html.dark-theme dd { - color: inherit !important; - background-color: transparent !important; - } + html.${themeClassName} a p, + html.${themeClassName} a div, + html.${themeClassName} a span, + html.${themeClassName} a li, + html.${themeClassName} a h1, + html.${themeClassName} a h2, + html.${themeClassName} a h3, + html.${themeClassName} a h4, + html.${themeClassName} a h5, + html.${themeClassName} a h6 { + color: var(--reader-text) !important; + background-color: transparent !important; + } - html.dark-theme hr { - border-color: #444444 !important; - background-color: #444444 !important; - } + html.${themeClassName} blockquote, html.${themeClassName} pre, + html.${themeClassName} figcaption, html.${themeClassName} caption, + html.${themeClassName} label, html.${themeClassName} dt, html.${themeClassName} dd { + color: inherit !important; + background-color: transparent !important; + } - html.dark-theme table, html.dark-theme tr, html.dark-theme td, html.dark-theme th { - background-color: transparent !important; - border-color: #555 !important; - } + html.${themeClassName} hr { + border-color: ${isDark ? '#444444' : '#CCCCCC'} !important; + background-color: ${isDark ? '#444444' : '#CCCCCC'} !important; + } - `; - } else { - css = ` html.light-theme { - background-color: #FFFFFF; - } - - `; - } + html.${themeClassName} table, html.${themeClassName} tr, html.${themeClassName} td, html.${themeClassName} th { + background-color: transparent !important; + border-color: ${isDark ? '#555' : '#CCC'} !important; + } + `; themeStyleElement.innerHTML = css; + + if (window.adjustInlineColorsForContrast) { + window.adjustInlineColorsForContrast(isDark, effectiveBg); + } + }; + + function getLuminance(r, g, b) { + var a = [r, g, b].map(function (v) { + v /= 255; + return v <= 0.03928 + ? v / 12.92 + : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } + + function hexToRgb(hex) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : {r:255,g:255,b:255}; + } + + function rgbStringToRgb(rgbStr) { + var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (parts) { + return { r: parseInt(parts[1]), g: parseInt(parts[2]), b: parseInt(parts[3]) }; + } + return null; + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var h, s, l = (max + min) / 2; + + if(max == min){ + h = s = 0; // achromatic + }else{ + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch(max){ + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return[h, s, l]; + } + + function hslToRgb(h, s, l) { + var r, g, b; + if(s == 0){ + r = g = b = l; // achromatic + }else{ + var hue2rgb = function hue2rgb(p, q, t){ + if(t < 0) t += 1; + if(t > 1) t -= 1; + if(t < 1/6) return p + (q - p) * 6 * t; + if(t < 1/2) return q; + if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + return[Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + } + + window.adjustInlineColorsForContrast = function(isDark, bgHex) { + var bgRgb = hexToRgb(bgHex); + var bgLum = getLuminance(bgRgb.r, bgRgb.g, bgRgb.b); + + var elements = document.querySelectorAll('[style*="color"]'); + elements.forEach(function(el) { + var style = window.getComputedStyle(el); + var colorStr = style.color; + var rgb = rgbStringToRgb(colorStr); + if (rgb) { + var lum = getLuminance(rgb.r, rgb.g, rgb.b); + var l1 = Math.max(bgLum, lum); + var l2 = Math.min(bgLum, lum); + var contrast = (l1 + 0.05) / (l2 + 0.05); + + if (contrast < 4.5) { + var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + if (bgLum < 0.5) { + hsl[2] = Math.max(hsl[2], 0.7); // Lighten + } else { + hsl[2] = Math.min(hsl[2], 0.3); // Darken + } + var newRgb = hslToRgb(hsl[0], hsl[1], hsl[2]); + el.style.setProperty('color', `rgb(${newRgb[0]}, ${newRgb[1]}, ${newRgb[2]})`, 'important'); + } + } + }); + + var bgElements = document.querySelectorAll('[style*="background"]'); + bgElements.forEach(function(el) { + var style = window.getComputedStyle(el); + var bgStr = style.backgroundColor; + if (bgStr && bgStr !== 'rgba(0, 0, 0, 0)' && bgStr !== 'transparent') { + var rgb = rgbStringToRgb(bgStr); + if (rgb) { + var lum = getLuminance(rgb.r, rgb.g, rgb.b); + if (isDark && lum > 0.5) { + el.style.setProperty('background-color', 'transparent', 'important'); + } else if (!isDark && lum < 0.2) { + el.style.setProperty('background-color', 'transparent', 'important'); + } + } + } + }); }; function handleHighlightInteraction(e) { diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 4f5f287..2fe545e 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -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) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index c8b610e..7267acf 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -26,8 +26,10 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.graphics.Color import android.graphics.Rect +import android.util.Base64 import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebSettings @@ -66,6 +68,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 @@ -84,6 +87,7 @@ import kotlinx.coroutines.launch import org.json.JSONObject import timber.log.Timber import java.io.BufferedReader +import java.io.ByteArrayOutputStream import java.io.InputStreamReader private fun getFontCssInjection(): String { @@ -150,7 +154,14 @@ class HighlightJsBridge( } @JavascriptInterface - fun onHighlightClicked(cfi: String, text: String, left: Int, top: Int, right: Int, bottom: Int) { + fun onHighlightClicked( + cfi: String, + text: String, + left: Int, + top: Int, + right: Int, + bottom: Int + ) { onClickCallback?.invoke(cfi, text, left, top, right, bottom) } } @@ -175,7 +186,8 @@ class CfiJsBridge( fun onCfiExtracted(jsonResponse: String) { try { // --- ADDED LOG --- - Timber.tag("PosSaveDiag").d("CfiJsBridge.onCfiExtracted: Raw JSON received from JS: $jsonResponse") + Timber.tag("PosSaveDiag") + .d("CfiJsBridge.onCfiExtracted: Raw JSON received from JS: $jsonResponse") val json = JSONObject(jsonResponse) val cfi = json.optString("cfi", "/4") @@ -196,7 +208,8 @@ class CfiJsBridge( onCfiReady(cfi) } } catch (e: Exception) { - Timber.tag("PosSaveDiag").e(e, "CfiJsBridge.onCfiExtracted: Error parsing CFI JSON response: $jsonResponse") + Timber.tag("PosSaveDiag") + .e(e, "CfiJsBridge.onCfiExtracted: Error parsing CFI JSON response: $jsonResponse") onCfiReady("/4") } } @@ -270,8 +283,7 @@ private data class CustomMenuState( @Suppress("unused") class AiJsBridge( - private val scope: CoroutineScope, - private val onContentReady: suspend (String) -> Unit + private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit ) { @JavascriptInterface fun onContentExtractedForSummarization(text: String) { @@ -297,6 +309,8 @@ fun ChapterWebView( onChunkRequested: (Int) -> Unit, chapterTitle: String, isDarkTheme: Boolean, + effectiveBg: androidx.compose.ui.graphics.Color, + effectiveText: androidx.compose.ui.graphics.Color, initialScrollTarget: ChapterScrollPosition?, initialPageScrollY: Int?, initialCfi: String?, @@ -335,7 +349,8 @@ fun ChapterWebView( onHighlightClicked: () -> Unit, onAutoScrollChapterEnd: () -> Unit = {}, activeHighlightPalette: List, - onUpdatePalette: (Int, HighlightColor) -> Unit + onUpdatePalette: (Int, HighlightColor) -> Unit, + activeTextureId: String? = null ) { Timber.d( "RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget" @@ -352,6 +367,21 @@ fun ChapterWebView( var showPaletteManager by remember { mutableStateOf(false) } + val textureBase64 by remember(activeTextureId) { + mutableStateOf( + activeTextureId?.let { id -> + ReaderTexture.entries.find { it.id == id }?.resId?.let { resId -> + val bmp = BitmapFactory.decodeResource(context.resources, resId) + val out = ByteArrayOutputStream() + bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out) + "data:image/png;base64," + Base64.encodeToString( + out.toByteArray(), + Base64.NO_WRAP + ) + } + }) + } + val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady) val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated) val currentOnBookmarkCfiGenerated by rememberUpdatedState(onBookmarkCfiGenerated) @@ -359,8 +389,7 @@ fun ChapterWebView( LaunchedEffect(currentFontSize, currentLineHeight) { localWebViewRef?.evaluateJavascript( - "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", - null + "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) } @@ -391,9 +420,7 @@ fun ChapterWebView( } catch (e: ActivityNotFoundException) { Timber.e(e, "No activity found to handle intent for URL: $urlToShow") Toast.makeText( - context, - "No browser found to open the link.", - Toast.LENGTH_LONG + context, "No browser found to open the link.", Toast.LENGTH_LONG ).show() } showExternalLinkDialog = null @@ -409,14 +436,19 @@ fun ChapterWebView( }, dismissButton = { TextButton(onClick = { showExternalLinkDialog = null }) { Text("Cancel") } - } - ) + }) } Box(modifier = modifier.fillMaxSize()) { + + LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) { + val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) + val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) + localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null) + } + key( key, - isDarkTheme, currentFontSize, currentLineHeight, currentFontFamily, @@ -424,408 +456,427 @@ fun ChapterWebView( ) { AndroidView( factory = { ctx -> - Timber.d( - "InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget" + Timber.d( + "InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget" + ) + val webView = InteractiveWebView( + context = ctx, + onSingleTap = onTap, + onPotentialScroll = onPotentialScroll, + onOverScrollTop = onOverScrollTop, + onOverScrollBottom = onOverScrollBottom, + onReleaseOverScrollTop = onReleaseOverScrollTop, + onReleaseOverScrollBottom = onReleaseOverScrollBottom, + onShowCustomSelectionMenu = { text, bounds, finishCallback -> + if (text.isNotBlank() && !bounds.isEmpty) { + customMenuState = CustomMenuState( + selectedText = text, + selectionBounds = Rect(bounds), + finishActionModeCallback = finishCallback, + isExistingHighlight = false + ) + } else { + customMenuState = null + finishCallback() + } + }, + onHideCustomSelectionMenu = { + if (customMenuState?.isExistingHighlight != true) { + customMenuState = null + } + }).apply { + localWebViewRef = this + onWebViewInstanceCreated(this) + addJavascriptInterface( + PageInfoBridge(onScrollStateUpdate), "PageInfoReporter" ) - val webView = InteractiveWebView( - context = ctx, - onSingleTap = onTap, - onPotentialScroll = onPotentialScroll, - onOverScrollTop = onOverScrollTop, - onOverScrollBottom = onOverScrollBottom, - onReleaseOverScrollTop = onReleaseOverScrollTop, - onReleaseOverScrollBottom = onReleaseOverScrollBottom, - onShowCustomSelectionMenu = { text, bounds, finishCallback -> - if (text.isNotBlank() && !bounds.isEmpty) { - customMenuState = CustomMenuState( - selectedText = text, - selectionBounds = Rect(bounds), - finishActionModeCallback = finishCallback, - isExistingHighlight = false - ) - } else { - customMenuState = null - finishCallback() - } - }, - onHideCustomSelectionMenu = { - if (customMenuState?.isExistingHighlight != true) { - customMenuState = null - } - } - ).apply { - localWebViewRef = this - onWebViewInstanceCreated(this) - addJavascriptInterface( - PageInfoBridge(onScrollStateUpdate), - "PageInfoReporter" - ) - addJavascriptInterface( - ProgressJsBridge(onTopChunkUpdated), - "ProgressReporter" - ) - addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge") + addJavascriptInterface( + ProgressJsBridge(onTopChunkUpdated), "ProgressReporter" + ) + addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge") - addJavascriptInterface(HighlightJsBridge( - onCreateCallback = onHighlightCreated, - onClickCallback = { cfi, text, left, top, right, bottom -> + addJavascriptInterface( + HighlightJsBridge( + onCreateCallback = onHighlightCreated, + onClickCallback = { cfi, text, left, top, right, bottom -> - onHighlightClicked() + onHighlightClicked() - val densityValue = density.density - val locationOnScreen = IntArray(2) - this.getLocationOnScreen(locationOnScreen) - val xOffset = locationOnScreen[0] - val yOffset = locationOnScreen[1] + val densityValue = density.density + val locationOnScreen = IntArray(2) + this.getLocationOnScreen(locationOnScreen) + val xOffset = locationOnScreen[0] + val yOffset = locationOnScreen[1] - val rect = Rect( - (left * densityValue).toInt() + xOffset, - (top * densityValue).toInt() + yOffset, - (right * densityValue).toInt() + xOffset, - (bottom * densityValue).toInt() + yOffset - ) + val rect = Rect( + (left * densityValue).toInt() + xOffset, + (top * densityValue).toInt() + yOffset, + (right * densityValue).toInt() + xOffset, + (bottom * densityValue).toInt() + yOffset + ) - customMenuState = CustomMenuState( - selectedText = text, - selectionBounds = rect, - finishActionModeCallback = { - localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) - }, - cfi = cfi, - isExistingHighlight = true - ) - } - ), "HighlightBridge") - - addJavascriptInterface( - AutoScrollJsBridge { - onAutoScrollChapterEnd() - }, - "AutoScrollBridge" - ) - - webChromeClient = object : android.webkit.WebChromeClient() { - override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { - consoleMessage?.let { - val message = it.message() - when { - message.startsWith("BookmarkDiagnosis") -> { - Timber.tag("BookmarkDiagnosis").d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") - } - - message.startsWith("CFI_DIAGNOSIS:") -> { - Timber.d( - "JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}" - ) - } - - message.startsWith("ImageDiagnosis") -> { - Timber.d("JS -> $message") - } - - message.startsWith("TTS_HIGHLIGHT_DIAGNOSIS:") -> { - Timber.d( - "JS -> ${message.substringAfter("TTS_HIGHLIGHT_DIAGNOSIS: ")}" - ) - } - - message.startsWith("HIGHLIGHT_DEBUG:") -> { - Timber.d( - "JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}" - ) - } - - message.startsWith("ReaderFontDiagnosis") -> { - Timber.d( - "JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}" - ) - } - - message.startsWith("AutoScrollDiagnosis") -> { - Timber.d( - "JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}" - ) - } - - message.startsWith("FRAG_NAV_DEBUG") -> { - Timber.tag("FRAG_NAV_DEBUG").d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}") - } - - else -> { - Timber.d( - "[${it.sourceId()}:${it.lineNumber()}] ${it.message()}" - ) - } - } - } - return true - } - } - addJavascriptInterface( - CfiJsBridge( - onCfiReady = { cfi -> currentOnCfiGenerated(cfi) }, - onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) }, - onScrollFinishedCallback = { success -> currentOnScrollFinished(success) } - ), "CfiBridge" - ) - - addJavascriptInterface( - SnippetJsBridge { cfi, snippet -> - currentOnSnippetForBookmarkReady(cfi, snippet) - }, "SnippetBridge" - ) - addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") - addJavascriptInterface( - AiJsBridge(ttsScope, onContentReadyForSummarization), - "AiBridge" - ) - - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - val url = request?.url?.toString() - if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { - Timber.d("Intercepted external link: $url") - showExternalLinkDialog = url - return true - } - return false - } - - override fun onLoadResource(view: WebView?, url: String?) { - super.onLoadResource(view, url) - if (url?.contains(".jpg", true) == true || - url?.contains(".jpeg", true) == true || - url?.contains(".png", true) == true || - url?.contains(".gif", true) == true || - url?.contains(".svg", true) == true || - url?.contains("image", true) == true - ) { - Timber.d( - "WebView is attempting to load resource: $url" - ) - } - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - Timber.d( - "onPageFinished. Injecting CSS and Font: ${currentFontFamily.fontFamilyName}" - ) - - view?.evaluateJavascript(jsToInject, null) - view?.evaluateJavascript( - "javascript:window.applyReaderTheme($isDarkTheme);", - null - ) - - val fragmentsJson = org.json.JSONArray(tocFragments).toString() - Timber.tag("FRAG_NAV_DEBUG").d("onPageFinished: Re-injecting TOC_FRAGMENTS: $fragmentsJson") - view?.evaluateJavascript("javascript:window.TOC_FRAGMENTS = $fragmentsJson;", null) - - view?.evaluateJavascript("javascript:setTimeout(window.auditTocFragments, 500);", null) - - view?.evaluateJavascript("javascript:window.HighlightBridgeHelper.restoreHighlights('${escapeJsString(highlightsJson)}');", null) - - val fontCss = getFontCssInjection().replace("\n", " ") - val customFontCss = if (customFontPath != null) { - "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" - } else "" - val combinedCss = "$fontCss $customFontCss" - - val injectFontJs = - "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);" - view?.evaluateJavascript("javascript:$injectFontJs") { - Timber.d("CSS Injection result: $it") - } - - val fontNameForJs = if (customFontPath != null) { - "CustomFont" - } else if (currentFontFamily == ReaderFont.ORIGINAL) { - "" - } else { - currentFontFamily.fontFamilyName - } - - view?.evaluateJavascript( - "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');", - null - ) - - view?.evaluateJavascript( - "javascript:window.checkImagesForDiagnosis();", - null - ) - - view?.evaluateJavascript( - "javascript:window.virtualization.init($initialChunkIndex, $totalChunks);", - null - ) - - @Suppress("VariableNeverRead") var scrollActionTaken = false - - if (!initialCfi.isNullOrBlank()) { - val cfiJsCommand = - "javascript:window.scrollToCfi('$initialCfi');" - Timber.d( - "WebView onPageFinished: Executing initial scroll to CFI: $initialCfi" - ) - view?.evaluateJavascript(cfiJsCommand) { - onChapterInitiallyScrolled() - scrollActionTaken = true - } - } else if (!initialFragmentId.isNullOrBlank()) { - Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") - view?.evaluateJavascript( - "javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", + customMenuState = CustomMenuState( + selectedText = text, + selectionBounds = rect, + finishActionModeCallback = { + localWebViewRef?.evaluateJavascript( + "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) - onChapterInitiallyScrolled() - scrollActionTaken = true - } else if (initialScrollTarget != null) { - val scrollJsCommand = when (initialScrollTarget) { - ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" - else -> "javascript:window.scrollToChapterStart();" + }, + cfi = cfi, + isExistingHighlight = true + ) + }), "HighlightBridge") + + addJavascriptInterface( + AutoScrollJsBridge { + onAutoScrollChapterEnd() + }, "AutoScrollBridge" + ) + + webChromeClient = object : android.webkit.WebChromeClient() { + override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { + consoleMessage?.let { + val message = it.message() + when { + message.startsWith("BookmarkDiagnosis") -> { + Timber.tag("BookmarkDiagnosis") + .d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") } - Timber.d( - "WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget" - ) - view?.evaluateJavascript(scrollJsCommand) { - onChapterInitiallyScrolled() - scrollActionTaken = true + + message.startsWith("CFI_DIAGNOSIS:") -> { + Timber.d( + "JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}" + ) } - } else if (initialPageScrollY != null && initialPageScrollY > 0) { - val scrollJsCommand = - "javascript:window.scrollToSpecificY($initialPageScrollY);" - Timber.d( - "WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY" - ) - view?.evaluateJavascript(scrollJsCommand) { - onChapterInitiallyScrolled() - scrollActionTaken = true + + message.startsWith("ImageDiagnosis") -> { + Timber.d("JS -> $message") } - } else { - Timber.d( - "WebView onPageFinished: No specific scroll, defaulting to start." - ) - view?.evaluateJavascript("javascript:window.scrollToChapterStart();") { - onChapterInitiallyScrolled() - scrollActionTaken = true + + message.startsWith("TTS_HIGHLIGHT_DIAGNOSIS:") -> { + Timber.d( + "JS -> ${message.substringAfter("TTS_HIGHLIGHT_DIAGNOSIS: ")}" + ) + } + + message.startsWith("HIGHLIGHT_DEBUG:") -> { + Timber.d( + "JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}" + ) + } + + message.startsWith("ReaderFontDiagnosis") -> { + Timber.d( + "JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}" + ) + } + + message.startsWith("AutoScrollDiagnosis") -> { + Timber.d( + "JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}" + ) + } + + message.startsWith("FRAG_NAV_DEBUG") -> { + Timber.tag("FRAG_NAV_DEBUG") + .d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}") + } + + else -> { + Timber.d( + "[${it.sourceId()}:${it.lineNumber()}] ${it.message()}" + ) } } + } + return true + } + } + addJavascriptInterface( + CfiJsBridge( + onCfiReady = { cfi -> currentOnCfiGenerated(cfi) }, + onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) }, + onScrollFinishedCallback = { success -> + currentOnScrollFinished(success) + }), "CfiBridge") - view?.clearFocus() - view?.evaluateJavascript( - "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", - null + addJavascriptInterface( + SnippetJsBridge { cfi, snippet -> + currentOnSnippetForBookmarkReady(cfi, snippet) + }, "SnippetBridge" + ) + addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") + addJavascriptInterface( + AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge" + ) + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, request: WebResourceRequest? + ): Boolean { + val url = request?.url?.toString() + if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { + Timber.d("Intercepted external link: $url") + showExternalLinkDialog = url + return true + } + return false + } + + override fun onLoadResource(view: WebView?, url: String?) { + super.onLoadResource(view, url) + if (url?.contains(".jpg", true) == true || url?.contains( + ".jpeg", + true + ) == true || url?.contains( + ".png", + true + ) == true || url?.contains( + ".gif", + true + ) == true || url?.contains( + ".svg", + true + ) == true || url?.contains("image", true) == true + ) { + Timber.d( + "WebView is attempting to load resource: $url" ) } } - settings.apply { - javaScriptEnabled = true - allowFileAccess = true - allowContentAccess = true - domStorageEnabled = true - layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL - setNeedInitialFocus(false) - setSupportZoom(false) - builtInZoomControls = false - displayZoomControls = false - useWideViewPort = true - loadWithOverviewMode = true + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + Timber.d( + "onPageFinished. Injecting CSS and Font: ${currentFontFamily.fontFamilyName}" + ) + + view?.evaluateJavascript(jsToInject, null) + + val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) + val textHex = + String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) + view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", + null) + + val fragmentsJson = org.json.JSONArray(tocFragments).toString() + Timber.tag("FRAG_NAV_DEBUG") + .d("onPageFinished: Re-injecting TOC_FRAGMENTS: $fragmentsJson") + view?.evaluateJavascript( + "javascript:window.TOC_FRAGMENTS = $fragmentsJson;", + null + ) + + view?.evaluateJavascript( + "javascript:setTimeout(window.auditTocFragments, 500);", + null + ) + + view?.evaluateJavascript( + "javascript:window.HighlightBridgeHelper.restoreHighlights('${ + escapeJsString( + highlightsJson + ) + }');", null + ) + + val fontCss = getFontCssInjection().replace("\n", " ") + val customFontCss = if (customFontPath != null) { + "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" + } else "" + val combinedCss = "$fontCss $customFontCss" + + val injectFontJs = + "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);" + view?.evaluateJavascript("javascript:$injectFontJs") { + Timber.d("CSS Injection result: $it") + } + + val fontNameForJs = if (customFontPath != null) { + "CustomFont" + } else if (currentFontFamily == ReaderFont.ORIGINAL) { + "" + } else { + currentFontFamily.fontFamilyName + } + + view?.evaluateJavascript( + "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');", + null + ) + + view?.evaluateJavascript( + "javascript:window.checkImagesForDiagnosis();", null + ) + + view?.evaluateJavascript( + "javascript:window.virtualization.init($initialChunkIndex, $totalChunks);", + null + ) + + @Suppress("VariableNeverRead") var scrollActionTaken = false + + if (!initialCfi.isNullOrBlank()) { + val cfiJsCommand = "javascript:window.scrollToCfi('$initialCfi');" + Timber.d( + "WebView onPageFinished: Executing initial scroll to CFI: $initialCfi" + ) + view?.evaluateJavascript(cfiJsCommand) { + onChapterInitiallyScrolled() + scrollActionTaken = true + } + } else if (!initialFragmentId.isNullOrBlank()) { + Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") + view?.evaluateJavascript( + "javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", + null + ) + onChapterInitiallyScrolled() + scrollActionTaken = true + } else if (initialScrollTarget != null) { + val scrollJsCommand = when (initialScrollTarget) { + ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" + else -> "javascript:window.scrollToChapterStart();" + } + Timber.d( + "WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget" + ) + view?.evaluateJavascript(scrollJsCommand) { + onChapterInitiallyScrolled() + scrollActionTaken = true + } + } else if (initialPageScrollY != null && initialPageScrollY > 0) { + val scrollJsCommand = + "javascript:window.scrollToSpecificY($initialPageScrollY);" + Timber.d( + "WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY" + ) + view?.evaluateJavascript(scrollJsCommand) { + onChapterInitiallyScrolled() + scrollActionTaken = true + } + } else { + Timber.d( + "WebView onPageFinished: No specific scroll, defaulting to start." + ) + view?.evaluateJavascript("javascript:window.scrollToChapterStart();") { + onChapterInitiallyScrolled() + scrollActionTaken = true + } + } + + view?.clearFocus() + view?.evaluateJavascript( + "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", + null + ) } - isVerticalScrollBarEnabled = false - isHorizontalScrollBarEnabled = false - this.setBackgroundColor(Color.TRANSPARENT) - Timber.d( - "WebView loading initial data with base URL: $baseUrl (Key: $key)" - ) - loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null) } - webView - }, - update = { webView -> + settings.apply { + javaScriptEnabled = true + allowFileAccess = true + allowContentAccess = true + domStorageEnabled = true + layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL + setNeedInitialFocus(false) + setSupportZoom(false) + builtInZoomControls = false + displayZoomControls = false + useWideViewPort = true + loadWithOverviewMode = true + } + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + this.setBackgroundColor(Color.TRANSPARENT) Timber.d( - "WebView update. Setting Font: ${currentFontFamily.fontFamilyName}" + "WebView loading initial data with base URL: $baseUrl (Key: $key)" ) - localWebViewRef = webView - onWebViewInstanceCreated(webView) - val fontCss = getFontCssInjection().replace("\n", " ") - val customFontCss = if (customFontPath != null) { - "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" - } else "" - val combinedCss = "$fontCss $customFontCss" - val injectFontJs = - "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" - webView.evaluateJavascript("javascript:$injectFontJs", null) - val fontNameForJs = if (customFontPath != null) { - "CustomFont" - } else if (currentFontFamily == ReaderFont.ORIGINAL) { - "" - } else { - currentFontFamily.fontFamilyName - } - val fragmentsJson = org.json.JSONArray(tocFragments).toString() - Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") + loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null) + } + webView + }, update = { webView -> + Timber.d( + "WebView update. Setting Font: ${currentFontFamily.fontFamilyName}" + ) + localWebViewRef = webView + onWebViewInstanceCreated(webView) + val fontCss = getFontCssInjection().replace("\n", " ") + val customFontCss = if (customFontPath != null) { + "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" + } else "" + val combinedCss = "$fontCss $customFontCss" + val injectFontJs = + "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" + webView.evaluateJavascript("javascript:$injectFontJs", null) + val fontNameForJs = if (customFontPath != null) { + "CustomFont" + } else if (currentFontFamily == ReaderFont.ORIGINAL) { + "" + } else { + currentFontFamily.fontFamilyName + } + val fragmentsJson = org.json.JSONArray(tocFragments).toString() + Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") - webView.evaluateJavascript("javascript:window.setTocFragments($fragmentsJson);", null) + webView.evaluateJavascript( + "javascript:window.setTocFragments($fragmentsJson);", + null + ) - webView.evaluateJavascript( - "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');", - null - ) - }, - modifier = Modifier.fillMaxSize() + webView.evaluateJavascript( + "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');", + null + ) + }, modifier = Modifier.fillMaxSize() ) } // Custom Selection Menu Popup customMenuState?.let { state -> - val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - val topMargin = with(density) { 16.dp.toPx() }.toInt() - val bottomMargin = with(density) { - if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx() - }.toInt() + val popupPositionProvider = + remember(state.selectionBounds, density, state.isExistingHighlight) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val topMargin = with(density) { 16.dp.toPx() }.toInt() + val bottomMargin = with(density) { + if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx() + }.toInt() - var x = state.selectionBounds.centerX() - popupContentSize.width / 2 + var x = state.selectionBounds.centerX() - popupContentSize.width / 2 - var y = state.selectionBounds.top - popupContentSize.height - topMargin - if (y < with(density) { 24.dp.toPx() }.toInt()) { - y = state.selectionBounds.bottom + bottomMargin - } - if (x < 0) x = 0 - if (x + popupContentSize.width > windowSize.width) { - x = windowSize.width - popupContentSize.width - } - if (y + popupContentSize.height > windowSize.height) { - y = windowSize.height - popupContentSize.height - } - if (y < 0) y = 0 + var y = state.selectionBounds.top - popupContentSize.height - topMargin + if (y < with(density) { 24.dp.toPx() }.toInt()) { + y = state.selectionBounds.bottom + bottomMargin + } + if (x < 0) x = 0 + if (x + popupContentSize.width > windowSize.width) { + x = windowSize.width - popupContentSize.width + } + if (y + popupContentSize.height > windowSize.height) { + y = windowSize.height - popupContentSize.height + } + if (y < 0) y = 0 - return IntOffset( - x.coerceIn(0, windowSize.width - popupContentSize.width), - y.coerceIn(0, windowSize.height - popupContentSize.height) - ) + return IntOffset( + x.coerceIn(0, windowSize.width - popupContentSize.width), + y.coerceIn(0, windowSize.height - popupContentSize.height) + ) + } } } - } Popup( - popupPositionProvider = popupPositionProvider, - onDismissRequest = { + popupPositionProvider = popupPositionProvider, onDismissRequest = { state.finishActionModeCallback() customMenuState = null - } - ) { + }) { Surface( shape = RoundedCornerShape(12.dp), shadowElevation = 6.dp, @@ -849,36 +900,31 @@ fun ChapterWebView( .size(32.dp) .background(colorEnum.color, CircleShape) .pointerInput(colorEnum) { - detectTapGestures( - onTap = { - Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}") - if (state.isExistingHighlight && state.cfi != null) { - localWebViewRef?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');", - null - ) - } else { - localWebViewRef?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');", - null - ) - } - state.finishActionModeCallback() - localWebViewRef?.clearFocus() - customMenuState = null - }, - onLongPress = { - showPaletteManager = true + detectTapGestures(onTap = { + Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}") + if (state.isExistingHighlight && state.cfi != null) { + localWebViewRef?.evaluateJavascript( + "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');", + null + ) + } else { + localWebViewRef?.evaluateJavascript( + "javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');", + null + ) } - ) - } - ) + state.finishActionModeCallback() + localWebViewRef?.clearFocus() + customMenuState = null + }, onLongPress = { + showPaletteManager = true + }) + }) } Spacer(modifier = Modifier.width(8.dp)) SpectrumButton( - onClick = { showPaletteManager = true }, - size = 32.dp + onClick = { showPaletteManager = true }, size = 32.dp ) } @@ -892,14 +938,18 @@ fun ChapterWebView( ) { PaginatedTextSelectionMenu( onCopy = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", state.selectedText) - clipboard.setPrimaryClip(clip) - state.finishActionModeCallback() - localWebViewRef?.clearFocus() - localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) - customMenuState = null - }, + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", state.selectedText) + clipboard.setPrimaryClip(clip) + state.finishActionModeCallback() + localWebViewRef?.clearFocus() + localWebViewRef?.evaluateJavascript( + "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", + null + ) + customMenuState = null + }, onSelectAll = null, onDictionary = { val textToDefine = state.selectedText @@ -924,24 +974,34 @@ fun ChapterWebView( }, onHighlight = null, // Highlight handles itself above in the Colors Row onTts = { - localWebViewRef?.evaluateJavascript("javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", null) + localWebViewRef?.evaluateJavascript( + "javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", + null + ) state.finishActionModeCallback() localWebViewRef?.clearFocus() - localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) + localWebViewRef?.evaluateJavascript( + "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", + null + ) customMenuState = null }, onDelete = if (state.isExistingHighlight && state.cfi != null) { { val highlightToDelete = userHighlights.find { h -> - h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) + h.cfi == state.cfi || h.cfi.split("|") + .contains(state.cfi) } if (highlightToDelete != null) { val cssClassToDelete = highlightToDelete.color.cssClass val allCfiParts = highlightToDelete.cfi.split("|") allCfiParts.forEach { partCfi -> localWebViewRef?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", - null + "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${ + escapeJsString( + partCfi + ) + }', '$cssClassToDelete');", null ) } onHighlightDeleted(highlightToDelete.cfi) @@ -951,8 +1011,7 @@ fun ChapterWebView( } } else null, isProUser = isProUser, - isOss = isOss - ) + isOss = isOss) } } } @@ -967,8 +1026,7 @@ fun ChapterWebView( onUpdatePalette(index, color) } showPaletteManager = false - } - ) + }) } } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 02f08c2..9f6ddd3 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -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( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 0215ad1..d7775dc 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -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) { + 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 { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]" + val themes = mutableListOf() + 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, + onThemeSelected: (String) -> Unit, + onCustomThemesUpdated: (List) -> Unit, + onDismiss: () -> Unit +) { + if (!isVisible) return + var showBuilder by remember { mutableStateOf(false) } + var editingTheme by remember { mutableStateOf(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, + 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(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)) } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index 62a3d87..0bc0bfe 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -107,6 +107,8 @@ class BookPaginator( private val density: Density, private val fontFamilyMap: Map, private val isDarkTheme: Boolean, + private val themeBackgroundColor: Color, + private val themeTextColor: Color, private val bookId: String, private val initialChapterToPaginate: Int, private val bookCss: Map, @@ -127,7 +129,7 @@ class BookPaginator( override var generation by mutableIntStateOf(0) private set - override val pageShiftRequest = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val pageShiftRequest = MutableSharedFlow(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val currentUserChapterIndex = MutableStateFlow(initialChapterToPaginate) internal val chapterPageCounts = ConcurrentHashMap() @@ -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.., 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): List { 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() 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() diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt index b1e486b..f782ab6 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt @@ -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>() val byClass = mutableMapOf>() @@ -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) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 05ccf4e..2de4398 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -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, - 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(null) } val currentPaginatorRef = remember { mutableStateOf(null) } - val previousConstraints = remember { arrayOf(this.constraints) } - if (previousConstraints[0] != this.constraints) { + val previousState = remember { + arrayOf(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() @@ -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( diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt index 0fd4d2c..a507736 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt @@ -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() + var allRules = OptimizedCssRules() val allFontFaces = mutableListOf() 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, diff --git a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt index cde0510..1591b7f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt +++ b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt @@ -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, diff --git a/app/src/main/res/drawable-nodpi/palette.xml b/app/src/main/res/drawable-nodpi/palette.xml new file mode 100644 index 0000000..f701027 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/palette.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/texture_canvas.png b/app/src/main/res/drawable-nodpi/texture_canvas.png new file mode 100644 index 0000000..edd5c01 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/texture_canvas.png differ diff --git a/app/src/main/res/drawable-nodpi/texture_eink.webp b/app/src/main/res/drawable-nodpi/texture_eink.webp new file mode 100644 index 0000000..050f115 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/texture_eink.webp differ diff --git a/app/src/main/res/drawable-nodpi/texture_paper.png b/app/src/main/res/drawable-nodpi/texture_paper.png new file mode 100644 index 0000000..b5855b9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/texture_paper.png differ diff --git a/app/src/main/res/drawable-nodpi/texture_slate.png b/app/src/main/res/drawable-nodpi/texture_slate.png new file mode 100644 index 0000000..9fddee6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/texture_slate.png differ