From 60dacf9c12ee765105c561ee55f76e39dfbf0f46 Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 26 Mar 2026 13:05:50 +0530 Subject: [PATCH] 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. --- app/src/main/java/com/aryan/reader/Common.kt | 686 ++++++++++++++++-- .../aryan/reader/epubreader/ChapterWebView.kt | 1 + .../reader/epubreader/EpubReaderScreen.kt | 622 +--------------- .../reader/paginatedreader/PaginatedReader.kt | 3 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 97 +-- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 6 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 99 ++- .../com/aryan/reader/pdf/ToolSettingsPopup.kt | 6 +- .../com/aryan/reader/pdf/UniversalDocument.kt | 55 +- 9 files changed, 834 insertions(+), 741 deletions(-) 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() } }