Pdf reader improvments (#113)

* Improve PDF rendering quality and performance.

- Set `FilterQuality.High` when drawing base bitmaps and high-res tiles in `PdfPageComposable`.
- Implement `inSampleSize` calculation in `UniversalDocument` to optimize memory usage during region decoding.
- Enhance bitmap drawing quality by adding `ANTI_ALIAS_FLAG` and `DITHER_FLAG` to the paint configuration.

* Implement custom reader themes for PDF viewer.

This change integrates the `ReaderThemePanel` and theme management logic into the PDF viewer, allowing users to apply preset or custom themes. Previously, the PDF viewer only supported a simple dark mode toggle.

Specific changes:
- Moved `ReaderTheme`, `ReaderTexture`, and theme-related utility functions from `EpubReaderScreen.kt` to `Common.kt` to allow sharing between EPUB and PDF readers.
- Updated `PdfPageComposable` to use `activeTheme` instead of `isDarkMode`, implementing a `ColorMatrix` to apply custom background and text colors to PDF pages.
- Replaced the dark mode toggle in `PdfViewerScreen` with a theme selection button that opens `ReaderThemePanel`.
- Introduced `PdfBuiltInThemes` to provide PDF-specific theme presets.

* Reset page transformation on constraint changes and refine tile rendering logic.

- Reset scale to 1.0 and offset to zero in `PdfPageComposable` when constraints change.
- Update `UniversalDocument` to use floating-point precision for source rectangle calculations and `RectF` for destination mapping to improve rendering accuracy.

* Improve eraser hit detection and visual thickness settings in PDF reader.
This commit is contained in:
Aryan 2026-03-26 13:05:50 +05:30 committed by GitHub
parent 4db2c97a30
commit 60dacf9c12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 834 additions and 741 deletions

View file

@ -1,48 +1,27 @@
/* // Common.kt
* 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 <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
@file:kotlin.OptIn(ExperimentalMaterial3Api::class) @file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader package com.aryan.reader
import android.content.Context 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.background
import androidx.compose.foundation.border 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.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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import 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.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -59,6 +38,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@ -66,16 +46,20 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy 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.GraphicEq
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -89,11 +73,18 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -101,6 +92,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -109,20 +101,29 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString 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.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.media3.common.util.UnstableApi 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.paginatedreader.TtsChunk
import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
@ -166,13 +171,14 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text import org.commonmark.node.Text
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import androidx.core.content.edit import kotlin.math.max
import androidx.core.graphics.toColorInt import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val aiServerBasePath = BuildConfig.AI_WORKER_URL
@ -270,6 +276,7 @@ fun rememberSearchState(
private val activeTooltipState = mutableStateOf<androidx.compose.material3.TooltipState?>(null) private val activeTooltipState = mutableStateOf<androidx.compose.material3.TooltipState?>(null)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TooltipIconButton( fun TooltipIconButton(
text: String, text: String,
@ -488,7 +495,7 @@ fun SearchNavigationControls(
} }
} }
@OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun SummarizationPopup( fun SummarizationPopup(
title: String, title: String,
@ -657,7 +664,7 @@ fun SummarizationPopup(
} }
} }
@OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun AiDefinitionPopup( fun AiDefinitionPopup(
word: String?, word: String?,
@ -1151,7 +1158,7 @@ suspend fun fetchRecap(
} }
} }
@OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@Composable @Composable
fun TtsSettingsSheet( fun TtsSettingsSheet(
isVisible: Boolean, isVisible: Boolean,
@ -2015,3 +2022,598 @@ fun ColorComparePill(
) )
} }
} }
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<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()) }
}
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)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderThemePanel(
isVisible: Boolean,
currentThemeId: String,
customThemes: List<ReaderTheme>,
builtInThemes: List<ReaderTheme> = BuiltInThemes,
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.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))
}
}

View file

@ -81,6 +81,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.ReaderTexture
import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -29,11 +29,6 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import com.aryan.reader.SpectrumBox
import com.aryan.reader.BrightnessSlider
import com.aryan.reader.ColorComparePill
import com.aryan.reader.HexInput
import com.aryan.reader.RgbInputColumn
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
@ -53,7 +48,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -62,11 +56,9 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
@ -79,35 +71,26 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
@ -133,32 +116,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -170,10 +140,11 @@ import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BannerMessage import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.CustomTopBanner import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
@ -183,6 +154,8 @@ import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchAiDefinition 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.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator 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.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState 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.SpeakerSamplePlayer
import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController 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) } prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
} }
enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) { const val PREF_READER_THEME = "reader_theme_id"
PAPER("paper", R.drawable.texture_paper, "Paper"), const val PREF_CUSTOM_THEMES = "custom_themes_json"
CANVAS("canvas", R.drawable.texture_canvas, "Canvas"),
EINK("eink", R.drawable.texture_eink, "E-Ink"),
SLATE("slate", R.drawable.texture_slate, "Slate")
}
data class ReaderTheme(
val id: String,
val name: String,
val backgroundColor: Color,
val textColor: Color,
val isDark: Boolean,
val textureId: String? = null,
val isCustom: Boolean = false
)
val BuiltInThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
)
private const val PREF_READER_THEME = "reader_theme_id"
private const val PREF_CUSTOM_THEMES = "custom_themes_json"
private fun saveReaderThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_READER_THEME, themeId) }
}
private fun loadReaderThemeId(context: Context): String {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
}
private fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonArray = JSONArray()
themes.filter { it.isCustom }.forEach { theme ->
val obj = JSONObject().apply {
put("id", theme.id)
put("name", theme.name)
put("bgColor", theme.backgroundColor.toArgb())
put("textColor", theme.textColor.toArgb())
put("isDark", theme.isDark)
theme.textureId?.let { put("textureId", it) }
}
jsonArray.put(obj)
}
prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) }
}
private fun loadCustomThemes(context: Context): List<ReaderTheme> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]"
val themes = mutableListOf<ReaderTheme>()
try {
val jsonArray = org.json.JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
themes.add(
ReaderTheme(
id = obj.getString("id"),
name = obj.getString("name"),
backgroundColor = Color(obj.getInt("bgColor")),
textColor = Color(obj.getInt("textColor")),
isDark = obj.getBoolean("isDark"),
textureId = if (obj.has("textureId")) obj.getString("textureId") else null,
isCustom = true
)
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse custom themes")
}
return themes
}
private fun calculateContrastRatio(color1: Color, color2: Color): Float {
val l1 = max(color1.luminance(), color2.luminance())
val l2 = min(color1.luminance(), color2.luminance())
return (l1 + 0.05f) / (l2 + 0.05f)
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
@ -4039,499 +3929,3 @@ fun EpubReaderHost(
} }
} }
} }
@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))
}
}

View file

@ -158,6 +158,7 @@ import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest.Builder import coil.request.ImageRequest.Builder
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderTexture
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor import com.aryan.reader.epubreader.HighlightColor
@ -529,7 +530,7 @@ fun PaginatedReaderScreen(
val context = LocalContext.current val context = LocalContext.current
val textureBitmap = remember(activeTextureId) { val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id -> 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) androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId)
} }
} }

View file

@ -407,7 +407,7 @@ internal fun PdfPageComposable(
clearSelectionTrigger: Long = 0L, clearSelectionTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((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, onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false, isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null, drawingState: PdfDrawingState? = null,
@ -534,41 +534,55 @@ internal fun PdfPageComposable(
val canvasWidthPx = remember { mutableFloatStateOf(0f) } val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) } val canvasHeightPx = remember { mutableFloatStateOf(0f) }
val colorFilter = remember(isDarkMode) { val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
if (isDarkMode) {
val colorFilter = remember(activeTheme) {
when (activeTheme.id) {
"no_theme", "system" -> null
"reverse" -> {
val colorMatrix = floatArrayOf( val colorMatrix = floatArrayOf(
-1f, -1f, 0f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 0f, 255f,
0f, 0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 0f, 1f, 0f
255f,
0f,
-1f,
0f,
0f,
255f,
0f,
0f,
-1f,
0f,
255f,
0f,
0f,
0f,
1f,
0f
) )
ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
} else { }
null 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) { val backgroundColor = remember(activeTheme, isVerticalScroll) {
if (isDarkMode) { if (activeTheme.id == "no_theme" || activeTheme.id == "system") {
Color(0xFF2A2A2A)
} else {
if (isVerticalScroll) Color.White else Color.Black 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) { if (dragStartedOnHandle) {
Timber.d( Timber.d(
"PointerInput: Drag started on handle $activeDraggingHandle" "PointerInput: Drag started on handle $activeDraggingHandle"
@ -3091,8 +3101,12 @@ internal fun PdfPageComposable(
} }
LaunchedEffect( LaunchedEffect(
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, pageIndex this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight
) { ) {
scale = 1f
offset = Offset.Zero
onScaleChanged(1f)
Timber.d( Timber.d(
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}" "PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
) )
@ -3861,7 +3875,6 @@ private fun PdfBitmapLayer(
.fillMaxSize() .fillMaxSize()
.graphicsLayer()) { .graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) { 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()) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) { if (bitmapState != null && !bitmapState.isRecycled) {
val dstW = if (targetWidth > 0) targetWidth else bitmapState.width val dstW = if (targetWidth > 0) targetWidth else bitmapState.width
@ -3869,17 +3882,16 @@ private fun PdfBitmapLayer(
val srcSize = IntSize(bitmapState.width, bitmapState.height) val srcSize = IntSize(bitmapState.width, bitmapState.height)
val dstSize = IntSize(dstW, dstH) val dstSize = IntSize(dstW, dstH)
// 1. Draw Base Bitmap
drawImage( drawImage(
image = bitmapState.asImageBitmap(), image = bitmapState.asImageBitmap(),
srcOffset = IntOffset.Zero, srcOffset = IntOffset.Zero,
srcSize = srcSize, srcSize = srcSize,
dstOffset = IntOffset.Zero, dstOffset = IntOffset.Zero,
dstSize = dstSize, dstSize = dstSize,
colorFilter = colorFilter colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
) )
// 2. Draw High-Res Tiles
if (effectiveScale > 1f) { if (effectiveScale > 1f) {
tiles.forEach { tile -> tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) { if (!tile.bitmap.isRecycled) {
@ -3891,7 +3903,8 @@ private fun PdfBitmapLayer(
dstSize = IntSize( dstSize = IntSize(
tile.renderRect.width(), tile.renderRect.height() tile.renderRect.width(), tile.renderRect.height()
), ),
colorFilter = colorFilter colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
) )
} }
} }

View file

@ -111,7 +111,6 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -177,7 +176,7 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
internal fun PdfVerticalReader( internal fun PdfVerticalReader(
state: VerticalPdfReaderState, state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>, pdfDocument: StableHolder<ReaderDocument>,
isDarkMode: Boolean, activeTheme: com.aryan.reader.ReaderTheme,
totalPages: Int, totalPages: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
virtualPages: List<VirtualPage> = emptyList(), virtualPages: List<VirtualPage> = emptyList(),
@ -243,6 +242,7 @@ internal fun PdfVerticalReader(
} }
} }
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) } var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val density = LocalDensity.current val density = LocalDensity.current
@ -1497,7 +1497,7 @@ internal fun PdfVerticalReader(
pageIndex = page.index, pageIndex = page.index,
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalPages, totalPages = totalPages,
isDarkMode = isDarkMode, activeTheme = activeTheme,
externalScale = highResScale, externalScale = highResScale,
onScaleChanged = {}, onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights, showAllTextHighlights = showAllTextHighlights,

View file

@ -259,6 +259,7 @@ import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup 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.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation 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.TextStyleConfig
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.summarizationUrl import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager 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_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package" private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_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 { object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy { val core: PdfiumCoreKt by lazy {
@ -1065,7 +1089,16 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } 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<List<Float>>(emptyList()) } var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) } var showBars by rememberSaveable { mutableStateOf(true) }
var isFullScreen by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) }
@ -1364,7 +1397,6 @@ fun PdfViewerScreen(
LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) } LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) }
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) } LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
LaunchedEffect(isPdfDarkMode) { savePdfDarkMode(context, isPdfDarkMode) }
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState() val toolSettings by annotationSettingsRepo.settings.collectAsState()
@ -2724,21 +2756,24 @@ fun PdfViewerScreen(
): Boolean { ): Boolean {
if (annotation.points.isEmpty()) return false if (annotation.points.isEmpty()) return false
val effectiveThreshold = threshold + (annotation.strokeWidth / 2f)
val thresholdSq = effectiveThreshold * effectiveThreshold
if (annotation.points.size == 1) { if (annotation.points.size == 1) {
val p = annotation.points[0] val p = annotation.points[0]
val dx = (p.x - hitPoint.x) * pageAspectRatio val dx = (p.x - hitPoint.x)
val dy = (p.y - hitPoint.y) val dy = (p.y - hitPoint.y) / pageAspectRatio
return (dx * dx + dy * dy) < (threshold * threshold) return (dx * dx + dy * dy) < thresholdSq
} }
for (i in 0 until annotation.points.size - 1) { for (i in 0 until annotation.points.size - 1) {
val a = annotation.points[i] val a = annotation.points[i]
val b = annotation.points[i + 1] val b = annotation.points[i + 1]
val pax = (hitPoint.x - a.x) * pageAspectRatio val pax = (hitPoint.x - a.x)
val pay = (hitPoint.y - a.y) val pay = (hitPoint.y - a.y) / pageAspectRatio
val bax = (b.x - a.x) * pageAspectRatio val bax = (b.x - a.x)
val bay = (b.y - a.y) val bay = (b.y - a.y) / pageAspectRatio
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f) val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
val t = (pax * bax + pay * bay) / segmentLenSq val t = (pax * bax + pay * bay) / segmentLenSq
@ -2749,7 +2784,7 @@ fun PdfViewerScreen(
val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY) val distSq = (pax - closestX) * (pax - closestX) + (pay - closestY) * (pay - closestY)
if (distSq < (threshold * threshold)) return true if (distSq < thresholdSq) return true
} }
return false return false
@ -3580,6 +3615,7 @@ fun PdfViewerScreen(
} }
showTtsSettingsSheet -> showTtsSettingsSheet = false showTtsSettingsSheet -> showTtsSettingsSheet = false
showThemePanel -> showThemePanel = false
else -> { else -> {
saveStateAndExit() saveStateAndExit()
@ -4257,7 +4293,7 @@ fun PdfViewerScreen(
pageIndex = pageIndex, pageIndex = pageIndex,
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
isDarkMode = isPdfDarkMode, activeTheme = activeTheme,
isScrollLocked = isScrollLocked, isScrollLocked = isScrollLocked,
onScaleChanged = { newScale -> onScaleChanged = { newScale ->
if (pagerState.currentPage == pageIndex) { if (pagerState.currentPage == pageIndex) {
@ -4636,7 +4672,7 @@ fun PdfViewerScreen(
PdfVerticalReader( PdfVerticalReader(
state = verticalReaderState, state = verticalReaderState,
pdfDocument = docHolder, pdfDocument = docHolder,
isDarkMode = isPdfDarkMode, activeTheme = activeTheme,
isScrollLocked = isScrollLocked, isScrollLocked = isScrollLocked,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
pageAspectRatios = ratiosHolder, pageAspectRatios = ratiosHolder,
@ -5223,22 +5259,14 @@ fun PdfViewerScreen(
) )
TooltipIconButton( TooltipIconButton(
text = if (isPdfDarkMode) text = "Theme",
stringResource(R.string.tooltip_dark_mode_off) description = "Theme Settings",
else onClick = { showThemePanel = true }
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 }
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.dark_mode), painter = painterResource(id = R.drawable.palette),
contentDescription = if (isPdfDarkMode) "Disable Dark Mode" contentDescription = "Theme Settings",
else "Enable Dark Mode", tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = if (isPdfDarkMode) MaterialTheme.colorScheme.primary
else 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) { if (clickedLinkUrl != null) {
val url = clickedLinkUrl!! val url = clickedLinkUrl!!
AlertDialog( AlertDialog(

View file

@ -128,7 +128,7 @@ fun ToolSettingsPopup(
val thicknessRange = when { val thicknessRange = when {
isHighlighter -> 0.01f..0.06f isHighlighter -> 0.01f..0.06f
isEraser -> 0.01f..0.1f isEraser -> 0.002f..0.1f
else -> 0.001f..0.015f else -> 0.001f..0.015f
} }
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f @Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
@ -170,9 +170,9 @@ fun ToolSettingsPopup(
.height(125.dp), .height(125.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val radiusDp = (activeToolThickness * 1000).coerceIn(10f, 100f).dp val diameterDp = (activeToolThickness * 800).coerceIn(4f, 150f).dp
Box( Box(
modifier = Modifier.size(radiusDp), modifier = Modifier.size(diameterDp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {

View file

@ -219,8 +219,7 @@ class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
tempEntries.add(Pair(path, extractedFile)) tempEntries.add(Pair(path, extractedFile))
var pfd: android.os.ParcelFileDescriptor? = null var pfd: android.os.ParcelFileDescriptor? = null
try { @Suppress("ConvertTryFinallyToUseCall") try {
// Extract seamlessly using fd to avoid ByteBuffer's state sync bug
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE) pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
Archive.readDataIntoFd(archive, pfd.fd) Archive.readDataIntoFd(archive, pfd.fd)
} finally { } 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) } tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
} catch (e: Exception) { } catch (e: Exception) {
@ -306,15 +305,42 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
val scaleX = drawSizeX.toFloat() / originalWidth val scaleX = drawSizeX.toFloat() / originalWidth
val scaleY = drawSizeY.toFloat() / originalHeight val scaleY = drawSizeY.toFloat() / originalHeight
val srcLeft = (-startX / scaleX).toInt().coerceAtLeast(0) val pageOffsetX = -startX.toFloat()
val srcTop = (-startY / scaleY).toInt().coerceAtLeast(0) val pageOffsetY = -startY.toFloat()
val srcRight = (srcLeft + (bitmap.width / scaleX).toInt()).coerceAtMost(originalWidth)
val srcBottom = (srcTop + (bitmap.height / scaleY).toInt()).coerceAtMost(originalHeight) 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) val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
if (rect.width() <= 0 || rect.height() <= 0) return 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 { val region = try {
decoder.decodeRegion(rect, options) decoder.decodeRegion(rect, options)
} catch (_: Exception) { } catch (_: Exception) {
@ -323,8 +349,17 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
if (region != null) { if (region != null) {
val canvas = Canvas(bitmap) 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() region.recycle()
} }
} }