diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt
index 2fe545e..c327736 100644
--- a/app/src/main/java/com/aryan/reader/Common.kt
+++ b/app/src/main/java/com/aryan/reader/Common.kt
@@ -1,48 +1,27 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
+// Common.kt
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader
import android.content.Context
-import androidx.annotation.OptIn
+import android.speech.tts.TextToSpeech
+import android.speech.tts.Voice
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.material3.TooltipBox
-import androidx.compose.material3.PlainTooltip
-import androidx.compose.material3.RichTooltip
-import androidx.compose.material3.rememberTooltipState
-import androidx.compose.material3.TooltipDefaults
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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 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
@@ -59,6 +38,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -66,16 +46,20 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -89,11 +73,18 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.RichTooltip
+import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -101,6 +92,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -109,20 +101,29 @@ 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.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
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.SolidColor
+import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.luminance
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
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -141,7 +142,11 @@ 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.core.content.edit
+import androidx.core.graphics.toColorInt
import androidx.media3.common.util.UnstableApi
+import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
+import com.aryan.reader.epubreader.PREF_READER_THEME
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS
import com.aryan.reader.tts.SpeakerSamplePlayer
@@ -166,13 +171,14 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.parser.Parser
+import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
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.max
+import kotlin.math.min
import kotlin.math.roundToInt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
@@ -270,6 +276,7 @@ fun rememberSearchState(
private val activeTooltipState = mutableStateOf(null)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TooltipIconButton(
text: String,
@@ -488,7 +495,7 @@ fun SearchNavigationControls(
}
}
-@OptIn(UnstableApi::class)
+@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun SummarizationPopup(
title: String,
@@ -657,7 +664,7 @@ fun SummarizationPopup(
}
}
-@OptIn(UnstableApi::class)
+@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun AiDefinitionPopup(
word: String?,
@@ -1151,7 +1158,7 @@ suspend fun fetchRecap(
}
}
-@OptIn(UnstableApi::class)
+@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun TtsSettingsSheet(
isVisible: Boolean,
@@ -1662,12 +1669,12 @@ fun DeviceVoiceSettingsSheet(
ListItem(
headlineContent = {
- Text(
- text = friendlyName,
- fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
- color = MaterialTheme.colorScheme.onSurface
- )
- },
+ Text(
+ text = friendlyName,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
supportingContent = if (voice.locale.variant.isNotEmpty()) {
{ Text("Variant: ${voice.locale.variant}") }
} else null,
@@ -2014,4 +2021,599 @@ fun ColorComparePill(
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
+}
+
+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)
+)
+
+fun saveReaderThemeId(context: Context, themeId: String) {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ prefs.edit { putString(PREF_READER_THEME, themeId) }
+}
+
+fun loadReaderThemeId(context: Context): String {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ return prefs.getString(PREF_READER_THEME, "system") ?: "system"
+}
+
+fun saveCustomThemes(context: Context, themes: List) {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ val jsonArray = JSONArray()
+ themes.filter { it.isCustom }.forEach { theme ->
+ val obj = JSONObject().apply {
+ put("id", theme.id)
+ put("name", theme.name)
+ put("bgColor", theme.backgroundColor.toArgb())
+ put("textColor", theme.textColor.toArgb())
+ put("isDark", theme.isDark)
+ theme.textureId?.let { put("textureId", it) }
+ }
+ jsonArray.put(obj)
+ }
+ prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) }
+}
+
+fun loadCustomThemes(context: Context): List {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]"
+ val themes = mutableListOf()
+ try {
+ val jsonArray = org.json.JSONArray(jsonString)
+ for (i in 0 until jsonArray.length()) {
+ val obj = jsonArray.getJSONObject(i)
+ themes.add(
+ ReaderTheme(
+ id = obj.getString("id"),
+ name = obj.getString("name"),
+ backgroundColor = Color(obj.getInt("bgColor")),
+ textColor = Color(obj.getInt("textColor")),
+ isDark = obj.getBoolean("isDark"),
+ textureId = if (obj.has("textureId")) obj.getString("textureId") else null,
+ isCustom = true
+ )
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to parse custom themes")
+ }
+ return themes
+}
+
+private fun calculateContrastRatio(color1: Color, color2: Color): Float {
+ val l1 = max(color1.luminance(), color2.luminance())
+ val l2 = min(color1.luminance(), color2.luminance())
+ return (l1 + 0.05f) / (l2 + 0.05f)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReaderThemePanel(
+ isVisible: Boolean,
+ currentThemeId: String,
+ customThemes: List,
+ builtInThemes: List = BuiltInThemes,
+ onThemeSelected: (String) -> Unit,
+ onCustomThemesUpdated: (List) -> Unit,
+ onDismiss: () -> Unit
+) {
+ if (!isVisible) return
+ var showBuilder by remember { mutableStateOf(false) }
+ var editingTheme by remember { mutableStateOf(null) }
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentWindowInsets = { WindowInsets.navigationBars }
+ ) {
+ AnimatedContent(targetState = showBuilder, label = "ThemePanelTransition") { isBuilding ->
+ if (isBuilding) {
+ ThemeBuilderView(
+ initialTheme = editingTheme,
+ onSave = { newTheme ->
+ val updatedList = if (editingTheme != null) {
+ customThemes.map { if (it.id == newTheme.id) newTheme else it }
+ } else {
+ customThemes + newTheme
+ }
+ onCustomThemesUpdated(updatedList)
+ onThemeSelected(newTheme.id)
+ showBuilder = false
+ editingTheme = null
+ },
+ onCancel = {
+ showBuilder = false
+ editingTheme = null
+ }
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.65f)
+ .padding(16.dp)
+ .padding(bottom = 16.dp)
+ ) {
+ Text(
+ "Reading Themes",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text("Presets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
+ Spacer(Modifier.height(8.dp))
+ ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
+
+ Spacer(Modifier.height(24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
+ IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary)
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+
+ if (customThemes.isEmpty()) {
+ Text("No custom themes yet. Tap '+' to create one.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ ThemeGrid(
+ themes = customThemes,
+ currentThemeId = currentThemeId,
+ onThemeSelected = onThemeSelected,
+ onEdit = { editingTheme = it; showBuilder = true },
+ onDelete = { themeToDelete ->
+ val updated = customThemes.filter { it.id != themeToDelete.id }
+ onCustomThemesUpdated(updated)
+ if (currentThemeId == themeToDelete.id) onThemeSelected("system")
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ThemeGrid(
+ themes: List,
+ currentThemeId: String,
+ onThemeSelected: (String) -> Unit,
+ onEdit: ((ReaderTheme) -> Unit)? = null,
+ onDelete: ((ReaderTheme) -> Unit)? = null
+) {
+ androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
+ columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(themes.size) { index ->
+ val theme = themes[index]
+ val isSelected = currentThemeId == theme.id
+ val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor
+ val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor
+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(bgColor, CircleShape)
+ .border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape)
+ .clickable { onThemeSelected(theme.id) },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
+
+ if (theme.isCustom && onEdit != null && onDelete != null) {
+ Spacer(modifier = Modifier.height(6.dp))
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
+ Spacer(Modifier.width(4.dp))
+ Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ThemeBuilderView(
+ initialTheme: ReaderTheme?,
+ onSave: (ReaderTheme) -> Unit,
+ onCancel: () -> Unit
+) {
+ var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") }
+ var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
+ var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
+ var textureId by remember { mutableStateOf(initialTheme?.textureId) }
+
+ var editingColorType by remember { mutableStateOf(null) }
+
+ val contrast = calculateContrastRatio(bgColor, txtColor)
+ val isDark = bgColor.luminance() < 0.5f
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.85f)
+ .padding(16.dp)
+ ) {
+ Text(
+ text = if (initialTheme == null) "New Theme" else "Edit Theme",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) {
+ 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)
+ ) {
+ 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)
+ )
+ }
+ Spacer(Modifier.height(16.dp))
+ }
+
+ // Action Buttons at the bottom for better visibility
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(onClick = onCancel) {
+ Text("Cancel", color = MaterialTheme.colorScheme.primary)
+ }
+ Spacer(Modifier.width(8.dp))
+ Button(onClick = {
+ onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true))
+ }) {
+ Text("Save", color = MaterialTheme.colorScheme.onPrimary)
+ }
+ }
+ }
+
+ 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.9f)
+ .padding(16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(20.dp)
+ .verticalScroll(rememberScrollState()), // Prevents elements from hiding off-screen
+ 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
+ )
+ ) {
+ Text("Save", color = Color.Black, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) {
+ Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run {
+ if (resId != null) {
+ val bmp = ImageBitmap.imageResource(LocalResources.current, resId)
+ this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) }
+ } else this.background(MaterialTheme.colorScheme.surfaceVariant)
+ })
+ Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
+ }
+}
+
+@Composable
+fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f))
+ Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f))
+ Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f))
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index ea40909..81a79b0 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -81,6 +81,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
+import com.aryan.reader.ReaderTexture
import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index c0083d3..3ac9c1d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -29,11 +29,6 @@ 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 android.net.Uri
@@ -53,7 +48,6 @@ 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
@@ -62,11 +56,9 @@ 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
@@ -79,35 +71,26 @@ 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
@@ -133,32 +116,19 @@ 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
@@ -170,10 +140,11 @@ import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
+import com.aryan.reader.BuiltInThemes
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
-import com.aryan.reader.R
+import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
@@ -183,6 +154,8 @@ import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchAiDefinition
+import com.aryan.reader.loadCustomThemes
+import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
@@ -197,6 +170,8 @@ import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
+import com.aryan.reader.saveCustomThemes
+import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
@@ -366,93 +341,8 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
-enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) {
- PAPER("paper", R.drawable.texture_paper, "Paper"),
- CANVAS("canvas", R.drawable.texture_canvas, "Canvas"),
- EINK("eink", R.drawable.texture_eink, "E-Ink"),
- SLATE("slate", R.drawable.texture_slate, "Slate")
-}
-
-data class ReaderTheme(
- val id: String,
- val name: String,
- val backgroundColor: Color,
- val textColor: Color,
- val isDark: Boolean,
- val textureId: String? = null,
- val isCustom: Boolean = false
-)
-
-val BuiltInThemes = listOf(
- ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
- ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
- ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
- ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
- ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
- ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
-)
-
-private const val PREF_READER_THEME = "reader_theme_id"
-private const val PREF_CUSTOM_THEMES = "custom_themes_json"
-
-private fun saveReaderThemeId(context: Context, themeId: String) {
- val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- prefs.edit { putString(PREF_READER_THEME, themeId) }
-}
-
-private fun loadReaderThemeId(context: Context): String {
- val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- return prefs.getString(PREF_READER_THEME, "system") ?: "system"
-}
-
-private fun saveCustomThemes(context: Context, themes: List) {
- val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- val jsonArray = JSONArray()
- themes.filter { it.isCustom }.forEach { theme ->
- val obj = JSONObject().apply {
- put("id", theme.id)
- put("name", theme.name)
- put("bgColor", theme.backgroundColor.toArgb())
- put("textColor", theme.textColor.toArgb())
- put("isDark", theme.isDark)
- theme.textureId?.let { put("textureId", it) }
- }
- jsonArray.put(obj)
- }
- prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) }
-}
-
-private fun loadCustomThemes(context: Context): List {
- val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
- val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]"
- val themes = mutableListOf()
- try {
- val jsonArray = org.json.JSONArray(jsonString)
- for (i in 0 until jsonArray.length()) {
- val obj = jsonArray.getJSONObject(i)
- themes.add(
- ReaderTheme(
- id = obj.getString("id"),
- name = obj.getString("name"),
- backgroundColor = Color(obj.getInt("bgColor")),
- textColor = Color(obj.getInt("textColor")),
- isDark = obj.getBoolean("isDark"),
- textureId = if (obj.has("textureId")) obj.getString("textureId") else null,
- isCustom = true
- )
- )
- }
- } catch (e: Exception) {
- Timber.e(e, "Failed to parse custom themes")
- }
- return themes
-}
-
-private fun calculateContrastRatio(color1: Color, color2: Color): Float {
- val l1 = max(color1.luminance(), color2.luminance())
- val l2 = min(color1.luminance(), color2.luminance())
- return (l1 + 0.05f) / (l2 + 0.05f)
-}
+const val PREF_READER_THEME = "reader_theme_id"
+const val PREF_CUSTOM_THEMES = "custom_themes_json"
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
@@ -4038,500 +3928,4 @@ fun EpubReaderHost(
)
}
}
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ReaderThemePanel(
- isVisible: Boolean,
- currentThemeId: String,
- customThemes: List,
- onThemeSelected: (String) -> Unit,
- onCustomThemesUpdated: (List) -> Unit,
- onDismiss: () -> Unit
-) {
- if (!isVisible) return
- var showBuilder by remember { mutableStateOf(false) }
- var editingTheme by remember { mutableStateOf(null) }
-
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- containerColor = MaterialTheme.colorScheme.surface,
- contentWindowInsets = { WindowInsets.navigationBars }
- ) {
- AnimatedContent(targetState = showBuilder, label = "ThemePanelTransition") { isBuilding ->
- if (isBuilding) {
- ThemeBuilderView(
- initialTheme = editingTheme,
- onSave = { newTheme ->
- val updatedList = if (editingTheme != null) {
- customThemes.map { if (it.id == newTheme.id) newTheme else it }
- } else {
- customThemes + newTheme
- }
- onCustomThemesUpdated(updatedList)
- onThemeSelected(newTheme.id)
- showBuilder = false
- editingTheme = null
- },
- onCancel = {
- showBuilder = false
- editingTheme = null
- }
- )
- } else {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .fillMaxHeight(0.65f)
- .padding(16.dp)
- .padding(bottom = 16.dp)
- ) {
- Text(
- "Reading Themes",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(bottom = 16.dp)
- )
-
- Text("Presets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.height(8.dp))
- ThemeGrid(themes = BuiltInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
-
- Spacer(Modifier.height(24.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
- IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) {
- Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary)
- }
- }
- Spacer(Modifier.height(8.dp))
-
- if (customThemes.isEmpty()) {
- Text("No custom themes yet. Tap '+' to create one.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- } else {
- ThemeGrid(
- themes = customThemes,
- currentThemeId = currentThemeId,
- onThemeSelected = onThemeSelected,
- onEdit = { editingTheme = it; showBuilder = true },
- onDelete = { themeToDelete ->
- val updated = customThemes.filter { it.id != themeToDelete.id }
- onCustomThemesUpdated(updated)
- if (currentThemeId == themeToDelete.id) onThemeSelected("system")
- }
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-fun ThemeGrid(
- themes: List,
- currentThemeId: String,
- onThemeSelected: (String) -> Unit,
- onEdit: ((ReaderTheme) -> Unit)? = null,
- onDelete: ((ReaderTheme) -> Unit)? = null
-) {
- androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
- columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- items(themes.size) { index ->
- val theme = themes[index]
- val isSelected = currentThemeId == theme.id
- val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor
- val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor
- val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
-
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Box(
- modifier = Modifier
- .size(56.dp)
- .background(bgColor, CircleShape)
- .border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape)
- .clickable { onThemeSelected(theme.id) },
- contentAlignment = Alignment.Center
- ) {
- Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
-
- if (theme.isCustom && onEdit != null && onDelete != null) {
- Spacer(modifier = Modifier.height(6.dp))
- Surface(
- shape = RoundedCornerShape(16.dp),
- color = MaterialTheme.colorScheme.surfaceVariant,
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.width(4.dp))
- Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-fun ThemeBuilderView(
- initialTheme: ReaderTheme?,
- onSave: (ReaderTheme) -> Unit,
- onCancel: () -> Unit
-) {
- var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") }
- var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
- var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
- var textureId by remember { mutableStateOf(initialTheme?.textureId) }
-
- var editingColorType by remember { mutableStateOf(null) }
-
- val contrast = calculateContrastRatio(bgColor, txtColor)
- val isDark = bgColor.luminance() < 0.5f
-
- Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.65f).padding(16.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- TextButton(onClick = onCancel) { Text("Cancel") }
- Text(if (initialTheme == null) "New Theme" else "Edit Theme", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
- TextButton(onClick = {
- onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true))
- }) { Text("Save") }
- }
-
- androidx.compose.material3.OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- label = { Text("Theme Name") },
- modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
- singleLine = true
- )
-
- // Live Preview Card
- Surface(
- modifier = Modifier.fillMaxWidth().height(120.dp).padding(vertical = 8.dp),
- shape = RoundedCornerShape(12.dp),
- color = bgColor,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
- ) {
- // Draw Texture if selected (kept logic for future)
- val context = LocalContext.current
- Box(modifier = Modifier.fillMaxSize().run {
- val texRes = ReaderTexture.entries.find { it.id == textureId }?.resId
- if (texRes != null) {
- val bmp = ImageBitmap.imageResource(context.resources, texRes)
- this.drawBehind {
- drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.Multiply, alpha = 0.5f)
- }
- } else this
- }) {
- Column(Modifier.padding(16.dp).fillMaxWidth()) {
- Text(
- text = "So many books, so little time.",
- color = txtColor,
- style = MaterialTheme.typography.titleMedium
- )
- Spacer(Modifier.height(8.dp))
- Text(
- text = "- Frank Zappa",
- color = txtColor,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.End
- )
- }
- }
- }
-
- // Animated Contrast Warning
- AnimatedVisibility(visible = contrast < 4.5f) {
- Text(
- "⚠️ Low contrast! This might cause eye strain.",
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.labelSmall,
- modifier = Modifier.padding(bottom = 8.dp)
- )
- }
-
- Spacer(Modifier.height(16.dp))
-
- // Sleek Color Swatches
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- ColorSwatchItem(
- label = "Page Color",
- color = bgColor,
- onClick = { editingColorType = "bg" },
- modifier = Modifier.weight(1f)
- )
- ColorSwatchItem(
- label = "Text Color",
- color = txtColor,
- onClick = { editingColorType = "text" },
- modifier = Modifier.weight(1f)
- )
- }
-
- // Texture UI hidden for now
- /*
- Spacer(Modifier.height(16.dp))
- Text("Texture", style = MaterialTheme.typography.labelMedium)
- Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- TextureOption("None", null, textureId == null) { textureId = null }
- ReaderTexture.entries.forEach { tex ->
- TextureOption(tex.displayName, tex.resId, textureId == tex.id) { textureId = tex.id }
- }
- }
- */
- Spacer(Modifier.height(16.dp))
- }
-
- editingColorType?.let { type ->
- ThemeColorPickerDialog(
- initialColor = if (type == "bg") bgColor else txtColor,
- title = if (type == "bg") "Page Color" else "Text Color",
- bgColor = bgColor,
- textColor = txtColor,
- editingColorType = type,
- onDismiss = { editingColorType = null },
- onColorChanged = { newColor ->
- if (type == "bg") bgColor = newColor else txtColor = newColor
- }
- )
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ColorSwatchItem(label: String, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier) {
- Column(modifier = modifier) {
- Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
- Surface(
- onClick = onClick,
- shape = RoundedCornerShape(12.dp),
- color = color,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
- modifier = Modifier.fillMaxWidth().height(56.dp)
- ) {}
- }
-}
-
-@Composable
-fun ThemeColorPickerDialog(
- initialColor: Color,
- title: String,
- bgColor: Color,
- textColor: Color,
- editingColorType: String,
- onDismiss: () -> Unit,
- onColorChanged: (Color) -> Unit
-) {
- val initialHsv = remember(initialColor) {
- val hsv = FloatArray(3)
- android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
- hsv
- }
-
- var hue by remember { mutableFloatStateOf(initialHsv[0]) }
- var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
- var value by remember { mutableFloatStateOf(initialHsv[2]) }
-
- val currentColor by remember {
- derivedStateOf {
- val hsv = floatArrayOf(hue, saturation, value)
- val argb = android.graphics.Color.HSVToColor(255, hsv)
- Color(argb)
- }
- }
-
- LaunchedEffect(currentColor) {
- onColorChanged(currentColor)
- }
-
- fun updateFromColor(color: Color) {
- val hsv = FloatArray(3)
- android.graphics.Color.colorToHSV(color.toArgb(), hsv)
- hue = hsv[0]
- saturation = hsv[1]
- value = hsv[2]
- }
-
- androidx.compose.ui.window.Dialog(
- onDismissRequest = onDismiss,
- properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
- ) {
- Surface(
- shape = RoundedCornerShape(24.dp),
- color = Color(0xFF2C2C2C),
- modifier = Modifier
- .fillMaxWidth(0.85f)
- .padding(8.dp)
- ) {
- Column(
- modifier = Modifier.padding(20.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Box(
- modifier = Modifier
- .background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
- .padding(horizontal = 24.dp, vertical = 8.dp)
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = Color.White
- )
- }
-
- Spacer(Modifier.height(16.dp))
-
- val liveBgColor = if (editingColorType == "bg") currentColor else bgColor
- val liveTextColor = if (editingColorType == "text") currentColor else textColor
-
- Surface(
- modifier = Modifier.fillMaxWidth().height(64.dp),
- shape = RoundedCornerShape(12.dp),
- color = liveBgColor,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
- ) {
- Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = "Live Preview",
- color = liveTextColor,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = "Reading is dreaming.",
- color = liveTextColor,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
-
- Spacer(Modifier.height(20.dp))
-
- SpectrumBox(
- hue = hue,
- saturation = saturation,
- currentColor = currentColor,
- onHueSatChanged = { h, s -> hue = h; saturation = s },
- modifier = Modifier.fillMaxWidth().height(220.dp)
- )
-
- Spacer(Modifier.height(20.dp))
-
- BrightnessSlider(
- hue = hue,
- saturation = saturation,
- value = value,
- onValueChanged = { value = it },
- modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
- )
-
- Spacer(Modifier.height(24.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.Bottom,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- ColorComparePill(
- oldColor = initialColor,
- newColor = currentColor,
- modifier = Modifier.width(64.dp).height(36.dp)
- )
-
- Column(
- modifier = Modifier.weight(1.6f),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
- Spacer(Modifier.height(4.dp))
- HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
- }
-
- Row(
- modifier = Modifier.weight(2.4f),
- horizontalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- RgbInputColumn(
- label = "R", value = currentColor.red,
- onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
- modifier = Modifier.weight(1f)
- )
- RgbInputColumn(
- label = "G", value = currentColor.green,
- onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
- modifier = Modifier.weight(1f)
- )
- RgbInputColumn(
- label = "B", value = currentColor.blue,
- onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
- modifier = Modifier.weight(1f)
- )
- }
- }
-
- Spacer(Modifier.height(24.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Button(
- onClick = onDismiss,
- colors = androidx.compose.material3.ButtonDefaults.buttonColors(
- containerColor = Color.White,
- contentColor = Color.Black
- )
- ) {
- Text("Done")
- }
- }
- }
- }
- }
-}
-
-@Composable
-fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) {
- Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) {
- Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run {
- if (resId != null) {
- val bmp = ImageBitmap.imageResource(LocalResources.current, resId)
- this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) }
- } else this.background(MaterialTheme.colorScheme.surfaceVariant)
- })
- Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
- }
-}
-
-@Composable
-fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f))
- Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f))
- Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f))
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
index 2de4398..2feb3a5 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
@@ -158,6 +158,7 @@ import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest.Builder
import com.aryan.reader.R
+import com.aryan.reader.ReaderTexture
import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor
@@ -529,7 +530,7 @@ fun PaginatedReaderScreen(
val context = LocalContext.current
val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id ->
- com.aryan.reader.epubreader.ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
+ ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId)
}
}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
index 4c9f00d..6e5b223 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
@@ -407,7 +407,7 @@ internal fun PdfPageComposable(
clearSelectionTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
- isDarkMode: Boolean = false,
+ activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
@@ -534,41 +534,55 @@ internal fun PdfPageComposable(
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
- val colorFilter = remember(isDarkMode) {
- if (isDarkMode) {
- val colorMatrix = floatArrayOf(
- -1f,
- 0f,
- 0f,
- 0f,
- 255f,
- 0f,
- -1f,
- 0f,
- 0f,
- 255f,
- 0f,
- 0f,
- -1f,
- 0f,
- 255f,
- 0f,
- 0f,
- 0f,
- 1f,
- 0f
- )
- ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
- } else {
- null
+ val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
+
+ val colorFilter = remember(activeTheme) {
+ when (activeTheme.id) {
+ "no_theme", "system" -> null
+ "reverse" -> {
+ val colorMatrix = floatArrayOf(
+ -1f, 0f, 0f, 0f, 255f,
+ 0f, -1f, 0f, 0f, 255f,
+ 0f, 0f, -1f, 0f, 255f,
+ 0f, 0f, 0f, 1f, 0f
+ )
+ ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
+ }
+ else -> {
+ val bgR = activeTheme.backgroundColor.red * 255f
+ val bgG = activeTheme.backgroundColor.green * 255f
+ val bgB = activeTheme.backgroundColor.blue * 255f
+
+ val fgR = activeTheme.textColor.red * 255f
+ val fgG = activeTheme.textColor.green * 255f
+ val fgB = activeTheme.textColor.blue * 255f
+
+ val dr = (bgR - fgR) / 255f
+ val dg = (bgG - fgG) / 255f
+ val db = (bgB - fgB) / 255f
+
+ val lumR = 0.2126f
+ val lumG = 0.7152f
+ val lumB = 0.0722f
+
+ val colorMatrix = floatArrayOf(
+ dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
+ dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
+ db * lumR, db * lumG, db * lumB, 0f, fgB,
+ 0f, 0f, 0f, 1f, 0f
+ )
+ ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
+ }
}
}
- val backgroundColor = remember(isDarkMode, isVerticalScroll) {
- if (isDarkMode) {
- Color(0xFF2A2A2A)
- } else {
+ val backgroundColor = remember(activeTheme, isVerticalScroll) {
+ if (activeTheme.id == "no_theme" || activeTheme.id == "system") {
if (isVerticalScroll) Color.White else Color.Black
+ } else if (activeTheme.id == "reverse") {
+ if (isVerticalScroll) Color.Black else Color.White
+ } else {
+ activeTheme.backgroundColor
}
}
@@ -1628,10 +1642,6 @@ internal fun PdfPageComposable(
}
}
- if (selectedTool == InkType.ERASER) {
- eraserPosition = down.position
- }
-
if (dragStartedOnHandle) {
Timber.d(
"PointerInput: Drag started on handle $activeDraggingHandle"
@@ -3091,8 +3101,12 @@ internal fun PdfPageComposable(
}
LaunchedEffect(
- this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, pageIndex
+ this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight
) {
+ scale = 1f
+ offset = Offset.Zero
+ onScaleChanged(1f)
+
Timber.d(
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
)
@@ -3861,7 +3875,6 @@ private fun PdfBitmapLayer(
.fillMaxSize()
.graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
- // THIS is the fix: Hard clip to the target bounds so edge tiles can't bleed out.
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
@@ -3869,17 +3882,16 @@ private fun PdfBitmapLayer(
val srcSize = IntSize(bitmapState.width, bitmapState.height)
val dstSize = IntSize(dstW, dstH)
- // 1. Draw Base Bitmap
drawImage(
image = bitmapState.asImageBitmap(),
srcOffset = IntOffset.Zero,
srcSize = srcSize,
dstOffset = IntOffset.Zero,
dstSize = dstSize,
- colorFilter = colorFilter
+ colorFilter = colorFilter,
+ filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
- // 2. Draw High-Res Tiles
if (effectiveScale > 1f) {
tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) {
@@ -3891,7 +3903,8 @@ private fun PdfBitmapLayer(
dstSize = IntSize(
tile.renderRect.width(), tile.renderRect.height()
),
- colorFilter = colorFilter
+ colorFilter = colorFilter,
+ filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
}
}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
index 1f15f47..5fe4047 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
@@ -111,7 +111,6 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
-import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -177,7 +176,7 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
internal fun PdfVerticalReader(
state: VerticalPdfReaderState,
pdfDocument: StableHolder,
- isDarkMode: Boolean,
+ activeTheme: com.aryan.reader.ReaderTheme,
totalPages: Int,
modifier: Modifier = Modifier,
virtualPages: List = emptyList(),
@@ -243,6 +242,7 @@ internal fun PdfVerticalReader(
}
}
var globalEraserPosition by remember { mutableStateOf(null) }
+ val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime
val density = LocalDensity.current
@@ -1497,7 +1497,7 @@ internal fun PdfVerticalReader(
pageIndex = page.index,
virtualPage = virtualPage,
totalPages = totalPages,
- isDarkMode = isDarkMode,
+ activeTheme = activeTheme,
externalScale = highResScale,
onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights,
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
index afaa385..27ef2cc 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
@@ -259,6 +259,7 @@ import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
+import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
@@ -270,6 +271,7 @@ import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.fetchAiDefinition
+import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@@ -281,6 +283,7 @@ import com.aryan.reader.pdf.data.SmartSearchResult
import com.aryan.reader.pdf.data.TextStyleConfig
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
+import com.aryan.reader.saveCustomThemes
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
@@ -343,6 +346,27 @@ private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
+private const val PDF_THEME_KEY = "pdf_reader_theme"
+
+private fun savePdfThemeId(context: Context, themeId: String) {
+ val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit { putString(PDF_THEME_KEY, themeId) }
+}
+
+private fun loadPdfThemeId(context: Context): String {
+ val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ return prefs.getString(PDF_THEME_KEY, "no_theme") ?: "no_theme"
+}
+
+val PdfBuiltInThemes = listOf(
+ com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
+ com.aryan.reader.ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
+ com.aryan.reader.ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
+ com.aryan.reader.ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
+ com.aryan.reader.ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
+ com.aryan.reader.ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
+ com.aryan.reader.ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
+)
object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy {
@@ -1065,7 +1089,16 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
- var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) }
+ var showThemePanel by remember { mutableStateOf(false) }
+ var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
+ var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
+
+ val activeTheme = remember(currentThemeId, customThemes) {
+ PdfBuiltInThemes.find { it.id == currentThemeId }
+ ?: customThemes.find { it.id == currentThemeId }
+ ?: PdfBuiltInThemes[0]
+ }
+ val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
var pageAspectRatios by remember { mutableStateOf>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) }
var isFullScreen by remember { mutableStateOf(false) }
@@ -1364,7 +1397,6 @@ fun PdfViewerScreen(
LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) }
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
- LaunchedEffect(isPdfDarkMode) { savePdfDarkMode(context, isPdfDarkMode) }
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState()
@@ -2724,21 +2756,24 @@ fun PdfViewerScreen(
): Boolean {
if (annotation.points.isEmpty()) return false
+ val effectiveThreshold = threshold + (annotation.strokeWidth / 2f)
+ val thresholdSq = effectiveThreshold * effectiveThreshold
+
if (annotation.points.size == 1) {
val p = annotation.points[0]
- val dx = (p.x - hitPoint.x) * pageAspectRatio
- val dy = (p.y - hitPoint.y)
- return (dx * dx + dy * dy) < (threshold * threshold)
+ val dx = (p.x - hitPoint.x)
+ val dy = (p.y - hitPoint.y) / pageAspectRatio
+ return (dx * dx + dy * dy) < thresholdSq
}
for (i in 0 until annotation.points.size - 1) {
val a = annotation.points[i]
val b = annotation.points[i + 1]
- val pax = (hitPoint.x - a.x) * pageAspectRatio
- val pay = (hitPoint.y - a.y)
- val bax = (b.x - a.x) * pageAspectRatio
- val bay = (b.y - a.y)
+ val pax = (hitPoint.x - a.x)
+ val pay = (hitPoint.y - a.y) / pageAspectRatio
+ val bax = (b.x - a.x)
+ val bay = (b.y - a.y) / pageAspectRatio
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
val t = (pax * bax + pay * bay) / segmentLenSq
@@ -2749,7 +2784,7 @@ fun PdfViewerScreen(
val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY)
- if (distSq < (threshold * threshold)) return true
+ if (distSq < thresholdSq) return true
}
return false
@@ -3580,6 +3615,7 @@ fun PdfViewerScreen(
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
+ showThemePanel -> showThemePanel = false
else -> {
saveStateAndExit()
@@ -4257,7 +4293,7 @@ fun PdfViewerScreen(
pageIndex = pageIndex,
virtualPage = virtualPage,
totalPages = totalDisplayPages,
- isDarkMode = isPdfDarkMode,
+ activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
onScaleChanged = { newScale ->
if (pagerState.currentPage == pageIndex) {
@@ -4636,7 +4672,7 @@ fun PdfViewerScreen(
PdfVerticalReader(
state = verticalReaderState,
pdfDocument = docHolder,
- isDarkMode = isPdfDarkMode,
+ activeTheme = activeTheme,
isScrollLocked = isScrollLocked,
totalPages = totalDisplayPages,
pageAspectRatios = ratiosHolder,
@@ -5223,22 +5259,14 @@ fun PdfViewerScreen(
)
TooltipIconButton(
- text = if (isPdfDarkMode)
- stringResource(R.string.tooltip_dark_mode_off)
- else
- stringResource(R.string.tooltip_dark_mode_on),
- description = if (isPdfDarkMode)
- stringResource(R.string.tooltip_dark_mode_off_desc)
- else
- stringResource(R.string.tooltip_dark_mode_on_desc),
- onClick = { isPdfDarkMode = !isPdfDarkMode }
+ text = "Theme",
+ description = "Theme Settings",
+ onClick = { showThemePanel = true }
) {
Icon(
- painter = painterResource(id = R.drawable.dark_mode),
- contentDescription = if (isPdfDarkMode) "Disable Dark Mode"
- else "Enable Dark Mode",
- tint = if (isPdfDarkMode) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.onSurfaceVariant
+ painter = painterResource(id = R.drawable.palette),
+ contentDescription = "Theme Settings",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -6926,6 +6954,25 @@ fun PdfViewerScreen(
)
}
+ if (showThemePanel) {
+ ReaderThemePanel(
+ isVisible = true,
+ currentThemeId = currentThemeId,
+ builtInThemes = PdfBuiltInThemes,
+ onThemeSelected = {
+ currentThemeId = it
+ savePdfThemeId(context, it)
+ showThemePanel = false
+ },
+ onDismiss = { showThemePanel = false },
+ customThemes = customThemes,
+ onCustomThemesUpdated = {
+ customThemes = it
+ saveCustomThemes(context, it)
+ }
+ )
+ }
+
if (clickedLinkUrl != null) {
val url = clickedLinkUrl!!
AlertDialog(
diff --git a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
index 1591b7f..76329d3 100644
--- a/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
@@ -128,7 +128,7 @@ fun ToolSettingsPopup(
val thicknessRange = when {
isHighlighter -> 0.01f..0.06f
- isEraser -> 0.01f..0.1f
+ isEraser -> 0.002f..0.1f
else -> 0.001f..0.015f
}
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
@@ -170,9 +170,9 @@ fun ToolSettingsPopup(
.height(125.dp),
contentAlignment = Alignment.Center
) {
- val radiusDp = (activeToolThickness * 1000).coerceIn(10f, 100f).dp
+ val diameterDp = (activeToolThickness * 800).coerceIn(4f, 150f).dp
Box(
- modifier = Modifier.size(radiusDp),
+ modifier = Modifier.size(diameterDp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
index ef64deb..1d1cb06 100644
--- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
@@ -219,8 +219,7 @@ class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
tempEntries.add(Pair(path, extractedFile))
var pfd: android.os.ParcelFileDescriptor? = null
- try {
- // Extract seamlessly using fd to avoid ByteBuffer's state sync bug
+ @Suppress("ConvertTryFinallyToUseCall") try {
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
Archive.readDataIntoFd(archive, pfd.fd)
} finally {
@@ -231,7 +230,7 @@ class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
}
}
- tempEntries.sortBy { it.first } // Natural sorting order based on the filename inside the archive
+ tempEntries.sortBy { it.first }
tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
} catch (e: Exception) {
@@ -306,15 +305,42 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
val scaleX = drawSizeX.toFloat() / originalWidth
val scaleY = drawSizeY.toFloat() / originalHeight
- val srcLeft = (-startX / scaleX).toInt().coerceAtLeast(0)
- val srcTop = (-startY / scaleY).toInt().coerceAtLeast(0)
- val srcRight = (srcLeft + (bitmap.width / scaleX).toInt()).coerceAtMost(originalWidth)
- val srcBottom = (srcTop + (bitmap.height / scaleY).toInt()).coerceAtMost(originalHeight)
+ val pageOffsetX = -startX.toFloat()
+ val pageOffsetY = -startY.toFloat()
+
+ val exactSrcLeft = pageOffsetX / scaleX
+ val exactSrcTop = pageOffsetY / scaleY
+ val exactSrcRight = (pageOffsetX + bitmap.width) / scaleX
+ val exactSrcBottom = (pageOffsetY + bitmap.height) / scaleY
+
+ val srcLeft = kotlin.math.floor(exactSrcLeft).toInt().coerceAtLeast(0)
+ val srcTop = kotlin.math.floor(exactSrcTop).toInt().coerceAtLeast(0)
+ val srcRight = kotlin.math.ceil(exactSrcRight).toInt().coerceAtMost(originalWidth)
+ val srcBottom = kotlin.math.ceil(exactSrcBottom).toInt().coerceAtMost(originalHeight)
val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
if (rect.width() <= 0 || rect.height() <= 0) return
- val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
+ val options = BitmapFactory.Options().apply {
+ inPreferredConfig = Bitmap.Config.ARGB_8888
+ inScaled = false
+ @Suppress("DEPRECATION")
+ inDither = true
+
+ var sampleSize = 1
+ val srcWidth = rect.width()
+ val srcHeight = rect.height()
+
+ if (srcHeight > bitmap.height || srcWidth > bitmap.width) {
+ val halfHeight = srcHeight / 2
+ val halfWidth = srcWidth / 2
+ while (halfHeight / sampleSize >= bitmap.height && halfWidth / sampleSize >= bitmap.width) {
+ sampleSize *= 2
+ }
+ }
+ inSampleSize = sampleSize
+ }
+
val region = try {
decoder.decodeRegion(rect, options)
} catch (_: Exception) {
@@ -323,8 +349,17 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
if (region != null) {
val canvas = Canvas(bitmap)
- val destRect = Rect(0, 0, bitmap.width, bitmap.height)
- canvas.drawBitmap(region, null, destRect, Paint(Paint.FILTER_BITMAP_FLAG))
+
+ val destLeft = (srcLeft * scaleX) - pageOffsetX
+ val destTop = (srcTop * scaleY) - pageOffsetY
+ val destRight = (srcRight * scaleX) - pageOffsetX
+ val destBottom = (srcBottom * scaleY) - pageOffsetY
+
+ val destRect = RectF(destLeft, destTop, destRight, destBottom)
+
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG)
+
+ canvas.drawBitmap(region, null, destRect, paint)
region.recycle()
}
}