v1.0.45-oss (#221)

* Implemented ML-based comic panel detection and a panel popup viewer.

* Optimized `ComicPanelDetector` for performance and memory efficiency.

* Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization.

* Refactored system UI handling and layout padding in `PdfViewerScreen`.

* Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files.

* replace hardcoded padding with dynamic header height and adjust IME layout logic

* Improve `PdfTextBox` interaction and visual consistency during zoom and pan.

* improve PDF text box dragging and scaling behavior across zoom levels

* optimize color scheme calculation using remember and expand text dimming coverage

* Implement in-app language selection and per-app language preferences.

* Improve PDF lock stability in `PdfVerticalReader`

* Implement "Preserve Image Colors" option for PDF themes

* Optimize TOC locate in ReaderDrawer

* Update library and home screen UI components

* smoother page turn animation in epub pagination mode

* Implement automatic page skipping for TTS when no text is found in PDF viewer

* Refine status bar handling and window insets across main screens

* Optimize ViewModel initialization and integrate AndroidX SplashScreen

* Move ComicPanelDetector to debug source set and introduce IPanelDetector interface

* Optimize TOC scrolling in PdfNavigationDrawerContent

* Bump version to 1.0.45(45)
This commit is contained in:
Aryan 2026-04-22 14:11:52 +05:30 committed by GitHub
parent 71e3614ad6
commit cb7de86224
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4795 additions and 3447 deletions

View file

@ -30,8 +30,10 @@ android {
applicationId = "com.aryan.reader"
minSdk = 26
targetSdk = 35
versionCode = 44
versionName = "1.0.43"
versionCode = 45
versionName = "1.0.45"
resourceConfigurations += setOf("en", "ar", "de", "tr")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
@ -227,6 +229,13 @@ dependencies {
implementation("org.zwobble.mammoth:mammoth:1.4.2")
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
implementation("androidx.core:core-splashscreen:1.2.0")
}
spotless {

View file

@ -0,0 +1,202 @@
package com.aryan.reader.ml
import android.graphics.Bitmap
import android.graphics.RectF
import org.tensorflow.lite.DataType
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.gpu.CompatibilityList
import org.tensorflow.lite.gpu.GpuDelegate
import org.tensorflow.lite.support.common.ops.NormalizeOp
import org.tensorflow.lite.support.image.ImageProcessor
import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.support.image.ops.ResizeOp
import timber.log.Timber
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
data class PanelResult(
val rect: RectF,
val confidence: Float
)
class ComicPanelDetector(modelFile: File) : IPanelDetector {
private var interpreter: Interpreter? = null
private val inputSize = 640
private var gpuDelegate: GpuDelegate? = null
private var isTransposed: Boolean = false
private var numBoxes: Int = 0
private var numElementsPerBox: Int = 0
private var outputBuffer: ByteBuffer? = null
private var floatOutputBuffer: java.nio.FloatBuffer? = null
private var flatOutput: FloatArray? = null
private val preAllocatedTensorImage = TensorImage(DataType.FLOAT32)
private val imageProcessor = ImageProcessor.Builder()
.add(ResizeOp(inputSize, inputSize, ResizeOp.ResizeMethod.BILINEAR))
.add(NormalizeOp(0f, 255f))
.build()
init {
try {
val compatList = CompatibilityList()
val options = Interpreter.Options().apply {
numThreads = 4
if (compatList.isDelegateSupportedOnThisDevice) {
val delegateOptions = compatList.bestOptionsForThisDevice.apply {
isPrecisionLossAllowed = true
val cacheDir = File(modelFile.parentFile, "gpu_cache")
if (!cacheDir.exists()) cacheDir.mkdirs()
setSerializationParams(cacheDir.absolutePath, "${modelFile.name}_${modelFile.length()}")
}
gpuDelegate = GpuDelegate(delegateOptions)
addDelegate(gpuDelegate)
Timber.i("GPU Delegate added successfully with serialization caching.")
} else {
Timber.i("GPU not supported on this device. Falling back to 4 CPU threads.")
}
}
interpreter = Interpreter(modelFile, options)
val outputTensor = interpreter!!.getOutputTensor(0)
val shape = outputTensor.shape()
Timber.d("Model Output Tensor Shape: ${shape.contentToString()}")
isTransposed = shape.size == 3 && shape[1] > shape[2]
numBoxes = if (isTransposed) shape[1] else shape[2]
numElementsPerBox = if (isTransposed) shape[2] else shape[1]
val outputBytes = numBoxes * numElementsPerBox * 4
outputBuffer = ByteBuffer.allocateDirect(outputBytes).order(ByteOrder.nativeOrder())
floatOutputBuffer = outputBuffer!!.asFloatBuffer()
flatOutput = FloatArray(numBoxes * numElementsPerBox)
Timber.i("TFLite Model loaded and buffers allocated successfully from ${modelFile.absolutePath}")
} catch (e: Exception) {
Timber.e(e, "Error loading TFLite model or allocating buffers")
}
}
override fun detectPanels(bitmap: Bitmap, confidenceThreshold: Float, iouThreshold: Float): List<RectF> {
val tflite = interpreter ?: return emptyList()
val buffer = outputBuffer ?: return emptyList()
val floatBuf = floatOutputBuffer ?: return emptyList()
val flatOut = flatOutput ?: return emptyList()
if (numBoxes <= 0) {
Timber.w("Detector not initialized correctly: numBoxes is 0")
return emptyList()
}
preAllocatedTensorImage.load(bitmap)
val processedImage = imageProcessor.process(preAllocatedTensorImage)
buffer.rewind()
val startTime = System.currentTimeMillis()
tflite.run(processedImage.buffer, buffer)
Timber.d("Inference took ${System.currentTimeMillis() - startTime}ms")
floatBuf.rewind()
floatBuf.get(flatOut)
var maxCoord = 0f
for (i in 0 until min(100, numBoxes)) {
val cx = if (isTransposed) flatOutput!![i * numElementsPerBox + 0] else flatOutput!![0 * numBoxes + i]
if (cx > maxCoord) maxCoord = cx
}
val isNormalized = maxCoord <= 1.5f
Timber.d("Are coordinates normalized? $isNormalized (Sample Max: $maxCoord)")
val scaleX = if (isNormalized) bitmap.width.toFloat() else bitmap.width.toFloat() / inputSize
val scaleY = if (isNormalized) bitmap.height.toFloat() else bitmap.height.toFloat() / inputSize
val parsedResults = mutableListOf<PanelResult>()
for (i in 0 until numBoxes) {
val confidence = if (isTransposed) flatOutput!![i * numElementsPerBox + 4] else flatOutput!![4 * numBoxes + i]
if (confidence > confidenceThreshold) {
val cx = if (isTransposed) flatOutput!![i * numElementsPerBox + 0] else flatOutput!![0 * numBoxes + i]
val cy = if (isTransposed) flatOutput!![i * numElementsPerBox + 1] else flatOutput!![1 * numBoxes + i]
val w = if (isTransposed) flatOutput!![i * numElementsPerBox + 2] else flatOutput!![2 * numBoxes + i]
val h = if (isTransposed) flatOutput!![i * numElementsPerBox + 3] else flatOutput!![3 * numBoxes + i]
val scaledCx = cx * scaleX
val scaledCy = cy * scaleY
val scaledW = w * scaleX
val scaledH = h * scaleY
val left = scaledCx - scaledW / 2
val top = scaledCy - scaledH / 2
val right = scaledCx + scaledW / 2
val bottom = scaledCy + scaledH / 2
parsedResults.add(
PanelResult(
rect = RectF(left, top, right, bottom),
confidence = confidence
)
)
}
}
val finalPanels = applyNMS(parsedResults, iouThreshold)
return finalPanels.map { it.rect }.sortedWith { r1, r2 ->
if (abs(r1.top - r2.top) < (bitmap.height * 0.05f)) {
r2.right.compareTo(r1.right)
} else {
r1.top.compareTo(r2.top)
}
}
}
private fun applyNMS(boxes: List<PanelResult>, iouThreshold: Float): List<PanelResult> {
val sortedBoxes = boxes.sortedByDescending { it.confidence }.toMutableList()
val selected = mutableListOf<PanelResult>()
while (sortedBoxes.isNotEmpty()) {
val current = sortedBoxes.removeAt(0)
selected.add(current)
sortedBoxes.removeAll { box ->
calculateIoU(current.rect, box.rect) > iouThreshold
}
}
return selected
}
private fun calculateIoU(box1: RectF, box2: RectF): Float {
val intersectionLeft = max(box1.left, box2.left)
val intersectionTop = max(box1.top, box2.top)
val intersectionRight = min(box1.right, box2.right)
val intersectionBottom = min(box1.bottom, box2.bottom)
if (intersectionRight < intersectionLeft || intersectionBottom < intersectionTop) return 0f
val intersectionArea = (intersectionRight - intersectionLeft) * (intersectionBottom - intersectionTop)
val box1Area = (box1.right - box1.left) * (box1.bottom - box1.top)
val box2Area = (box2.right - box2.left) * (box2.bottom - box2.top)
return intersectionArea / (box1Area + box2Area - intersectionArea)
}
override fun close() {
interpreter?.close()
interpreter = null
gpuDelegate?.close()
gpuDelegate = null
outputBuffer = null
floatOutputBuffer = null
flatOutput = null
}
}

View file

@ -34,6 +34,7 @@
<application
android:name=".MyApplication"
android:localeConfig="@xml/locales_config"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@ -41,7 +42,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Reader"
android:theme="@style/Theme.App.Starting"
android:networkSecurityConfig="@xml/network_security_config">
<activity

View file

@ -1814,6 +1814,18 @@ fun loadReaderThemeId(context: Context): String {
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
}
const val PREF_EXCLUDE_IMAGES = "exclude_images"
fun saveExcludeImages(context: Context, excludeImages: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(PREF_EXCLUDE_IMAGES, excludeImages) }
}
fun loadExcludeImages(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_EXCLUDE_IMAGES, false)
}
fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonArray = JSONArray()
@ -1868,6 +1880,9 @@ private fun calculateContrastRatio(color1: Color, color2: Color): Float {
fun ReaderThemePanel(
isVisible: Boolean,
currentThemeId: String,
excludeImages: Boolean = false,
onExcludeImagesChange: (Boolean) -> Unit = {},
showExcludeImagesOption: Boolean = false,
customThemes: List<ReaderTheme>,
builtInThemes: List<ReaderTheme> = BuiltInThemes,
onThemeSelected: (String) -> Unit,
@ -1920,6 +1935,23 @@ fun ReaderThemePanel(
modifier = Modifier.padding(bottom = 16.dp)
)
if (showExcludeImagesOption) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text("Preserve Image Colors", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
Text("Keep original image colors when theme changes", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
androidx.compose.material3.Switch(
checked = excludeImages,
onCheckedChange = onExcludeImagesChange
)
}
}
Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)

View file

@ -22,13 +22,14 @@
package com.aryan.reader
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -41,6 +42,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -57,20 +59,18 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.AlertDialog
@ -132,6 +132,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
@ -183,6 +184,7 @@ fun HomeScreen(
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
var showLanguageDialog by remember { mutableStateOf(false) }
val feedbackResult =
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message")
@ -335,7 +337,9 @@ fun HomeScreen(
showStrictFilterDialog = true
}
},
onAppThemeClick = { showAppThemePanel = true }
onAppThemeClick = { showAppThemePanel = true },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onLanguageClick = { showLanguageDialog = true }
)
} else {
ContextualTopAppBar(
@ -509,6 +513,10 @@ fun HomeScreen(
)
}
if (showLanguageDialog) {
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
}
if (showAppThemePanel) {
AppThemeBottomSheet(
uiState = uiState,
@ -749,6 +757,8 @@ fun RecentFileCard(
isDownloading: Boolean,
) {
val context = LocalContext.current
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
@ -757,88 +767,70 @@ fun RecentFileCard(
item.coverImagePath?.let { File(it) } ?: placeholder
}
Surface(
modifier = modifier.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f },
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
shadowElevation = 4.dp,
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
) {
Column(
modifier = Modifier.combinedClickable(
onClick = onClick, onLongClick = onLongClick
androidx.compose.material3.ElevatedCard(
modifier = modifier
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 6.dp else 2.dp
)
) {
Box {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.74f)
) {
AsyncImage(
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
.fallback(placeholder).crossfade(true).build(),
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.height(160.dp).fillMaxWidth(),
modifier = Modifier.fillMaxSize()
)
if (item.sourceFolderUri != null) {
Box(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = stringResource(R.string.local_folder),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
modifier = Modifier.fillMaxSize().background(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.15f),
0.3f to Color.Transparent,
0.6f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.5f)
)
)
)
}
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
if (isOpdsStream) {
Box(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = CircleShape
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = stringResource(R.string.opds_stream),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges(
item = item,
isPinned = isPinned,
overlay = true,
modifier = Modifier
.align(Alignment.TopStart)
.padding(10.dp)
)
}
}
if (isPinned) {
Box(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp).background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
).padding(4.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = stringResource(R.string.pinned),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
if (!item.isAvailable) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f)),
modifier = Modifier.matchParentSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
if (isDownloading) {
CircularProgressIndicator(color = Color.White)
} else {
Icon(
imageVector = Icons.Filled.Info,
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(48.dp),
tint = Color.White
@ -846,39 +838,131 @@ fun RecentFileCard(
}
}
}
if (isSelected) {
Box(
modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)
modifier = Modifier.matchParentSize()
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(48.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(8.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) {
FileTypeBadge(type = item.type, overlay = true)
}
progressPercent?.let { percent ->
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp),
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color.White.copy(alpha = 0.14f)
)
) {
Text(
text = "$percent%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
text = item.cardTitle(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxWidth(),
lineHeight = 20.sp
)
Box(
modifier = Modifier.height(20.dp), contentAlignment = Alignment.Center
) {
item.progressPercentage?.let { progress ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.progress_complete, progress.toInt()),
text = authorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
minLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
if (!item.isAvailable) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
contentAlignment = Alignment.CenterStart
) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
@ -902,7 +986,9 @@ fun DefaultTopAppBar(
onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit
onAppThemeClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit,
onLanguageClick: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
@ -985,6 +1071,13 @@ fun DefaultTopAppBar(
}
})
HorizontalDivider()
DropdownMenuItem(text = { Text("Language") }, onClick = {
onLanguageClick()
showOptionsMenu = false
})
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache()
@ -995,6 +1088,14 @@ fun DefaultTopAppBar(
showOptionsMenu = false
})
if (BuildConfig.DEBUG) {
HorizontalDivider()
DropdownMenuItem(text = { Text("Test Panel ML Detection") }, onClick = {
onTestPanelDetectionClick()
showOptionsMenu = false
})
}
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
@ -1760,6 +1861,7 @@ fun ThemeSwatch(
}
}
@SuppressLint("UnrememberedMutableState")
@Composable
fun CreateAppThemeDialog(
initialColor: Color = Color(0xFF6750A4),
@ -1916,3 +2018,46 @@ fun CreateAppThemeDialog(
}
}
}
@Composable
fun LanguageSelectionDialog(onDismiss: () -> Unit) {
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.language ?: "en" else "en"
val languages = listOf(
"en" to "English (Default)",
"ar" to "العربية (Arabic)",
"de" to "Deutsch (German)",
"tr" to "Türkçe (Turkish)"
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Language") },
text = {
Column {
languages.forEach { (tag, name) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(tag)
)
onDismiss()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = currentTag == tag, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Text(name)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}

View file

@ -22,28 +22,17 @@
package com.aryan.reader
import android.annotation.SuppressLint
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import com.aryan.reader.opds.OpdsViewModel
import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsCatalog
import org.jsoup.Jsoup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.AssistChip
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -52,6 +41,8 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -67,6 +58,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -74,24 +68,28 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@ -100,6 +98,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -133,9 +132,13 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
@ -147,6 +150,7 @@ private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count)
}
@SuppressLint("LocalContextGetResourceValueCall")
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
@ -605,6 +609,7 @@ fun LibraryScreenContent(
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -918,7 +923,7 @@ private fun ShelfDetailScreen(
var showMoreMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.statusBarsPadding(),
modifier = Modifier,
topBar = {
if (isContextualModeActive) {
ContextualTopAppBar(
@ -1071,7 +1076,7 @@ private fun AddBooksModeScreen(
var showSourceMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.statusBarsPadding(),
modifier = Modifier,
topBar = {
CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
@ -1256,13 +1261,21 @@ private fun ShelfListItem(
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
) {
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
shadowElevation = 4.dp,
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp
),
modifier = Modifier
.fillMaxWidth()
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(
onClick = onItemClick,
onLongClick = {
@ -1318,22 +1331,41 @@ private fun LibraryListItem(
item.coverImagePath?.let { File(it) } ?: placeholder
}
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
shadowElevation = 4.dp,
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 6.dp else 2.dp
),
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.height(132.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(0.7f)
.clip(MaterialTheme.shapes.medium)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
) {
AsyncImage(
model = ImageRequest.Builder(context)
@ -1344,100 +1376,128 @@ private fun LibraryListItem(
.build(),
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
if (isSelected) {
Box(
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Check, contentDescription = "Selected", modifier = Modifier.size(36.dp).background(MaterialTheme.colorScheme.primary, CircleShape).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary)
}
}
}
Column(
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
.clip(MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(16.dp))
.weight(1f)
.fillMaxHeight()
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.sourceFolderUri != null) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
if (isOpdsStream) {
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = "OPDS Stream",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(4.dp))
}
if (isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = item.customName ?: item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
text = item.cardTitle(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
lineHeight = 20.sp
)
if (!item.isAvailable) {
Spacer(modifier = Modifier.width(8.dp))
if (isDownloading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "Not available locally",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
item.author?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
text = item.cardAuthor(),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
minLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges(
item = item,
isPinned = isPinned
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
FileTypeBadge(type = item.type, overlay = false)
item.progressPercentage?.let {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.weight(1f)
.height(28.dp),
contentAlignment = Alignment.CenterStart
) {
if (!item.isAvailable) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = "${it.toInt()}% complete",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
ReadingProgressSection(
progressPercentage = item.progressPercentage,
compact = true,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable

View file

@ -23,7 +23,7 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
@ -43,9 +43,10 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)

View file

@ -91,6 +91,7 @@ fun MainScreen(
}
Scaffold(
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
bottomBar = {
NavigationBar {
bottomBarItems.forEachIndexed { index, screen ->

View file

@ -28,6 +28,7 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.database.Cursor
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
@ -115,7 +116,9 @@ import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.concurrent.Executors.newSingleThreadExecutor
private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
@ -276,15 +279,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val appContext: Context = application.applicationContext
private val authRepository = AuthRepository(appContext)
private val recentFilesRepository = RecentFilesRepository(appContext)
private val pdfTextRepository = PdfTextRepository(appContext)
private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao()
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
private val singleFileImporter = SingleFileImporter(appContext)
private val bookImporter = BookImporter(appContext)
private val pdfTextRepository by lazy { PdfTextRepository(appContext) }
private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() }
private val epubParser by lazy { EpubParser(appContext) }
private val mobiParser by lazy { MobiParser(appContext) }
private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) }
private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) }
private val singleFileImporter by lazy { SingleFileImporter(appContext) }
private val bookImporter by lazy { BookImporter(appContext) }
private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) }
private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) }
private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) }
private val pdfHighlightRepository by lazy { PdfHighlightRepository(appContext) }
private val pdfAnnotationRepository by lazy { PdfAnnotationRepository(appContext) }
private val prefs: SharedPreferences =
application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
private val firestoreRepository = FirestoreRepository()
@ -301,16 +310,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val feedbackRepository = FeedbackRepository(appContext)
private var feedbackListener: Any? = null
private val importMutex = Mutex()
private val pageLayoutRepository = PageLayoutRepository(appContext)
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
private val pdfHighlightRepository = PdfHighlightRepository(appContext)
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = null
private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null
private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher()
private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? {
if (panelDetector == null && BuildConfig.DEBUG) {
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
if (modelFile.exists()) {
try {
val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector")
panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector
} catch (e: Exception) {
Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection")
}
} else {
Timber.e("Model file best_float16.tflite not found in external files dir")
}
}
return panelDetector
}
data class PageModificationResult(
val layout: List<VirtualPage>,
val annotations: Map<Int, List<PdfAnnotation>>,
@ -502,7 +528,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ReaderScreenState()
initialValue = _internalState.value
)
fun setTabsEnabled(enabled: Boolean) {
@ -796,7 +822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
init {
Timber.d("ViewModel instance created.")
viewModelScope.launch(Dispatchers.IO) {
PDFBoxResourceLoader.init(getApplication())
}
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
@ -1181,8 +1209,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private val pdfAnnotationRepository = PdfAnnotationRepository(appContext)
private fun getFastFileId(context: Context, uri: Uri): String {
var result = uri.toString()
try {
@ -4410,6 +4436,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
super.onCleared()
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
firestoreRepository.removeListener(feedbackListener)
panelDetector?.close()
panelDetector = null
Timber.d("ViewModel instance cleared (onCleared).")
}
@ -4627,6 +4655,104 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return authRepository.getIdToken()
}
fun testPanelDetection(context: Context) {
viewModelScope.launch(mlDispatcher) {
try {
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
if (!modelFile.exists()) {
withContext(Dispatchers.Main) { showBanner("Model not found", isError = true) }
return@launch
}
val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ }
?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ }
if (cbzItem == null) {
withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) }
return@launch
}
val uri = cbzItem.getUri() ?: return@launch
Timber.d("BATCH TEST START: ${cbzItem.displayName}")
var cacheFile: File? = null
try {
val detector = getOrInitDetector(context) ?: run {
withContext(Dispatchers.Main) { showBanner("Model could not be loaded", isError = true) }
return@launch
}
cacheFile = File(context.cacheDir, "temp_test_batch.cbz")
context.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
}
// 1. Initialize Model Once
val initStartTime = System.currentTimeMillis()
val initDuration = System.currentTimeMillis() - initStartTime
Timber.d(">>> [BATCH] Model Initialization: ${initDuration}ms")
val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
val totalPages = archiveDoc.getPageCount()
val startIndex = 3
val numPagesToTest = 10
val endIndex = minOf(startIndex + numPagesToTest - 1, totalPages - 1)
val resultsLog = StringBuilder()
resultsLog.append("Batch Results:\n")
// 2. Loop through pages
for (i in startIndex..endIndex) {
val page = archiveDoc.openPage(i)
if (page != null) {
val w = page.getPageWidthPoint()
val h = page.getPageHeightPoint()
if (w > 0 && h > 0) {
val bitmap = androidx.core.graphics.createBitmap(w, h)
page.renderPageBitmap(bitmap, 0, 0, w, h, false)
// Measure precise inference time
val pageStartTime = System.currentTimeMillis()
val panels = detector.detectPanels(bitmap)
val pageDuration = System.currentTimeMillis() - pageStartTime
val logLine = "Page $i: ${pageDuration}ms (Found ${panels.size} panels)"
Timber.d(">>>[BATCH] $logLine")
resultsLog.append("$logLine\n")
bitmap.recycle()
delay(20)
}
page.close()
}
}
archiveDoc.close()
withContext(Dispatchers.Main) {
Timber.i(resultsLog.toString())
showBanner("Batch test complete! Check logs for page-by-page timings.")
}
} finally {
cacheFile?.delete()
}
} catch (e: Exception) {
Timber.e(e, "Batch test failed")
}
}
}
suspend fun detectComicPanels(bitmap: Bitmap, context: Context): List<android.graphics.RectF> {
return withContext(mlDispatcher) {
try {
val detector = getOrInitDetector(context)
detector?.detectPanels(bitmap) ?: emptyList()
} catch (e: Exception) {
Timber.e(e, "Error during panel detection")
emptyList()
}
}
}
companion object {
private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names"

View file

@ -28,10 +28,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.ui.graphics.Color
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -55,8 +57,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll
@ -69,6 +73,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -85,6 +90,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@ -100,6 +106,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -111,6 +118,7 @@ import java.util.Date
import java.util.Locale
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
@ -254,15 +262,15 @@ fun CustomTopAppBar(
actions: @Composable RowScope.() -> Unit = {}
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.statusBarsPadding()
.height(56.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -874,20 +882,168 @@ fun AutoSizeText(
@Composable
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
val containerColor = if (overlay) androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (overlay) androidx.compose.ui.graphics.Color.White else MaterialTheme.colorScheme.onSecondaryContainer
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
shape = RoundedCornerShape(50),
color = containerColor,
contentColor = contentColor
contentColor = contentColor,
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null
) {
Text(
text = type.name.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
fun RecentFileItem.cardTitle(): String {
return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName
}
fun RecentFileItem.cardAuthor(): String {
return author
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: UNKNOWN_AUTHOR_LABEL
}
fun RecentFileItem.progressPercentValue(): Int {
return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
}
fun RecentFileItem.progressFraction(): Float {
return progressPercentValue() / 100f
}
fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true
}
@Composable
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
val container = if (overlay) {
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.92f)
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
val content = if (overlay) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
return container to content
}
@Composable
fun StatusIconBadge(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
overlay: Boolean = false,
) {
val (containerColor, contentColor) = statusBadgeColors(overlay)
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = containerColor,
contentColor = contentColor,
tonalElevation = if (overlay) 0.dp else 2.dp,
shadowElevation = if (overlay) 0.dp else 1.dp,
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) else null
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.padding(6.dp).size(14.dp)
)
}
}
@Composable
fun FileStatusBadges(
item: RecentFileItem,
isPinned: Boolean,
modifier: Modifier = Modifier,
overlay: Boolean = false,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.sourceFolderUri != null) {
StatusIconBadge(
icon = Icons.Default.Folder,
contentDescription = stringResource(R.string.local_folder),
overlay = overlay
)
}
if (item.isOpdsStream()) {
StatusIconBadge(
icon = Icons.Default.Cloud,
contentDescription = stringResource(R.string.opds_stream),
overlay = overlay
)
}
if (isPinned) {
StatusIconBadge(
icon = Icons.Default.PushPin,
contentDescription = stringResource(R.string.pinned),
overlay = overlay
)
}
}
}
@Composable
fun ReadingProgressSection(
progressPercentage: Float?,
modifier: Modifier = Modifier,
label: String? = null,
compact: Boolean = false,
) {
val percent = (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
val progress = percent / 100f
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (label != null) {
Text(
text = label,
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
Spacer(modifier = Modifier.weight(1f))
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Text(
text = "$percent%",
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(if (compact) 6.dp else 8.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(if (compact) 5.dp else 6.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
)
}
}

View file

@ -94,6 +94,7 @@ import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@ -329,7 +330,8 @@ private fun ChaptersList(
mutableStateOf(allParentIndices)
}
val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) {
val visibleItemInfo by remember(effectiveToc) {
derivedStateOf {
val result = mutableListOf<Pair<Int, EpubTocEntry>>()
val visibilityStack = BooleanArray(50) { false }
visibilityStack[0] = true
@ -353,6 +355,7 @@ private fun ChaptersList(
}
result
}
}
val coroutineScope = rememberCoroutineScope()
@ -367,23 +370,29 @@ private fun ChaptersList(
val targetEntry = activeTocEntry ?: return@launch
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
if (targetOriginalIndex != -1) {
// Ensure parents are expanded
var currentLevel = targetEntry.depth
val newExpanded = expandedEntryIndices.toMutableSet()
for (i in targetOriginalIndex downTo 0) {
val entry = effectiveToc[i]
if (entry.depth < currentLevel) {
newExpanded.add(i)
currentLevel = entry.depth
}
if (currentLevel == 0) break
}
expandedEntryIndices = newExpanded
// Delay to allow visibility array to recompose
kotlinx.coroutines.delay(100)
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
if (visibleIdx != -1) {
var attempts = 0
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
delay(30)
attempts++
}
listState.animateScrollToItem(visibleIdx)
}
}

View file

@ -448,7 +448,7 @@ fun EpubReaderScreen(
}
@Suppress("ControlFlowWithEmptyBody")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@androidx.annotation.OptIn(UnstableApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class)
@ -481,6 +481,13 @@ fun EpubReaderHost(
val activity = context as? Activity
val scope = rememberCoroutineScope()
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
DisposableEffect(window, view) {
onDispose {
window?.let {
WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars())
}
}
}
val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() }

View file

@ -0,0 +1,9 @@
package com.aryan.reader.ml
import android.graphics.Bitmap
import android.graphics.RectF
interface IPanelDetector {
fun detectPanels(bitmap: Bitmap, confidenceThreshold: Float = 0.25f, iouThreshold: Float = 0.45f): List<RectF>
fun close()
}

View file

@ -1908,7 +1908,6 @@ internal fun PaginatedReaderContent(
val down = event.changes.firstOrNull { it.pressed }
if (down != null) {
pageTurnTouchY = down.position.y
Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}")
}
}
}
@ -3952,10 +3951,6 @@ private fun Modifier.realisticBookPage(
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
// Log composition frequency
SideEffect {
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
}
val frontPath = remember { Path() }
val backPath = remember { Path() }
@ -3965,7 +3960,6 @@ private fun Modifier.realisticBookPage(
.graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
// Log layer property updates
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
}
@ -3994,7 +3988,14 @@ private fun Modifier.realisticBookPage(
val h = size.height
val startY = touchY ?: h
val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
val rawCenterDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
val flattenFactor = if (progress > 0.75f) {
((progress - 0.75f) / 0.25f).coerceIn(0f, 1f)
} else {
0f
}
val centerDist = rawCenterDist * (1f - flattenFactor)
val cornerY = if (centerDist >= 0) h else 0f

View file

@ -19,13 +19,23 @@
*/
package com.aryan.reader.pdf
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -35,19 +45,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Redo
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DoNotTouch
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -330,3 +350,159 @@ private fun DockIcon(
)
}
}
@Composable
fun PenPlayground(onClose: () -> Unit) {
var selectedPen by remember { mutableStateOf(PenType.FOUNTAIN_PEN) }
var selectedColor by remember { mutableStateOf(Color(0xFF2196F3)) } // Default Blue
val colors = listOf(
Color(0xFFF44336), // Red
Color(0xFFFFEB3B), // Yellow
Color(0xFF2196F3), // Blue
Color(0xFF4CAF50), // Green
Color(0xFF9C27B0), // Purple
Color.White, // White
Color.Black // Black
)
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.padding(16.dp),
shape = RoundedCornerShape(28.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 16.dp,
tonalElevation = 0.dp
) {
Column(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier.padding(start = 12.dp)
)
IconButton(onClick = onClose) {
Icon(
painter = painterResource(
id = R.drawable.close
),
contentDescription = "Close", tint = Color.Gray
)
}
}
Spacer(Modifier.height(40.dp))
// --- The Pen Rack ---
Row(
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Bottom
) {
PenType.entries.forEach { type ->
val isSelected = selectedPen == type
val offsetY by animateDpAsState(
targetValue = if (isSelected) (-20).dp else 0.dp, label = "offset"
)
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.2f else 1.0f, label = "scale"
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.offset(y = offsetY)
.scale(scale)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { selectedPen = type }) {
// Drawing Area
Box(
modifier = Modifier
.width(40.dp)
.height(120.dp),
contentAlignment = Alignment.BottomCenter
) {
PenIcon(
color = selectedColor,
type = type,
isSelected = isSelected,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.8f)
)
}
}
}
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = Color.White.copy(alpha = 0.1f),
thickness = 1.dp
)
Spacer(Modifier.height(24.dp))
// --- Color Palette ---
Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly
) {
colors.forEach { color ->
val isSelected = selectedColor == color
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.clickable { selectedColor = color }) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = color)
if (isSelected) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2,
style = Stroke(width = 3.dp.toPx())
)
}
}
if (isSelected && color == Color.White) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
} else if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = if (color == Color.Black) Color.White
else Color.Black.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp)
)
}
}
}
}
Spacer(Modifier.height(16.dp))
}
}
}

View file

@ -0,0 +1,149 @@
package com.aryan.reader.pdf
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.aryan.reader.R
@Composable
internal fun PasswordDialog(isError: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_password_protected)) }, text = {
Column {
Text(stringResource(R.string.desc_password_protected))
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(onDone = { onConfirm(password) }),
isError = isError,
supportingText = if (isError) {
{ Text(stringResource(R.string.error_incorrect_password)) }
} else null,
trailingIcon = {
val image = if (passwordVisible) Icons.Filled.Visibility
else Icons.Filled.VisibilityOff
val description = if (passwordVisible) stringResource(R.string.content_desc_hide_password) else stringResource(R.string.content_desc_show_password)
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
modifier = Modifier.fillMaxWidth()
)
}
}, confirmButton = {
Button(onClick = { onConfirm(password) }, enabled = password.isNotBlank()) {
Text(stringResource(R.string.action_open))
}
}, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
}
@Composable
internal fun OcrLanguageSelectionDialog(
currentLanguage: OcrLanguage,
isFirstRun: Boolean,
onDismiss: () -> Unit,
onLanguageSelected: (OcrLanguage) -> Unit
) {
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_select_ocr_language)) }, text = {
Column(Modifier.selectableGroup()) {
Text(
stringResource(R.string.desc_select_ocr_language),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
if (isFirstRun) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
Row(
Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.desc_ocr_language_change_later),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
} else {
Spacer(Modifier.height(8.dp))
}
OcrLanguage.entries.forEach { language ->
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = (language == currentLanguage),
onClick = { onLanguageSelected(language) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = (language == currentLanguage), onClick = null)
Text(
text = language.displayName,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
}

View file

@ -0,0 +1,194 @@
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.print.PageRange
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintDocumentInfo
import android.provider.OpenableColumns
import android.util.LruCache
import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.FileInputStream
import java.io.FileOutputStream
import kotlin.random.Random
object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy {
PdfiumCoreKt(Dispatchers.Default)
}
}
internal data class DocumentCacheItem(
val doc: ReaderDocument,
val pfd: ParcelFileDescriptor,
val totalPages: Int,
val pageAspectRatios: List<Float>,
val flatTableOfContents: List<TocEntry>
)
internal class DocumentCache(val maxSize: Int = 3) {
val cache = object : LruCache<String, DocumentCacheItem>(maxSize) {
override fun entryRemoved(
evicted: Boolean,
key: String,
oldValue: DocumentCacheItem,
newValue: DocumentCacheItem?
) {
if (evicted) {
CoroutineScope(Dispatchers.IO).launch {
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
}
}
}
}
fun put(key: String, item: DocumentCacheItem) { cache.put(key, item) }
fun get(key: String): DocumentCacheItem? = cache.get(key)
fun evictAll() { cache.evictAll() }
}
class PdfPrintDocumentAdapter(
private val context: Context,
private val pdfUri: Uri,
private val fileName: String
) : PrintDocumentAdapter() {
override fun onLayout(
oldAttributes: PrintAttributes?,
newAttributes: PrintAttributes?,
cancellationSignal: CancellationSignal?,
callback: LayoutResultCallback?,
extras: Bundle?
) {
if (cancellationSignal?.isCanceled == true) {
callback?.onLayoutCancelled()
return
}
val info = PrintDocumentInfo.Builder(fileName)
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
.build()
callback?.onLayoutFinished(info, true)
}
override fun onWrite(
pages: Array<out PageRange>?,
destination: ParcelFileDescriptor?,
cancellationSignal: CancellationSignal?,
callback: WriteResultCallback?
) {
try {
context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd ->
FileInputStream(pfd.fileDescriptor).use { input ->
FileOutputStream(destination?.fileDescriptor).use { output ->
val buf = ByteArray(8192)
var bytesRead: Int
while (input.read(buf).also { bytesRead = it } > 0) {
if (cancellationSignal?.isCanceled == true) {
Timber.tag("PdfPrint").d("Print job cancelled during write")
callback?.onWriteCancelled()
return
}
output.write(buf, 0, bytesRead)
}
}
}
}
Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler")
callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
} catch (e: Exception) {
Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler")
callback?.onWriteFailed(e.message)
}
}
}
internal fun generateShortId(): String {
return Random.nextInt(1000, 9999).toString()
}
internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): String {
val base = originalName?.substringBeforeLast('.') ?: "Document"
val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_").take(50)
val suffix = if (isAnnotated) "_annotated" else ""
val shortId = generateShortId()
return "${safeBase}${suffix}_${shortId}.pdf"
}
internal fun getFastFileId(context: Context, uri: Uri): String {
var result = uri.toString()
try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
result = "${name}_${size}"
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to generate fast file ID")
}
return result
}
internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bitmap? {
return withContext(Dispatchers.IO) {
var page: ReaderPage? = null
try {
page = doc.openPage(pageIndex)
if (page == null) return@withContext null
val bitmapWidth = 1080
val aspectRatio =
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
if (aspectRatio.isNaN() || aspectRatio <= 0) {
Timber.e("Invalid aspect ratio for page $pageIndex")
return@withContext null
}
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
if (bitmapHeight <= 0) {
Timber.e("Invalid calculated bitmap height for page $pageIndex")
return@withContext null
}
val bitmap = createBitmap(bitmapWidth, bitmapHeight)
page.renderPageBitmap(
bitmap = bitmap,
startX = 0,
startY = 0,
drawSizeX = bitmapWidth,
drawSizeY = bitmapHeight,
renderAnnot = true
)
bitmap
} catch (e: Exception) {
Timber.e(e, "Error rendering page $pageIndex to bitmap for summarization")
null
} finally {
try {
page?.close()
} catch (e: Exception) {
Timber.w(e, "Error closing page in renderPageToBitmap")
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.annotation.OptIn
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.tts.TtsPlaybackManager
internal enum class SaveMode {
ORIGINAL, ANNOTATED
}
enum class SearchHighlightMode {
FOCUSED, ALL
}
internal sealed interface HistoryAction {
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
}
internal enum class DockLocation {
TOP, BOTTOM, FLOATING
}
internal enum class DisplayMode {
PAGINATION, VERTICAL_SCROLL
}
@OptIn(UnstableApi::class)
@Suppress("unused")
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}

View file

@ -0,0 +1,537 @@
// PdfNavigationDrawerContent.kt
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PdfNavigationDrawerContent(
flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>,
currentPage: Int,
customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit,
onRenameBookmark: (PdfBookmark, String) -> Unit,
onDeleteBookmark: (PdfBookmark) -> Unit,
onDeleteHighlight: (PdfUserHighlight) -> Unit,
onNoteRequested: (String?) -> Unit,
onCloseDrawer: () -> Unit
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
}, text = { Text("Chapters") })
Tab(
selected = drawerPagerState.currentPage == 1,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
},
text = { Text("Bookmarks") },
modifier = Modifier.testTag("BookmarksTab")
)
Tab(
selected = drawerPagerState.currentPage == 2,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
},
text = { Text("Highlights") },
modifier = Modifier.testTag("HighlightsTab")
)
}
HorizontalPager(
state = drawerPagerState,
modifier = Modifier.fillMaxWidth().weight(1f)
) { page ->
when (page) {
0 -> { // Chapters Page
if (flatTableOfContents.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"Chapters are not available for this book.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
} else {
val listState = rememberLazyListState()
val allParentIndices = remember(flatTableOfContents) {
flatTableOfContents.indices.filter { i ->
val next = flatTableOfContents.getOrNull(i + 1)
next != null && next.nestLevel > flatTableOfContents[i].nestLevel
}.toSet()
}
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
mutableStateOf(allParentIndices)
}
val visibleItemInfo by remember(flatTableOfContents) {
derivedStateOf {
val result = mutableListOf<Pair<Int, TocEntry>>()
val visibilityStack = BooleanArray(20) { false }
visibilityStack[0] = true
for (i in flatTableOfContents.indices) {
val entry = flatTableOfContents[i]
val level = entry.nestLevel.coerceIn(0, 19)
if (visibilityStack[level]) {
result.add(i to entry)
val isExpanded = expandedEntryIndices.contains(i)
if (level + 1 < visibilityStack.size) {
visibilityStack[level + 1] = isExpanded
}
} else {
if (level + 1 < visibilityStack.size) {
visibilityStack[level + 1] = false
}
}
}
result
}
}
val currentTocEntry by remember(currentPage, flatTableOfContents) {
derivedStateOf {
flatTableOfContents.lastOrNull { it.pageIndex <= currentPage }
}
}
val onScrollToCurrent = {
drawerScope.launch {
val targetEntry = currentTocEntry ?: return@launch
val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
if (targetOriginalIndex != -1) {
var currentLevel = targetEntry.nestLevel
val newExpanded = expandedEntryIndices.toMutableSet()
for (i in targetOriginalIndex downTo 0) {
val entry = flatTableOfContents[i]
if (entry.nestLevel < currentLevel) {
newExpanded.add(i)
currentLevel = entry.nestLevel
}
if (currentLevel == 0) break
}
expandedEntryIndices = newExpanded
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
if (visibleIdx != -1) {
var attempts = 0
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
delay(30)
attempts++
}
listState.animateScrollToItem(visibleIdx)
}
}
}
Unit
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) {
Text("Expand All")
}
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
Text("Collapse All")
}
TextButton(onClick = onScrollToCurrent) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight()
.padding(end = 12.dp)
) {
items(
items = visibleItemInfo,
key = { it.second.title + it.first }
) { item ->
val (originalIndex, entry) = item
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
val isExpanded = expandedEntryIndices.contains(originalIndex)
val isCurrentChapter = entry == currentTocEntry
PdfTocTreeItem(
label = entry.title,
nestLevel = entry.nestLevel,
isExpanded = isExpanded,
hasChildren = hasChildren,
isCurrent = isCurrentChapter,
onToggleExpand = {
expandedEntryIndices = if (isExpanded) {
expandedEntryIndices - originalIndex
} else {
expandedEntryIndices + originalIndex
}
},
onClick = {
onCloseDrawer()
onPageSelected(entry.pageIndex)
}
)
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
}
1 -> { // Bookmarks Page
if (bookmarks.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any bookmarks yet.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
} else {
var bookmarkMenuExpandedFor by remember { mutableStateOf<PdfBookmark?>(null) }
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfBookmark?>(null) }
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
val sortedBookmarks = remember(bookmarks) {
bookmarks.sortedBy { it.pageIndex }
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(
items = sortedBookmarks, key = { index, bookmark ->
"bm_${index}_${bookmark.pageIndex}"
}) { _, bookmark ->
ListItem(headlineContent = {
Text(
bookmark.title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}, supportingContent = {
Text(
"Page ${bookmark.pageIndex + 1} of ${bookmark.totalPages}",
style = MaterialTheme.typography.bodySmall
)
}, trailingContent = {
Box {
IconButton(
onClick = {
bookmarkMenuExpandedFor = bookmark
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options for bookmark"
)
}
DropdownMenu(
expanded = bookmarkMenuExpandedFor == bookmark,
onDismissRequest = {
bookmarkMenuExpandedFor = null
}) {
DropdownMenuItem(text = {
Text("Rename")
}, onClick = {
showRenameBookmarkDialog = bookmark
bookmarkMenuExpandedFor = null
})
DropdownMenuItem(text = {
Text("Delete")
}, onClick = {
showDeleteConfirmDialogFor = bookmark
bookmarkMenuExpandedFor = null
})
}
}
}, modifier = Modifier
.clickable {
onCloseDrawer()
onPageSelected(bookmark.pageIndex)
}
.testTag(
"BookmarkItem_${bookmark.pageIndex}"
))
HorizontalDivider()
}
}
showRenameBookmarkDialog?.let { bookmarkToRename ->
var newTitle by remember { mutableStateOf("") }
AlertDialog(onDismissRequest = {
showRenameBookmarkDialog = null
}, title = { Text("Rename Bookmark") }, text = {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("New Title") },
placeholder = {
Text(
text = bookmarkToRename.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f
)
)
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}, confirmButton = {
TextButton(
onClick = {
onRenameBookmark(bookmarkToRename, newTitle)
showRenameBookmarkDialog = null
}) { Text("Save") }
}, dismissButton = {
TextButton(
onClick = {
showRenameBookmarkDialog = null
}) { Text("Cancel") }
})
}
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
AlertDialog(onDismissRequest = {
showDeleteConfirmDialogFor = null
}, title = { Text("Delete Bookmark?") }, text = {
Text(
"Are you sure you want to permanently delete this bookmark?"
)
}, confirmButton = {
TextButton(
onClick = {
onDeleteBookmark(bookmarkToDelete)
showDeleteConfirmDialogFor = null
}) { Text("Delete") }
}, dismissButton = {
TextButton(
onClick = {
showDeleteConfirmDialogFor = null
}) { Text("Cancel") }
})
}
}
}
2 -> { // Highlights Page
if (userHighlights.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any highlights yet.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
} else {
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfUserHighlight?>(null) }
var filterWithNotesOnly by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = !filterWithNotesOnly,
onClick = { filterWithNotesOnly = false },
label = { Text("All") }
)
FilterChip(
selected = filterWithNotesOnly,
onClick = { filterWithNotesOnly = true },
label = { Text("With Notes") }
)
}
val filteredHighlights = if (filterWithNotesOnly) {
userHighlights.filter { !it.note.isNullOrBlank() }
} else {
userHighlights.toList()
}
val sortedHighlights = remember(filteredHighlights) {
filteredHighlights.sortedBy { it.pageIndex }
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(
items = sortedHighlights,
key = { _, highlight -> highlight.id }
) { _, highlight ->
ListItem(
headlineContent = {
Text(
text = highlight.text.ifBlank { "Highlighted section" },
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
Box(
modifier = Modifier
.size(12.dp)
.background(displayColor, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
"Page ${highlight.pageIndex + 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!highlight.note.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = highlight.note,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
trailingContent = {
Box {
var highlightMenuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { highlightMenuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Options")
}
DropdownMenu(
expanded = highlightMenuExpanded,
onDismissRequest = { highlightMenuExpanded = false }
) {
DropdownMenuItem(
text = { Text(if (highlight.note.isNullOrBlank()) "Add Note" else "Edit Note") },
onClick = {
onNoteRequested(highlight.id)
highlightMenuExpanded = false
onCloseDrawer()
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showDeleteConfirmDialogFor = highlight
highlightMenuExpanded = false
}
)
}
}
},
modifier = Modifier.clickable {
onCloseDrawer()
onPageSelected(highlight.pageIndex)
}
)
HorizontalDivider()
}
}
}
showDeleteConfirmDialogFor?.let { highlightToDelete ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialogFor = null },
title = { Text("Delete Highlight?") },
text = { Text("Are you sure you want to permanently delete this highlight?") },
confirmButton = {
TextButton(
onClick = {
onDeleteHighlight(highlightToDelete)
showDeleteConfirmDialogFor = null
}
) { Text("Delete") }
},
dismissButton = {
TextButton(
onClick = { showDeleteConfirmDialogFor = null }
) { Text("Cancel") }
}
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,253 @@
package com.aryan.reader.pdf
import android.graphics.Bitmap
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import com.aryan.reader.R
private data class ScrollbarCalculations(
val thumbHeight: Float,
val thumbOffset: Float,
val contentHeight: Float,
val viewportHeight: Float
)
@Composable
fun VerticalScrollbar(
listState: LazyListState,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isDragged by interactionSource.collectIsDraggedAsState()
val scrollbarState by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val visibleItemsInfo = layoutInfo.visibleItemsInfo
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
return@derivedStateOf null
}
// Estimate total height
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
val viewportRatio = viewportHeight / estimatedContentHeight
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
val maxScrollPixels = estimatedContentHeight - viewportHeight
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
val trackHeight = viewportHeight - thumbHeight
val thumbOffset = trackHeight * scrollProgress
ScrollbarCalculations(
thumbHeight = thumbHeight,
thumbOffset = thumbOffset,
contentHeight = estimatedContentHeight,
viewportHeight = viewportHeight
)
}
}
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = 200),
label = "ScrollbarAlpha"
)
if (scrollbarState != null) {
val state = scrollbarState!!
val draggableState = rememberDraggableState { delta ->
val trackHeight = state.viewportHeight - state.thumbHeight
if (trackHeight > 0) {
val scrollRatio = delta / trackHeight
val totalScrollableDistance = state.contentHeight - state.viewportHeight
val scrollDelta = scrollRatio * totalScrollableDistance
listState.dispatchRawDelta(scrollDelta)
}
}
Box(
modifier = modifier
.width(30.dp)
.fillMaxHeight()
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
interactionSource = interactionSource
)
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.graphicsLayer { translationY = state.thumbOffset }
.padding(end = 4.dp)
.width(6.dp)
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
.alpha(alpha)
.background(
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(100)
)
)
}
}
}
@Composable
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null, interactionSource = remember { MutableInteractionSource() }) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface.copy(
alpha = 0.9f
), shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Icon(
painter = painterResource(id = R.drawable.slider),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
Text(
text = "Page $currentPage of $totalPages",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
internal fun ThumbnailWithIndicator(
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
) {
val borderColor = MaterialTheme.colorScheme.primary
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
modifier = Modifier
.width(45.dp)
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, borderColor)
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = "Start page thumbnail",
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
Box(modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(borderColor))
}
}
@Composable
internal fun BookmarkButton(
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.width(48.dp)
.height(48.dp)
.clip(RectangleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
), contentAlignment = Alignment.TopCenter
) {
AnimatedVisibility(visible = isBookmarked, enter = fadeIn(), exit = fadeOut()) {
Icon(
painter = painterResource(id = R.drawable.bookmark),
contentDescription = "Bookmark",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
internal fun ZoomPercentageIndicator(percentage: Int) {
Surface(
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
) {
Text(
text = "$percentage%",
color = Color.White,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}

View file

@ -347,7 +347,9 @@ data class PageStaticData(
val targetWidth: Int,
val targetHeight: Int,
val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean
val isDarkMode: Boolean,
val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>>
)
@Stable
@ -416,6 +418,7 @@ internal fun PdfPageComposable(
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
excludeImages: Boolean = false,
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
@ -451,7 +454,9 @@ internal fun PdfPageComposable(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
@ -852,11 +857,13 @@ internal fun PdfPageComposable(
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList()
return@LaunchedEffect
}
@ -864,6 +871,7 @@ internal fun PdfPageComposable(
val allLinks = mutableListOf<PageLink>()
var finalDisplayList = emptyList<EmbeddedAnnotation>()
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
var mappedImageRects = emptyList<android.graphics.Rect>()
val annotLink = 2
try {
@ -931,6 +939,39 @@ internal fun PdfPageComposable(
Timber.e(e, "Error fetching web links")
}
// --- Extract Image Bounds ---
try {
val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) {
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
val imgRects = mutableListOf<android.graphics.Rect>()
val outRect = FloatArray(4)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
val pdfRectF = android.graphics.RectF(
min(outRect[0], outRect[2]),
max(outRect[1], outRect[3]),
max(outRect[0], outRect[2]),
min(outRect[1], outRect[3])
)
val deviceRect = pageWrapper.mapRectToDevice(
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
currentPageRotation, pdfRectF
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom))
}
}
}
}
mappedImageRects = imgRects
}
} catch (e: Exception) {
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
}
// 3. Extract Embedded Annotations
try {
val pagePtr = pageWrapper.getNativePointer()
@ -1035,6 +1076,7 @@ internal fun PdfPageComposable(
pageLinks = allLinks
embeddedAnnotations = finalDisplayList
standardAnnotScreenRects = mappedAnnots
imageScreenRects = mappedImageRects
}
}
}
@ -2627,7 +2669,38 @@ internal fun PdfPageComposable(
coroutineScope.launch {
val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f
Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale")
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
val tapXInBitmap = tapInContentCoords.x * ratioX
val tapYInBitmap = tapInContentCoords.y * ratioY
val panels = onDetectPanels(bitmapState!!)
val tappedPanel = panels.firstOrNull {
it.contains(tapXInBitmap, tapYInBitmap)
}
if (tappedPanel != null) {
Timber.d("Popup: Cropping panel $tappedPanel")
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
val width = right - left
val height = bottom - top
if (width > 0 && height > 0) {
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
onShowPanelPopup(cropped)
return@launch
}
}
}
val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero
@ -2658,10 +2731,10 @@ internal fun PdfPageComposable(
)
) {
val progress = value
scale = lerp(
scale = androidx.compose.ui.util.lerp(
startScale, targetScale, progress
)
offset = lerp(
offset = androidx.compose.ui.geometry.lerp(
startOffset, targetOffset, progress
)
onScaleChanged(scale)
@ -3555,6 +3628,7 @@ internal fun PdfPageComposable(
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
val stableTiles = remember(tiles) { StableHolder(tiles) }
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
val staticData = remember(
stableBitmapState,
@ -3567,7 +3641,9 @@ internal fun PdfPageComposable(
actualBitmapWidthPx,
actualBitmapHeightPx,
stableColorFilter,
isDarkMode
isDarkMode,
excludeImages,
stableImageRects
) {
Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@ -3583,7 +3659,9 @@ internal fun PdfPageComposable(
targetWidth = actualBitmapWidthPx,
targetHeight = actualBitmapHeightPx,
colorFilter = stableColorFilter,
isDarkMode = isDarkMode
isDarkMode = isDarkMode,
excludeImages = excludeImages,
imageRects = stableImageRects
)
}
@ -3659,6 +3737,7 @@ internal fun PdfPageComposable(
}
},
scale = scale,
uiScale = effectiveScale,
offset = offset,
startHandlePos = startHandleContentPosition.value,
endHandlePos = endHandleContentPosition.value,
@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer(
targetWidth: Int,
targetHeight: Int,
colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false
isDarkMode: Boolean = false,
excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = emptyList()
) {
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) {
@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer(
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
imageRects.forEach { rect ->
val scaleX = bitmapState.width.toFloat() / dstW.toFloat()
val scaleY = bitmapState.height.toFloat() / dstH.toFloat()
val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0)
val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0)
val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width)
val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height)
val w = srcRectRight - srcRectLeft
val h = srcRectBottom - srcRectTop
if (w > 0 && h > 0) {
drawImage(
image = bitmapState.asImageBitmap(),
srcOffset = IntOffset(srcRectLeft, srcRectTop),
srcSize = IntSize(w, h),
dstOffset = IntOffset(rect.left, rect.top),
dstSize = IntSize(rect.width(), rect.height()),
colorFilter = null,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
}
}
}
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling) {
tiles.forEach { tile ->
@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer(
srcOffset = IntOffset.Zero,
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
dstSize = IntSize(
tile.renderRect.width(), tile.renderRect.height()
),
dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
imageRects.forEach { imgRect ->
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
val iw = intersectRight - intersectLeft
val ih = intersectBottom - intersectTop
if (iw > 0 && ih > 0) {
val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width()
val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height()
val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt()
val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt()
val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt()
val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt()
val srcW = srcRight - srcLeft
val srcH = srcBottom - srcTop
if (srcW > 0 && srcH > 0) {
drawImage(
image = tile.bitmap.asImageBitmap(),
srcOffset = IntOffset(srcLeft, srcTop),
srcSize = IntSize(srcW, srcH),
dstOffset = IntOffset(intersectLeft, intersectTop),
dstSize = IntSize(iw, ih),
colorFilter = null,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
}
}
}
}
}
}
}
@ -4497,7 +4642,9 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
targetWidth = data.targetWidth,
targetHeight = data.targetHeight,
colorFilter = data.colorFilter.item,
isDarkMode = data.isDarkMode
isDarkMode = data.isDarkMode,
excludeImages = data.excludeImages,
imageRects = data.imageRects.item
)
}
@ -4571,6 +4718,7 @@ private fun PdfPageRenderer(
drawingState: PdfDrawingState?,
onCanvasSizeChanged: (Float, Float) -> Unit,
scale: Float,
uiScale: Float,
offset: Offset,
startHandlePos: Offset?,
endHandlePos: Offset?,
@ -4697,6 +4845,12 @@ private fun PdfPageRenderer(
}
}
if (textBoxes.isNotEmpty()) {
androidx.compose.runtime.SideEffect {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}")
}
}
textBoxes.forEach { box ->
val isDraggingThisBox = (box.id == draggingBoxId)
val boxAlpha = if (isDraggingThisBox) 0f else 1f
@ -4707,17 +4861,27 @@ private fun PdfPageRenderer(
isSelected = (box.id == selectedTextBoxId),
isEditMode = isEditMode,
isDarkMode = staticData.isDarkMode,
scale = uiScale,
pageWidthPx = staticData.targetWidth.toFloat(),
pageHeightPx = staticData.targetHeight.toFloat(),
handlePosition = HandlePosition.AUTO,
onBoundsChanged = { newBounds ->
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId")
if (draggingBoxId != box.id) {
onTextBoxChange(box.copy(relativeBounds = newBounds))
} else {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally")
}
},
onTextChanged = { newText ->
onTextBoxChange(box.copy(text = newText))
},
onSelect = { onTextBoxSelect(box.id) },
onSelect = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]")
onTextBoxSelect(box.id)
},
onDragStart = { touchOffset ->
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset")
if (isVerticalScroll) {
val topLeft = Offset(
box.relativeBounds.left * staticData.targetWidth,
@ -4729,10 +4893,12 @@ private fun PdfPageRenderer(
}
},
onDrag = { delta, currentBounds ->
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
if (isVerticalScroll) {
onTextBoxDrag(delta)
} else {
onTextBoxDrag(delta)
val scaledDelta = delta * scale
onTextBoxDrag(scaledDelta)
val width = staticData.targetWidth
val edgeThreshold = 60f
@ -4746,9 +4912,11 @@ private fun PdfPageRenderer(
}
},
onDragEnd = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
onTextBoxDragEnd()
},
onDragCancel = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
onTextBoxDragEnd()
},
modifier = Modifier

View file

@ -0,0 +1,373 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit
import com.aryan.reader.BuildConfig
import com.aryan.reader.ReaderTheme
import com.aryan.reader.epubreader.SystemUiMode
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
internal const val TTS_MODE_KEY = "tts_mode"
internal const val DISPLAY_MODE_KEY = "pdf_display_mode"
internal const val PDF_DARK_MODE_KEY = "pdf_dark_mode"
internal const val OCR_LANGUAGE_KEY = "ocr_language_key"
internal const val OCR_LANGUAGE_SELECTED_KEY = "ocr_language_selected_key"
internal const val DOCK_LOCATION_KEY = "dock_location"
internal const val DOCK_OFFSET_X_KEY = "dock_offset_x"
internal const val DOCK_OFFSET_Y_KEY = "dock_offset_y"
internal const val PDF_AUTO_SCROLL_SPEED_KEY = "pdf_auto_scroll_speed"
internal const val PDF_AUTO_SCROLL_USE_SLIDER_KEY = "pdf_auto_scroll_use_slider"
internal const val PDF_AUTO_SCROLL_MIN_SPEED_KEY = "pdf_auto_scroll_min_speed"
internal const val PDF_AUTO_SCROLL_MAX_SPEED_KEY = "pdf_auto_scroll_max_speed"
internal const val STYLUS_ONLY_MODE_KEY = "stylus_only_mode"
private const val PDF_AUTO_SCROLL_IS_LOCAL_PREFIX = "pdf_as_local_"
private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
enum class PdfReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
SEARCH("Search", "Bottom Bar"),
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
AI_FEATURES("AI Features", "Bottom Bar"),
EDIT_MODE("Edit Mode", "Bottom Bar"),
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
READING_MODE("Reading Mode", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
REFLOW("Text View (Reflow)", "Overflow Menu"),
SHARE("Share", "Overflow Menu"),
SAVE_COPY("Save Copy", "Overflow Menu"),
PRINT("Print", "Overflow Menu")
}
val PdfBuiltInThemes = listOf(
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
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)
)
internal fun loadPdfHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
}
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
}
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return PdfHighlightColor.entries.associateWith {
val defaultArgb = it.color.toArgb()
val savedArgb = prefs.getInt("custom_highlight_${it.name}", defaultArgb)
Color(savedArgb)
}
}
internal fun saveCustomHighlightColors(context: Context, colors: Map<PdfHighlightColor, Color>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
colors.forEach { (colorEnum, color) ->
putInt("custom_highlight_${colorEnum.name}", color.toArgb())
}
}
}
internal fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_KEEP_SCREEN_ON_KEY, isEnabled) }
}
internal fun loadKeepScreenOn(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_KEEP_SCREEN_ON_KEY, false)
}
internal fun savePdfSystemUiMode(context: Context, mode: SystemUiMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putInt(PDF_SYSTEM_UI_MODE_KEY, mode.id) }
}
internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getInt(PDF_SYSTEM_UI_MODE_KEY, SystemUiMode.SYNC.id)
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
}
internal fun savePdfThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_THEME_KEY, themeId) }
}
internal 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"
}
internal fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
internal fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
}
internal fun loadExternalDictPackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
}
internal fun saveExternalDictPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
internal fun loadExternalTranslatePackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
}
internal fun saveExternalTranslatePackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
}
internal fun loadExternalSearchPackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
}
internal fun saveExternalSearchPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
internal fun savePdfMusicianMode(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) }
}
internal fun loadPdfMusicianMode(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_MUSICIAN_MODE_KEY, false)
}
internal fun savePdfScrollLocked(context: Context, bookId: String, isLocked: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, isLocked) }
}
internal fun loadPdfScrollLocked(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false)
}
internal fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
}
internal fun loadPdfAutoScrollLocalMode(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, false)
}
internal fun savePdfAutoScrollLocalSettings(context: Context, bookId: String, speed: Float, min: Float, max: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, speed)
putFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, min)
putFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, max)
}
}
internal fun loadPdfAutoScrollLocalSettings(context: Context, bookId: String): Triple<Float, Float, Float>? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
if (!prefs.contains(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId)) return null
val speed = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, 3.0f)
val min = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, 0.1f)
val max = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, 10.0f)
return Triple(speed, min, max)
}
internal fun savePdfAutoScrollMinSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, speed) }
}
internal fun saveStylusOnlyMode(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(STYLUS_ONLY_MODE_KEY, isEnabled) }
}
internal fun loadStylusOnlyMode(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(STYLUS_ONLY_MODE_KEY, false)
}
internal fun loadPdfAutoScrollMinSpeed(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, 0.1f)
}
internal fun savePdfAutoScrollMaxSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, speed) }
}
internal fun loadPdfAutoScrollMaxSpeed(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, 10.0f)
}
internal fun savePdfAutoScrollUseSlider(context: Context, useSlider: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, useSlider) }
}
internal fun loadPdfAutoScrollUseSlider(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, false)
}
internal fun savePdfAutoScrollSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(PDF_AUTO_SCROLL_SPEED_KEY, speed) }
}
internal fun loadPdfAutoScrollSpeed(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(PDF_AUTO_SCROLL_SPEED_KEY, 3.0f)
}
internal fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat("pdf_locked_scale_$bookId", scale)
putFloat("pdf_locked_offset_x_$bookId", offsetX)
putFloat("pdf_locked_offset_y_$bookId", offsetY)
}
}
internal fun loadPdfLockedState(context: Context, bookId: String): Triple<Float, Float, Float>? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
if (!prefs.contains("pdf_locked_scale_$bookId")) return null
return Triple(
prefs.getFloat("pdf_locked_scale_$bookId", 1f),
prefs.getFloat("pdf_locked_offset_x_$bookId", 0f),
prefs.getFloat("pdf_locked_offset_y_$bookId", 0f)
)
}
internal fun saveOcrLanguage(context: Context, language: OcrLanguage) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(OCR_LANGUAGE_KEY, language.name)
putBoolean(OCR_LANGUAGE_SELECTED_KEY, true)
}
}
internal fun loadOcrLanguage(context: Context): OcrLanguage {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val name = prefs.getString(OCR_LANGUAGE_KEY, OcrLanguage.LATIN.name)
return try {
OcrLanguage.valueOf(name ?: OcrLanguage.LATIN.name)
} catch (_: Exception) {
OcrLanguage.LATIN
}
}
internal fun hasUserSelectedOcrLanguage(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(OCR_LANGUAGE_SELECTED_KEY, false)
}
internal fun saveDockState(context: Context, location: DockLocation, offset: Offset) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(DOCK_LOCATION_KEY, location.name)
putFloat(DOCK_OFFSET_X_KEY, offset.x)
putFloat(DOCK_OFFSET_Y_KEY, offset.y)
}
}
internal fun loadDockState(context: Context): Pair<DockLocation, Offset> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val locName = prefs.getString(DOCK_LOCATION_KEY, DockLocation.BOTTOM.name)
val location = try {
DockLocation.valueOf(locName ?: DockLocation.BOTTOM.name)
} catch (_: Exception) {
DockLocation.BOTTOM
}
val x = prefs.getFloat(DOCK_OFFSET_X_KEY, 0f)
val y = prefs.getFloat(DOCK_OFFSET_Y_KEY, 0f)
return location to Offset(x, y)
}
internal fun saveDisplayMode(context: Context, mode: DisplayMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(DISPLAY_MODE_KEY, mode.name) }
}
internal fun loadDisplayMode(context: Context): DisplayMode {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val modeName = prefs.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name)
return try {
DisplayMode.valueOf(modeName ?: DisplayMode.VERTICAL_SCROLL.name)
} catch (_: IllegalArgumentException) {
DisplayMode.VERTICAL_SCROLL
}
}
internal data class TtsPageData(
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
)
internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_DARK_MODE_KEY, isDark) }
}
internal fun loadPdfDarkMode(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
}

View file

@ -0,0 +1,236 @@
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.aryan.reader.R
import com.aryan.reader.SearchResult
@Composable
internal fun SearchNavigationPill(
text: String,
mode: SearchHighlightMode,
onToggleMode: () -> Unit,
onPrev: () -> Unit,
onNext: () -> Unit,
onTextClick: () -> Unit,
isPrevEnabled: Boolean = true,
isNextEnabled: Boolean = true
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.shadow(6.dp, RoundedCornerShape(50))
.height(56.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp)
) {
// Toggle Mode
IconButton(onClick = onToggleMode) {
Icon(
imageVector = if (mode == SearchHighlightMode.ALL) Icons.Default.Visibility
else Icons.Default.VisibilityOff,
contentDescription = "Toggle Highlights",
tint = if (mode == SearchHighlightMode.ALL) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Vertical Divider
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.3f
)
)
)
// Prev
IconButton(onClick = onPrev, enabled = isPrevEnabled) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = "Previous",
tint = if (isPrevEnabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
// Counter/Text
Box(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onTextClick
)
.padding(horizontal = 8.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
// Next
IconButton(onClick = onNext, enabled = isNextEnabled) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Next",
tint = if (isNextEnabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
}
@Composable
fun PdfSearchResultsPanel(
lazyResults: LazyPagingItems<SearchResult>,
totalPageCount: Int,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (lazyResults.itemCount == 0 && lazyResults.loadState.refresh !is LoadState.Loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
}
} else {
Column {
Text(
text = stringResource(R.string.msg_results_found_pages),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
HorizontalDivider()
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
val result = lazyResults[index]
if (result != null) {
ListItem(
headlineContent = {
Text(
result.locationTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}, supportingContent = {
Text(
result.snippet, style = MaterialTheme.typography.bodyMedium
)
}, modifier = Modifier
.testTag(
"SearchResultItem_${result.locationInSource}"
)
.clickable { onResultClick(result) })
HorizontalDivider()
}
}
}
}
}
if (lazyResults.loadState.refresh is LoadState.Loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
@Composable
fun PdfSearchResultsList(
results: List<SearchResult>,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (results.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.search_no_results_simple), style = MaterialTheme.typography.bodyLarge)
}
} else {
Column {
Text(
text = "${results.size} matches found",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
HorizontalDivider()
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
itemsIndexed(results) { _, result ->
ListItem(
headlineContent = {
Text(
result.locationTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}, supportingContent = {
Text(
result.snippet, style = MaterialTheme.typography.bodyMedium
)
}, modifier = Modifier
.testTag(
"SearchResultItem_${result.locationInSource}"
)
.clickable { onResultClick(result) })
HorizontalDivider()
}
}
}
}
}
}

View file

@ -0,0 +1,153 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader.pdf
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode
@Composable
fun PdfCustomizeToolsSheet(
hiddenTools: Set<String>,
onUpdate: (Set<String>) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text(
text = stringResource(R.string.title_customize_toolbar),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.desc_customize_toolbar),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) {
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
item {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
items(tools) { tool ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
val newSet = hiddenTools.toMutableSet()
if (newSet.contains(tool.name)) newSet.remove(tool.name)
else newSet.add(tool.name)
onUpdate(newSet)
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = !hiddenTools.contains(tool.name),
onCheckedChange = { isVisible ->
val newSet = hiddenTools.toMutableSet()
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
onUpdate(newSet)
}
)
}
}
}
}
}
}
}
@Composable
fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode,
onSystemUiModeChange: (SystemUiMode) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(12.dp))
OptionSegmentedControl(
options = SystemUiMode.entries,
selectedOption = systemUiMode,
onOptionSelected = onSystemUiModeChange,
getLabel = { it.title }
)
}
}
}

View file

@ -21,7 +21,8 @@ package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -52,7 +53,12 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
@ -78,6 +84,49 @@ enum class HandlePosition {
TOP, BOTTOM, AUTO
}
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
suspend fun PointerInputScope.detectEagerDragGestures(
onDragStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
onDrag: (PointerInputChange, Offset) -> Unit
) {
awaitEachGesture {
var dragStarted = false
try {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Consume immediately
onDragStart(down.position)
dragStarted = true
val pointerId = down.id
var canceled = false
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) {
canceled = true
break
}
if (change.changedToUp()) {
change.consume()
break
}
if (change.positionChanged()) {
val dragAmount = change.position - change.previousPosition
change.consume()
onDrag(change, dragAmount)
}
}
if (canceled) onDragCancel() else onDragEnd()
dragStarted = false
} finally {
if (dragStarted) {
onDragCancel()
}
}
}
}
@Composable
fun ResizableTextBox(
box: PdfTextBox,
@ -86,6 +135,7 @@ fun ResizableTextBox(
isDarkMode: Boolean,
pageWidthPx: Float,
pageHeightPx: Float,
scale: Float = 1f,
onBoundsChanged: (Rect) -> Unit,
onTextChanged: (String) -> Unit,
onSelect: () -> Unit,
@ -101,8 +151,9 @@ fun ResizableTextBox(
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
val handleSize = 10.dp
val handleTouchSize = 40.dp
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
val handleSize = (10f / scale).dp
val handleTouchSize = (40f / scale).dp
val handleSizePx = with(density) { handleSize.toPx() }
val halfHandlePx = handleSizePx / 2f
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
@ -126,7 +177,9 @@ fun ResizableTextBox(
}
}
}
androidx.compose.runtime.SideEffect {
Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}")
}
var currentRectPx by remember {
mutableStateOf(
Rect(
@ -152,6 +205,8 @@ fun ResizableTextBox(
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx")
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
@ -162,20 +217,19 @@ fun ResizableTextBox(
}
}
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
derivedStateOf {
when (handlePosition) {
// Freeze handle position while dragging to prevent UI jumping
var isHandleAtTop by remember { mutableStateOf(false) }
LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) {
if (!isDraggingOrResizing) {
isHandleAtTop = when (handlePosition) {
HandlePosition.TOP -> true
HandlePosition.BOTTOM -> false
HandlePosition.AUTO -> {
if (pageHeightPx <= 0f) {
false
} else {
val spaceBelow = pageHeightPx - currentRectPx.bottom
spaceBelow < requiredBottomSpacePx
}
if (pageHeightPx <= 0f) false
else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx
}
}
}
@ -184,11 +238,9 @@ fun ResizableTextBox(
Box(
modifier = modifier
.zIndex(if (isSelected) 10f else 0f)
.offset {
IntOffset(
(currentRectPx.left - halfHandlePx).roundToInt(),
(currentRectPx.top - halfHandlePx).roundToInt()
)
.graphicsLayer {
translationX = currentRectPx.left - halfHandlePx
translationY = currentRectPx.top - halfHandlePx
}
.size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
@ -201,10 +253,13 @@ fun ResizableTextBox(
.fillMaxSize()
.padding(handleSize / 2)
.pointerInput(Unit) {
detectTapGestures { onSelect() }
detectTapGestures {
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
onSelect()
}
}
.then(
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
)
) {
BasicTextField(
@ -220,7 +275,7 @@ fun ResizableTextBox(
background = box.backgroundColor,
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
(box.fontSize * pageHeightPx).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
@ -267,8 +322,11 @@ fun ResizableTextBox(
}
.size(handleTouchSize)
.pointerInput(onBoundsChanged) {
detectDragGestures(
onDragStart = { isDraggingOrResizing = true },
detectEagerDragGestures(
onDragStart = {
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
isDraggingOrResizing = true
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
@ -277,40 +335,42 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
change.consume()
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f
val minSize = 50f / scale
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
t = (t + dy).coerceIn(0f, b - minSize)
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
t = (t + dy).coerceIn(0f, b - minSize)
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
else -> {}
}
currentRectPx = Rect(l, t, r, b)
@ -328,13 +388,15 @@ fun ResizableTextBox(
DragPill(
isDarkMode = isDarkMode,
scale = scale,
modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
.zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
detectDragGestures(
detectEagerDragGestures(
onDragStart = { offset ->
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
isDraggingOrResizing = true
onDragStart(offset)
},
@ -346,6 +408,7 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized)
onDragEnd()
},
@ -354,13 +417,12 @@ fun ResizableTextBox(
onDragCancel()
}
) { change, dragAmount ->
change.consume()
val w = currentRectPx.width
val h = currentRectPx.height
val rawLeft = currentRectPx.left + dragAmount.x
val rawTop = currentRectPx.top + dragAmount.y
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w))
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect
onDrag(dragAmount, newRect)
@ -374,21 +436,22 @@ fun ResizableTextBox(
@Composable
private fun DragPill(
modifier: Modifier = Modifier,
isDarkMode: Boolean
isDarkMode: Boolean,
scale: Float = 1f
) {
Surface(
modifier = modifier
.size(width = 48.dp, height = 24.dp),
.size(width = (48f / scale).dp, height = (24f / scale).dp),
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = 4.dp
shadowElevation = (4f / scale).dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.drag_handle),
contentDescription = "Drag to move text box",
modifier = Modifier.size(20.dp)
modifier = Modifier.size((20f / scale).dp)
)
}
}

View file

@ -0,0 +1,229 @@
package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import org.json.JSONArray
import timber.log.Timber
private const val MAX_FIXED_RECURSION = 128
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>()
for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add(
TocEntry(
title = title,
pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level
)
)
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
}
}
return entries
}
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet()
return try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).mapNotNull { i ->
try {
val json = jsonArray.getJSONObject(i)
PdfBookmark(
pageIndex = json.getInt("pageIndex"),
title = json.getString("title"),
totalPages = json.getInt("totalPages")
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmark from JSON object")
null
}
}.toSet()
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
emptySet()
}
}
@Composable
internal fun PdfTocTreeItem(
label: String,
nestLevel: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(enabled = hasChildren, onClick = onToggleExpand),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}

View file

@ -0,0 +1,541 @@
// PdfToolbars.kt
package com.aryan.reader.pdf
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import com.aryan.reader.data.RecentFileItem
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import com.aryan.reader.BuildConfig
import com.aryan.reader.FileType
import com.aryan.reader.R
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PdfTopBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
systemUiMode: SystemUiMode,
statusBarHeightDp: Dp,
searchState: SearchState,
focusRequester: FocusRequester,
onCloseSearch: () -> Unit,
isLoadingDocument: Boolean,
errorMessage: String?,
currentPageForDisplay: Int,
totalPages: Int,
pagerStatePageCount: Int,
hiddenTools: Set<String>,
isScrollLocked: Boolean,
isEditMode: Boolean,
displayMode: DisplayMode,
isKeepScreenOn: Boolean,
isTtsSessionActive: Boolean,
isBookmarked: Boolean,
canDeletePage: Boolean,
isReflowingThisBook: Boolean,
hasReflowFile: Boolean,
isPdfDocumentLoaded: Boolean,
isTabsEnabled: Boolean,
openTabs: List<RecentFileItem>,
activeTabBookId: String?,
effectiveFileType: FileType,
onNavigateBack: () -> Unit,
onShowThemePanel: () -> Unit,
onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit,
onShowPenPlayground: () -> Unit,
onImportSvg: () -> Unit,
onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit,
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
onShowTtsSettings: () -> Unit,
onToggleBookmark: () -> Unit,
onInsertPage: () -> Unit,
onDeletePage: () -> Unit,
onReflowAction: () -> Unit,
onShare: () -> Unit,
onSaveCopy: () -> Unit,
onPrint: () -> Unit,
onTabClick: (String) -> Unit,
onTabClose: (String) -> Unit,
onNewTabClick: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars,
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
val isStatusBarVisible = when (systemUiMode) {
SystemUiMode.DEFAULT -> true
SystemUiMode.SYNC -> showStandardBars
SystemUiMode.HIDDEN -> false
}
val topBarPadding = if (isStatusBarVisible) statusBarHeightDp else 0.dp
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Column(modifier = Modifier.fillMaxWidth().padding(top = topBarPadding)) {
Row(
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (searchState.isSearchActive) {
SearchTopBar(
searchState = searchState,
focusRequester = focusRequester,
onCloseSearch = onCloseSearch
)
} else {
TooltipIconButton(
text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc),
onClick = onNavigateBack
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
val titleText = when {
isLoadingDocument -> stringResource(R.string.loading_pdf)
errorMessage != null -> stringResource(R.string.error_loading_pdf)
totalPages > 0 && pagerStatePageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages"
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
else -> "PDF Viewer"
}
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
)
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary Settings", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (BuildConfig.DEBUG) {
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
}
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
}
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
}
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = { showMoreMenu = false; onShowCustomizeTools() },
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_ocr_language)) },
onClick = { showMoreMenu = false; onShowOcrLanguage() }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = { showMoreMenu = false; onShowVisualOptions() },
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
onClick = { showMoreMenu = false; onStartAutoScroll() }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive,
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
onClick = { showMoreMenu = false; onToggleBookmark() }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
onClick = { showMoreMenu = false; onInsertPage() }
)
if (canDeletePage) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete_page)) },
onClick = { showMoreMenu = false; onDeletePage() },
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
)
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
DropdownMenuItem(
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
onClick = { showMoreMenu = false; onReflowAction() },
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_share)) },
onClick = { showMoreMenu = false; onShare() },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
)
}
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
onClick = { showMoreMenu = false; onSaveCopy() },
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
)
}
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_print)) },
onClick = { showMoreMenu = false; onPrint() },
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
}
}
}
}
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
LazyRow(
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
verticalAlignment = Alignment.Bottom
) {
items(openTabs, key = { it.bookId }) { tab ->
val isSelected = tab.bookId == activeTabBookId
val bgColor = if (isSelected) MaterialTheme.colorScheme.surface else Color.Transparent
val contentColor = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.height(if (isSelected) 44.dp else 36.dp)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(bgColor)
.clickable { onTabClick(tab.bookId) }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = tab.customName ?: tab.title ?: tab.displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 140.dp),
style = MaterialTheme.typography.labelLarge,
color = contentColor
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { onTabClose(tab.bookId) },
modifier = Modifier.size(20.dp)
) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close_tab), modifier = Modifier.size(16.dp), tint = contentColor)
}
}
}
item {
IconButton(onClick = onNewTabClick, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp).size(36.dp)) {
Icon(Icons.Default.Add, contentDescription = "New Tab", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
}
}
@Composable
fun ReflowProgressOverlay(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
isReflowingThisBook: Boolean,
reflowProgressValue: Float
) {
AnimatedVisibility(
visible = showStandardBars && isReflowingThisBook,
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
shadowElevation = 4.dp
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.generating_text_view),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = "${(reflowProgressValue * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { reflowProgressValue },
modifier = Modifier.fillMaxWidth().height(6.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
@Composable
fun PdfBottomBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
searchStateActive: Boolean,
systemUiMode: SystemUiMode,
navBarHeightDp: Dp,
hiddenTools: Set<String>,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
isEditMode: Boolean,
isTtsSessionActive: Boolean,
ttsErrorMessage: String?,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive,
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
val isNavBarVisible = systemUiMode != SystemUiMode.HIDDEN
val bottomBarPadding = if (isNavBarVisible) navBarHeightDp else 0.dp
Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp) {
val bottomBarScrollState = rememberScrollState()
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
}
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("TocButton")
) {
Icon(Icons.Default.Menu, contentDescription = "Table of Contents")
}
}
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("SearchButton")
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = "Highlight all text", tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = "Toggle Editing Mode", tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS", tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
ttsErrorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
}

View file

@ -184,11 +184,12 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
@OptIn(FlowPreview::class)
@Composable
internal fun PdfVerticalReader(
modifier: Modifier = Modifier,
state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>,
activeTheme: com.aryan.reader.ReaderTheme,
excludeImages: Boolean = false,
totalPages: Int,
modifier: Modifier = Modifier,
virtualPages: List<VirtualPage> = emptyList(),
pageAspectRatios: StableHolder<List<Float>>,
headerHeight: Dp,
@ -284,6 +285,11 @@ internal fun PdfVerticalReader(
val dividerHeightDp = 8.dp
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) }
var isDragging by remember { mutableStateOf(false) }
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
@ -373,8 +379,41 @@ internal fun PdfVerticalReader(
var isInitialLayout by remember { mutableStateOf(true) }
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
var hasRestoredLockedState by remember { mutableStateOf(false) }
LaunchedEffect(isScrollLocked, lockedState, totalDocHeight, screenWidth, isInteracting) {
if (!hasRestoredLockedState && isScrollLocked && lockedState != null && totalDocHeight > 0f && screenWidth > 0f && !isInteracting) {
val (savedScale, savedPanX, savedPanY) = lockedState
Timber.tag("PdfLockDiagnostic").i("RESTORING: Scale=$savedScale, X=$savedPanX, Y=$savedPanY")
val zoomedDocWidth = screenWidth * savedScale
val minPanX = if (zoomedDocWidth < screenWidth) (screenWidth - zoomedDocWidth) / 2f else -(zoomedDocWidth - screenWidth)
val maxPanX = if (zoomedDocWidth < screenWidth) minPanX else 0f
val zoomedDocHeight = totalDocHeight * savedScale
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val maxPanY = headerHeightPx
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(minPanX, maxPanX)
panYAnimatable.updateBounds(minPanY, maxPanY)
zoomAnimatable.snapTo(savedScale)
panXAnimatable.snapTo(savedPanX)
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
hasRestoredLockedState = true
}
}
LaunchedEffect(layoutState.pages) {
if (!isInitialLayout) {
if (!isInitialLayout && !isScrollLocked) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue
} else {
@ -410,21 +449,13 @@ internal fun PdfVerticalReader(
launch { panXAnimatable.snapTo(targetPanX) }
launch { panYAnimatable.snapTo(finalPanY) }
}
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
state.currentPage = targetPageIdx
if (isFit) onZoomChange(targetZoom)
}
}
if (!isInitialLayout) {
delay(50)
isResizing = false
targetPageDuringResize.intValue = -1
} else if (isScrollLocked && lockedState != null) {
val (savedScale, savedPanX, _) = lockedState
coroutineScope {
launch { zoomAnimatable.snapTo(savedScale) }
launch { panXAnimatable.snapTo(savedPanX) }
}
}
isInitialLayout = false
}
@ -461,11 +492,6 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY)
}
var isFlinging by remember { mutableStateOf(false) }
var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) }
var isDragging by remember { mutableStateOf(false) }
LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) {
@ -483,6 +509,9 @@ internal fun PdfVerticalReader(
panYAnimatable.snapTo(y)
}
if (x != currentPanX) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x")
}
panXAnimatable.snapTo(x)
}
}
@ -585,8 +614,8 @@ internal fun PdfVerticalReader(
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = -15f * ratio
} else if (draggingBoxOffset.y + draggingBoxSize.height > bottomEdge - scrollZone) {
val boxBottom = draggingBoxOffset.y + draggingBoxSize.height
} else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) {
val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value)
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = 15f * ratio
@ -744,6 +773,9 @@ internal fun PdfVerticalReader(
}
if (!isAnimating) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]")
}
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
} else {
@ -1575,6 +1607,7 @@ internal fun PdfVerticalReader(
virtualPage = virtualPage,
totalPages = totalPages,
activeTheme = activeTheme,
excludeImages = excludeImages,
externalScale = highResScale,
onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights,
@ -1638,17 +1671,20 @@ internal fun PdfVerticalReader(
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
draggingBoxSize = Size(
box.relativeBounds.width * page.width * currentZoom,
box.relativeBounds.height * page.height * currentZoom
box.relativeBounds.width * page.width,
box.relativeBounds.height * page.height
)
draggingBoxPageHeight = page.height * currentZoom
draggingBoxPageHeight = page.height
draggingBoxOffset = Offset(boxScreenX, boxScreenY)
draggingBoxTouchDelta = touchOffset * currentZoom
draggingBoxId = box.id
},
onTextBoxDrag = { dragDelta ->
draggingBoxOffset += dragDelta
val currentZoom = zoomAnimatable.value
val scaledDelta = dragDelta * currentZoom
Timber.tag("PdfTextBoxDebug").v("VerticalReader onTextBoxDrag dragDelta=$dragDelta zoom=$currentZoom scaledDelta=$scaledDelta")
draggingBoxOffset += scaledDelta
},
onTextBoxDragEnd = {
scope.launch {
@ -1681,9 +1717,9 @@ internal fun PdfVerticalReader(
val rawRelY =
(finalBoxY / currentZoom) / targetPage.height
val relW =
(draggingBoxSize.width / currentZoom) / targetPage.width
draggingBoxSize.width / targetPage.width
val relH =
(draggingBoxSize.height / currentZoom) / targetPage.height
draggingBoxSize.height / targetPage.height
val clampedW = relW.coerceAtMost(1f)
val clampedH = relH.coerceAtMost(1f)
@ -2051,7 +2087,8 @@ internal fun PdfVerticalReader(
val fontScaleRatio =
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f
val boxBottomY = draggingBoxOffset.y + draggingBoxSize.height
val currentZoom = zoomAnimatable.value
val boxBottomY = draggingBoxOffset.y + (draggingBoxSize.height * currentZoom)
val spaceBelow = screenHeight - boxBottomY
val overlayHandlePos =
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM
@ -2062,6 +2099,11 @@ internal fun PdfVerticalReader(
draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
)
}
.graphicsLayer {
scaleX = currentZoom
scaleY = currentZoom
transformOrigin = TransformOrigin(0f, 0f)
}
.zIndex(100f)) {
ResizableTextBox(
box = draggedBox.copy(
@ -2073,6 +2115,7 @@ internal fun PdfVerticalReader(
isDarkMode = isDarkMode,
pageWidthPx = draggingBoxSize.width,
pageHeightPx = draggingBoxSize.height,
scale = currentZoom,
handlePosition = overlayHandlePos,
onBoundsChanged = {},
onTextChanged = {},

File diff suppressed because it is too large Load diff

View file

@ -35,14 +35,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -118,7 +115,6 @@ import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import timber.log.Timber
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
private enum class ColorMenuMode {
@ -176,18 +172,6 @@ fun TextAnnotationDock(
val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val imeBottom = WindowInsets.ime.getBottom(density)
val navBottom = WindowInsets.navigationBars.getBottom(density)
val spacerHeightPx = max(0, imeBottom - navBottom)
val spacerHeightDp = with(density) { spacerHeightPx.toDp() }
val effectiveSpacerHeight = if (spacerHeightDp > 0.dp) {
spacerHeightDp
} else {
bottomDockPadding
}
LaunchedEffect(activePopup) {
if (activePopup == ActivePopup.NONE) {
activeMenuMode = ColorMenuMode.PALETTE
@ -200,7 +184,7 @@ fun TextAnnotationDock(
val dockBarHeight = 48.dp
val margin = 8.dp
val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin)
val finalOffsetY = -(bottomDockPadding + dockBarHeight + margin)
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY
@ -724,7 +708,7 @@ fun TextAnnotationDock(
}
}
}
Spacer(modifier = Modifier.height(effectiveSpacerHeight))
Spacer(modifier = Modifier.height(bottomDockPadding))
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.materialkolor.PaletteStyle
import androidx.compose.ui.platform.LocalContext
@ -118,36 +119,58 @@ fun AppTheme(
content: @Composable () -> Unit
) {
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val context = LocalContext.current
val colorScheme = when {
val baseColorScheme = remember(
darkTheme, dynamicColor, supportsDynamicColor, seedColor, contrastLevel
) {
when {
seedColor != null -> dynamicColorScheme(
seedColor = seedColor,
isDark = darkTheme,
contrastLevel = contrastLevel,
style = PaletteStyle.Fidelity
)
dynamicColor && supportsDynamicColor -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
}
val finalColorScheme = colorScheme.copy(
onPrimary = colorScheme.onPrimary.copy(alpha = textDimFactor),
onSecondary = colorScheme.onSecondary.copy(alpha = textDimFactor),
onTertiary = colorScheme.onTertiary.copy(alpha = textDimFactor),
onBackground = colorScheme.onBackground.copy(alpha = textDimFactor),
onSurface = colorScheme.onSurface.copy(alpha = textDimFactor),
onSurfaceVariant = colorScheme.onSurfaceVariant.copy(alpha = textDimFactor),
onError = colorScheme.onError.copy(alpha = textDimFactor),
onPrimaryContainer = colorScheme.onPrimaryContainer.copy(alpha = textDimFactor),
onSecondaryContainer = colorScheme.onSecondaryContainer.copy(alpha = textDimFactor),
onTertiaryContainer = colorScheme.onTertiaryContainer.copy(alpha = textDimFactor),
onErrorContainer = colorScheme.onErrorContainer.copy(alpha = textDimFactor)
val finalColorScheme = remember(baseColorScheme, textDimFactor) {
if (textDimFactor >= 1.0f) {
baseColorScheme
} else {
baseColorScheme.copy(
primary = baseColorScheme.primary.copy(alpha = textDimFactor),
secondary = baseColorScheme.secondary.copy(alpha = textDimFactor),
tertiary = baseColorScheme.tertiary.copy(alpha = textDimFactor),
error = baseColorScheme.error.copy(alpha = textDimFactor),
primaryContainer = baseColorScheme.primaryContainer.copy(alpha = textDimFactor),
secondaryContainer = baseColorScheme.secondaryContainer.copy(alpha = textDimFactor),
tertiaryContainer = baseColorScheme.tertiaryContainer.copy(alpha = textDimFactor),
errorContainer = baseColorScheme.errorContainer.copy(alpha = textDimFactor),
outline = baseColorScheme.outline.copy(alpha = textDimFactor),
outlineVariant = baseColorScheme.outlineVariant.copy(alpha = textDimFactor),
inversePrimary = baseColorScheme.inversePrimary.copy(alpha = textDimFactor),
inverseOnSurface = baseColorScheme.inverseOnSurface.copy(alpha = textDimFactor),
onPrimary = baseColorScheme.onPrimary.copy(alpha = textDimFactor),
onSecondary = baseColorScheme.onSecondary.copy(alpha = textDimFactor),
onTertiary = baseColorScheme.onTertiary.copy(alpha = textDimFactor),
onBackground = baseColorScheme.onBackground.copy(alpha = textDimFactor),
onSurface = baseColorScheme.onSurface.copy(alpha = textDimFactor),
onSurfaceVariant = baseColorScheme.onSurfaceVariant.copy(alpha = textDimFactor),
onError = baseColorScheme.onError.copy(alpha = textDimFactor),
onPrimaryContainer = baseColorScheme.onPrimaryContainer.copy(alpha = textDimFactor),
onSecondaryContainer = baseColorScheme.onSecondaryContainer.copy(alpha = textDimFactor),
onTertiaryContainer = baseColorScheme.onTertiaryContainer.copy(alpha = textDimFactor),
onErrorContainer = baseColorScheme.onErrorContainer.copy(alpha = textDimFactor),
)
}
}
MaterialTheme(
colorScheme = finalColorScheme,

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Reader" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="Theme.Reader" parent="android:Theme.Material.Light.NoActionBar" />
<!-- Modern Splash Screen Theme -->
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<!-- You can specify a background color here if needed, otherwise it defaults to the system's window background -->
<!-- <item name="windowSplashScreenBackground">#121212</item> -->
<item name="postSplashScreenTheme">@style/Theme.Reader</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en"/>
<locale android:name="ar"/>
<locale android:name="de"/>
<locale android:name="tr"/>
</locale-config>