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