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:
parent
4db2c97a30
commit
60dacf9c12
9 changed files with 834 additions and 741 deletions
|
|
@ -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,
|
||||||
|
|
@ -1662,12 +1669,12 @@ fun DeviceVoiceSettingsSheet(
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = friendlyName,
|
text = friendlyName,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = if (voice.locale.variant.isNotEmpty()) {
|
supportingContent = if (voice.locale.variant.isNotEmpty()) {
|
||||||
{ Text("Variant: ${voice.locale.variant}") }
|
{ Text("Variant: ${voice.locale.variant}") }
|
||||||
} else null,
|
} else null,
|
||||||
|
|
@ -2014,4 +2021,599 @@ fun ColorComparePill(
|
||||||
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
|
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<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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -4038,500 +3928,4 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 colorMatrix = floatArrayOf(
|
val colorFilter = remember(activeTheme) {
|
||||||
-1f,
|
when (activeTheme.id) {
|
||||||
0f,
|
"no_theme", "system" -> null
|
||||||
0f,
|
"reverse" -> {
|
||||||
0f,
|
val colorMatrix = floatArrayOf(
|
||||||
255f,
|
-1f, 0f, 0f, 0f, 255f,
|
||||||
0f,
|
0f, -1f, 0f, 0f, 255f,
|
||||||
-1f,
|
0f, 0f, -1f, 0f, 255f,
|
||||||
0f,
|
0f, 0f, 0f, 1f, 0f
|
||||||
0f,
|
)
|
||||||
255f,
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
||||||
0f,
|
}
|
||||||
0f,
|
else -> {
|
||||||
-1f,
|
val bgR = activeTheme.backgroundColor.red * 255f
|
||||||
0f,
|
val bgG = activeTheme.backgroundColor.green * 255f
|
||||||
255f,
|
val bgB = activeTheme.backgroundColor.blue * 255f
|
||||||
0f,
|
|
||||||
0f,
|
val fgR = activeTheme.textColor.red * 255f
|
||||||
0f,
|
val fgG = activeTheme.textColor.green * 255f
|
||||||
1f,
|
val fgB = activeTheme.textColor.blue * 255f
|
||||||
0f
|
|
||||||
)
|
val dr = (bgR - fgR) / 255f
|
||||||
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
val dg = (bgG - fgG) / 255f
|
||||||
} else {
|
val db = (bgB - fgB) / 255f
|
||||||
null
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue