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