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" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 44 versionCode = 45
versionName = "1.0.43" versionName = "1.0.45"
resourceConfigurations += setOf("en", "ar", "de", "tr")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild { externalNativeBuild {
@ -227,6 +229,13 @@ dependencies {
implementation("org.zwobble.mammoth:mammoth:1.4.2") implementation("org.zwobble.mammoth:mammoth:1.4.2")
implementation("com.materialkolor:material-kolor:5.0.0-alpha07") 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 { 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 <application
android:name=".MyApplication" android:name=".MyApplication"
android:localeConfig="@xml/locales_config"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@ -41,7 +42,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Reader" android:theme="@style/Theme.App.Starting"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <activity

View file

@ -1814,6 +1814,18 @@ fun loadReaderThemeId(context: Context): String {
return prefs.getString(PREF_READER_THEME, "system") ?: "system" 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>) { fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonArray = JSONArray() val jsonArray = JSONArray()
@ -1868,6 +1880,9 @@ private fun calculateContrastRatio(color1: Color, color2: Color): Float {
fun ReaderThemePanel( fun ReaderThemePanel(
isVisible: Boolean, isVisible: Boolean,
currentThemeId: String, currentThemeId: String,
excludeImages: Boolean = false,
onExcludeImagesChange: (Boolean) -> Unit = {},
showExcludeImagesOption: Boolean = false,
customThemes: List<ReaderTheme>, customThemes: List<ReaderTheme>,
builtInThemes: List<ReaderTheme> = BuiltInThemes, builtInThemes: List<ReaderTheme> = BuiltInThemes,
onThemeSelected: (String) -> Unit, onThemeSelected: (String) -> Unit,
@ -1920,6 +1935,23 @@ fun ReaderThemePanel(
modifier = Modifier.padding(bottom = 16.dp) 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) Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected) ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)

View file

@ -22,13 +22,14 @@
package com.aryan.reader package com.aryan.reader
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid 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.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.AlertDialog 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -183,6 +184,7 @@ fun HomeScreen(
var showStrictFilterDialog by remember { mutableStateOf(false) } var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) }
var showLanguageDialog by remember { mutableStateOf(false) }
val feedbackResult = val feedbackResult =
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message") navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message")
@ -335,7 +337,9 @@ fun HomeScreen(
showStrictFilterDialog = true showStrictFilterDialog = true
} }
}, },
onAppThemeClick = { showAppThemePanel = true } onAppThemeClick = { showAppThemePanel = true },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onLanguageClick = { showLanguageDialog = true }
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -509,6 +513,10 @@ fun HomeScreen(
) )
} }
if (showLanguageDialog) {
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
}
if (showAppThemePanel) { if (showAppThemePanel) {
AppThemeBottomSheet( AppThemeBottomSheet(
uiState = uiState, uiState = uiState,
@ -749,6 +757,8 @@ fun RecentFileCard(
isDownloading: Boolean, isDownloading: Boolean,
) { ) {
val context = LocalContext.current 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) { val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder 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 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 item.coverImagePath?.let { File(it) } ?: placeholder
} }
Surface( androidx.compose.material3.ElevatedCard(
modifier = modifier.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }, modifier = modifier
shape = MaterialTheme.shapes.medium, .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
tonalElevation = if (isSelected) 8.dp else 2.dp, .then(
shadowElevation = 4.dp, if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null else Modifier
) { )
Column( .clip(MaterialTheme.shapes.large)
modifier = Modifier.combinedClickable( .combinedClickable(onClick = onClick, onLongClick = onLongClick),
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( AsyncImage(
model = ImageRequest.Builder(context).data(imageModel).error(placeholder) model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
.fallback(placeholder).crossfade(true).build(), .fallback(placeholder).crossfade(true).build(),
contentDescription = item.displayName, contentDescription = item.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.height(160.dp).fillMaxWidth(), modifier = Modifier.fillMaxSize()
) )
if (item.sourceFolderUri != null) {
Box( Box(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( modifier = Modifier.fillMaxSize().background(
color = MaterialTheme.colorScheme.secondaryContainer, androidx.compose.ui.graphics.Brush.verticalGradient(
shape = CircleShape 0f to Color.Black.copy(alpha = 0.15f),
).padding(4.dp) 0.3f to Color.Transparent,
) { 0.6f to Color.Transparent,
Icon( 1f to Color.Black.copy(alpha = 0.5f)
imageVector = Icons.Default.Folder, )
contentDescription = stringResource(R.string.local_folder), )
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
}
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
if (isOpdsStream) { FileStatusBadges(
Box( item = item,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( isPinned = isPinned,
color = MaterialTheme.colorScheme.tertiaryContainer, overlay = true,
shape = CircleShape modifier = Modifier
).padding(4.dp) .align(Alignment.TopStart)
) { .padding(10.dp)
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = stringResource(R.string.opds_stream),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
) )
} }
}
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) { if (!item.isAvailable) {
Box( Box(
modifier = Modifier modifier = Modifier.matchParentSize()
.matchParentSize() .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isDownloading) { if (isDownloading) {
CircularProgressIndicator(color = Color.White) CircularProgressIndicator(color = Color.White)
} else { } else {
Icon( Icon(
imageVector = Icons.Filled.Info, Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally), contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = Color.White tint = Color.White
@ -846,39 +838,131 @@ fun RecentFileCard(
} }
} }
} }
if (isSelected) {
Box( 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) 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(80.dp) .background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.Start
verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName, text = item.cardTitle(),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f) lineHeight = 20.sp
) )
Box( Spacer(modifier = Modifier.height(4.dp))
modifier = Modifier.height(20.dp), contentAlignment = Alignment.Center
) {
item.progressPercentage?.let { progress ->
Text( Text(
text = stringResource(R.string.progress_complete, progress.toInt()), text = authorText,
style = MaterialTheme.typography.bodySmall, 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, onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit, onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit, onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit onAppThemeClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit,
onLanguageClick: () -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu 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() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache() onClearCache()
@ -995,6 +1088,14 @@ fun DefaultTopAppBar(
showOptionsMenu = false showOptionsMenu = false
}) })
if (BuildConfig.DEBUG) {
HorizontalDivider()
DropdownMenuItem(text = { Text("Test Panel ML Detection") }, onClick = {
onTestPanelDetectionClick()
showOptionsMenu = false
})
}
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") { if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
HorizontalDivider() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
@ -1760,6 +1861,7 @@ fun ThemeSwatch(
} }
} }
@SuppressLint("UnrememberedMutableState")
@Composable @Composable
fun CreateAppThemeDialog( fun CreateAppThemeDialog(
initialColor: Color = Color(0xFF6750A4), 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 package com.aryan.reader
import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable 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.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -52,6 +41,8 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -74,24 +68,28 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit 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.Folder
import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -100,6 +98,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -133,9 +132,13 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.opds.OpdsAcquisition 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.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -147,6 +150,7 @@ private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count) return pluralStringResource(id = R.plurals.book_count, count, count)
} }
@SuppressLint("LocalContextGetResourceValueCall")
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@ -605,6 +609,7 @@ fun LibraryScreenContent(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding()
.height(64.dp), .height(64.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -918,7 +923,7 @@ private fun ShelfDetailScreen(
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = Modifier.statusBarsPadding(), modifier = Modifier,
topBar = { topBar = {
if (isContextualModeActive) { if (isContextualModeActive) {
ContextualTopAppBar( ContextualTopAppBar(
@ -1071,7 +1076,7 @@ private fun AddBooksModeScreen(
var showSourceMenu by remember { mutableStateOf(false) } var showSourceMenu by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = Modifier.statusBarsPadding(), modifier = Modifier,
topBar = { topBar = {
CustomTopAppBar( CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
@ -1256,13 +1261,21 @@ private fun ShelfListItem(
onItemClick: () -> Unit, onItemClick: () -> Unit,
onItemLongClick: () -> Unit, onItemLongClick: () -> Unit,
) { ) {
Surface( androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.large,
tonalElevation = if (isSelected) 8.dp else 2.dp, colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
shadowElevation = 4.dp, containerColor = MaterialTheme.colorScheme.surfaceContainerLow
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, ),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable( .combinedClickable(
onClick = onItemClick, onClick = onItemClick,
onLongClick = { onLongClick = {
@ -1318,22 +1331,41 @@ private fun LibraryListItem(
item.coverImagePath?.let { File(it) } ?: placeholder item.coverImagePath?.let { File(it) } ?: placeholder
} }
Surface( androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.large,
tonalElevation = if (isSelected) 8.dp else 2.dp, colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
shadowElevation = 4.dp, containerColor = MaterialTheme.colorScheme.surfaceContainerLow
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, ),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 6.dp else 2.dp
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .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( .combinedClickable(
onClick = onItemClick, onClick = onItemClick,
onLongClick = onItemLongClick onLongClick = onItemLongClick
) )
) { ) {
Row( Row(
modifier = Modifier.padding(12.dp), modifier = Modifier
verticalAlignment = Alignment.Top .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( AsyncImage(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
@ -1344,100 +1376,128 @@ private fun LibraryListItem(
.build(), .build(),
contentDescription = item.displayName, contentDescription = item.displayName,
contentScale = ContentScale.Crop, 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 modifier = Modifier
.size(width = 70.dp, height = 100.dp) .weight(1f)
.clip(MaterialTheme.shapes.small) .fillMaxHeight()
) ) {
Row(
Spacer(modifier = Modifier.width(16.dp)) verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) { 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(
text = item.customName ?: item.title ?: item.displayName, text = item.cardTitle(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis, 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)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = it, text = item.cardAuthor(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
minLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Spacer(modifier = Modifier.height(8.dp)) if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
Row(verticalAlignment = Alignment.CenterVertically) { 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) FileTypeBadge(type = item.type, overlay = false)
item.progressPercentage?.let { Box(
Spacer(modifier = Modifier.width(8.dp)) modifier = Modifier
Text( .weight(1f)
text = "", .height(28.dp),
style = MaterialTheme.typography.bodySmall, contentAlignment = Alignment.CenterStart
color = MaterialTheme.colorScheme.onSurfaceVariant ) {
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(
text = "${it.toInt()}% complete", text = if (isDownloading) {
style = MaterialTheme.typography.bodySmall, stringResource(R.string.status_downloading)
color = MaterialTheme.colorScheme.primary } 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 @Composable

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.database.Cursor import android.database.Cursor
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.DocumentsContract import android.provider.DocumentsContract
@ -115,7 +116,9 @@ import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.PdfiumCore import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.concurrent.Executors.newSingleThreadExecutor
private const val KEY_RENDER_MODE = "render_mode" private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" 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 appContext: Context = application.applicationContext
private val authRepository = AuthRepository(appContext) private val authRepository = AuthRepository(appContext)
private val recentFilesRepository = RecentFilesRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext)
private val pdfTextRepository = PdfTextRepository(appContext)
private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao() private val pdfTextRepository by lazy { PdfTextRepository(appContext) }
private val epubParser = EpubParser(appContext) private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() }
private val mobiParser = MobiParser(appContext) private val epubParser by lazy { EpubParser(appContext) }
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext) private val mobiParser by lazy { MobiParser(appContext) }
private val odtParser = com.aryan.reader.epub.OdtParser(appContext) private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) }
private val singleFileImporter = SingleFileImporter(appContext) private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) }
private val bookImporter = BookImporter(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 = private val prefs: SharedPreferences =
application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
private val firestoreRepository = FirestoreRepository() private val firestoreRepository = FirestoreRepository()
@ -301,16 +310,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val feedbackRepository = FeedbackRepository(appContext) private val feedbackRepository = FeedbackRepository(appContext)
private var feedbackListener: Any? = null private var feedbackListener: Any? = null
private val importMutex = Mutex() 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) private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
@Suppress("unused") @Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow() val navigationEvent = _navigationEvent.receiveAsFlow()
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = 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( data class PageModificationResult(
val layout: List<VirtualPage>, val layout: List<VirtualPage>,
val annotations: Map<Int, List<PdfAnnotation>>, val annotations: Map<Int, List<PdfAnnotation>>,
@ -502,7 +528,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), started = SharingStarted.WhileSubscribed(5000),
initialValue = ReaderScreenState() initialValue = _internalState.value
) )
fun setTabsEnabled(enabled: Boolean) { fun setTabsEnabled(enabled: Boolean) {
@ -796,7 +822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
init { init {
Timber.d("ViewModel instance created.") Timber.d("ViewModel instance created.")
viewModelScope.launch(Dispatchers.IO) {
PDFBoxResourceLoader.init(getApplication()) PDFBoxResourceLoader.init(getApplication())
}
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0) val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) } 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 { private fun getFastFileId(context: Context, uri: Uri): String {
var result = uri.toString() var result = uri.toString()
try { try {
@ -4410,6 +4436,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
super.onCleared() super.onCleared()
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener) prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
firestoreRepository.removeListener(feedbackListener) firestoreRepository.removeListener(feedbackListener)
panelDetector?.close()
panelDetector = null
Timber.d("ViewModel instance cleared (onCleared).") Timber.d("ViewModel instance cleared (onCleared).")
} }
@ -4627,6 +4655,104 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return authRepository.getIdToken() 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 { companion object {
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names" 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.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.ui.graphics.Color
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add 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.ContentCopy
import androidx.compose.material.icons.filled.Delete 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.Info
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -85,6 +90,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler 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.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@ -111,6 +118,7 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.log10 import kotlin.math.log10
import kotlin.math.pow 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 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" 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 = {} actions: @Composable RowScope.() -> Unit = {}
) { ) {
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth()
.height(56.dp),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp shadowElevation = 2.dp
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.statusBarsPadding()
.height(56.dp)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -874,20 +882,168 @@ fun AutoSizeText(
@Composable @Composable
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) { 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 containerColor = if (overlay) 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 contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Surface( Surface(
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(50),
color = containerColor, color = containerColor,
contentColor = contentColor contentColor = contentColor,
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null
) { ) {
Text( Text(
text = type.name.uppercase(), text = type.name.uppercase(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) 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.RenderMode
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -329,7 +330,8 @@ private fun ChaptersList(
mutableStateOf(allParentIndices) mutableStateOf(allParentIndices)
} }
val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) { val visibleItemInfo by remember(effectiveToc) {
derivedStateOf {
val result = mutableListOf<Pair<Int, EpubTocEntry>>() val result = mutableListOf<Pair<Int, EpubTocEntry>>()
val visibilityStack = BooleanArray(50) { false } val visibilityStack = BooleanArray(50) { false }
visibilityStack[0] = true visibilityStack[0] = true
@ -353,6 +355,7 @@ private fun ChaptersList(
} }
result result
} }
}
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -367,23 +370,29 @@ private fun ChaptersList(
val targetEntry = activeTocEntry ?: return@launch val targetEntry = activeTocEntry ?: return@launch
val targetOriginalIndex = effectiveToc.indexOf(targetEntry) val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
if (targetOriginalIndex != -1) { if (targetOriginalIndex != -1) {
// Ensure parents are expanded
var currentLevel = targetEntry.depth var currentLevel = targetEntry.depth
val newExpanded = expandedEntryIndices.toMutableSet() val newExpanded = expandedEntryIndices.toMutableSet()
for (i in targetOriginalIndex downTo 0) { for (i in targetOriginalIndex downTo 0) {
val entry = effectiveToc[i] val entry = effectiveToc[i]
if (entry.depth < currentLevel) { if (entry.depth < currentLevel) {
newExpanded.add(i) newExpanded.add(i)
currentLevel = entry.depth currentLevel = entry.depth
} }
if (currentLevel == 0) break
} }
expandedEntryIndices = newExpanded expandedEntryIndices = newExpanded
// Delay to allow visibility array to recompose
kotlinx.coroutines.delay(100)
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry } val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
if (visibleIdx != -1) { if (visibleIdx != -1) {
var attempts = 0
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
delay(30)
attempts++
}
listState.animateScrollToItem(visibleIdx) listState.animateScrollToItem(visibleIdx)
} }
} }

View file

@ -448,7 +448,7 @@ fun EpubReaderScreen(
} }
@Suppress("ControlFlowWithEmptyBody") @Suppress("ControlFlowWithEmptyBody")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -481,6 +481,13 @@ fun EpubReaderHost(
val activity = context as? Activity val activity = context as? Activity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) } 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 focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = 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 } val down = event.changes.firstOrNull { it.pressed }
if (down != null) { if (down != null) {
pageTurnTouchY = down.position.y 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, isDarkTheme: Boolean,
touchY: Float? touchY: Float?
): Modifier = composed { ): Modifier = composed {
// Log composition frequency
SideEffect {
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
}
val frontPath = remember { Path() } val frontPath = remember { Path() }
val backPath = remember { Path() } val backPath = remember { Path() }
@ -3965,7 +3960,6 @@ private fun Modifier.realisticBookPage(
.graphicsLayer { .graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
// Log layer property updates
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) { if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset") Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
} }
@ -3994,7 +3988,14 @@ private fun Modifier.realisticBookPage(
val h = size.height val h = size.height
val startY = touchY ?: h 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 val cornerY = if (centerDist >= 0) h else 0f

View file

@ -19,13 +19,23 @@
*/ */
package com.aryan.reader.pdf 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Icons
import androidx.compose.material.icons.automirrored.filled.Redo import androidx.compose.material.icons.automirrored.filled.Redo
import androidx.compose.material.icons.automirrored.filled.Undo 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.Close
import androidx.compose.material.icons.filled.DoNotTouch 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.TouchApp
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource 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 targetWidth: Int,
val targetHeight: Int, val targetHeight: Int,
val colorFilter: StableHolder<ColorFilter?>, val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean val isDarkMode: Boolean,
val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>>
) )
@Stable @Stable
@ -416,6 +418,7 @@ internal fun PdfPageComposable(
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((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), 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, onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false, isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null, drawingState: PdfDrawingState? = null,
@ -451,7 +454,9 @@ internal fun PdfPageComposable(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(), customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null, onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = 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 val pdfDocumentItem = pdfDocument.item
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } 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()) } @Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(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) { LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
if (pageLinks.isNotEmpty()) pageLinks = emptyList() if (pageLinks.isNotEmpty()) pageLinks = emptyList()
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList() if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList()
return@LaunchedEffect return@LaunchedEffect
} }
@ -864,6 +871,7 @@ internal fun PdfPageComposable(
val allLinks = mutableListOf<PageLink>() val allLinks = mutableListOf<PageLink>()
var finalDisplayList = emptyList<EmbeddedAnnotation>() var finalDisplayList = emptyList<EmbeddedAnnotation>()
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>() var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
var mappedImageRects = emptyList<android.graphics.Rect>()
val annotLink = 2 val annotLink = 2
try { try {
@ -931,6 +939,39 @@ internal fun PdfPageComposable(
Timber.e(e, "Error fetching web links") 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 // 3. Extract Embedded Annotations
try { try {
val pagePtr = pageWrapper.getNativePointer() val pagePtr = pageWrapper.getNativePointer()
@ -1035,6 +1076,7 @@ internal fun PdfPageComposable(
pageLinks = allLinks pageLinks = allLinks
embeddedAnnotations = finalDisplayList embeddedAnnotations = finalDisplayList
standardAnnotScreenRects = mappedAnnots standardAnnotScreenRects = mappedAnnots
imageScreenRects = mappedImageRects
} }
} }
} }
@ -2627,7 +2669,38 @@ internal fun PdfPageComposable(
coroutineScope.launch { coroutineScope.launch {
val startScale = scale val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f 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 startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) { val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero Offset.Zero
@ -2658,10 +2731,10 @@ internal fun PdfPageComposable(
) )
) { ) {
val progress = value val progress = value
scale = lerp( scale = androidx.compose.ui.util.lerp(
startScale, targetScale, progress startScale, targetScale, progress
) )
offset = lerp( offset = androidx.compose.ui.geometry.lerp(
startOffset, targetOffset, progress startOffset, targetOffset, progress
) )
onScaleChanged(scale) onScaleChanged(scale)
@ -3555,6 +3628,7 @@ internal fun PdfPageComposable(
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) } val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
val stableTiles = remember(tiles) { StableHolder(tiles) } val stableTiles = remember(tiles) { StableHolder(tiles) }
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
val staticData = remember( val staticData = remember(
stableBitmapState, stableBitmapState,
@ -3567,7 +3641,9 @@ internal fun PdfPageComposable(
actualBitmapWidthPx, actualBitmapWidthPx,
actualBitmapHeightPx, actualBitmapHeightPx,
stableColorFilter, stableColorFilter,
isDarkMode isDarkMode,
excludeImages,
stableImageRects
) { ) {
Timber.tag("PdfDrawPerf").v( Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}" "STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@ -3583,7 +3659,9 @@ internal fun PdfPageComposable(
targetWidth = actualBitmapWidthPx, targetWidth = actualBitmapWidthPx,
targetHeight = actualBitmapHeightPx, targetHeight = actualBitmapHeightPx,
colorFilter = stableColorFilter, colorFilter = stableColorFilter,
isDarkMode = isDarkMode isDarkMode = isDarkMode,
excludeImages = excludeImages,
imageRects = stableImageRects
) )
} }
@ -3659,6 +3737,7 @@ internal fun PdfPageComposable(
} }
}, },
scale = scale, scale = scale,
uiScale = effectiveScale,
offset = offset, offset = offset,
startHandlePos = startHandleContentPosition.value, startHandlePos = startHandleContentPosition.value,
endHandlePos = endHandleContentPosition.value, endHandlePos = endHandleContentPosition.value,
@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer(
targetWidth: Int, targetWidth: Int,
targetHeight: Int, targetHeight: Int,
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false isDarkMode: Boolean = false,
excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = emptyList()
) { ) {
Canvas(modifier = Modifier Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
.fillMaxSize()
.graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) { translate(left = centeringOffsetX, top = centeringOffsetY) {
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
if (bitmapState != null && !bitmapState.isRecycled) { if (bitmapState != null && !bitmapState.isRecycled) {
@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer(
filterQuality = androidx.compose.ui.graphics.FilterQuality.High 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 val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling) { if (needsTiling) {
tiles.forEach { tile -> tiles.forEach { tile ->
@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer(
srcOffset = IntOffset.Zero, srcOffset = IntOffset.Zero,
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height), srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top), dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
dstSize = IntSize( dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
tile.renderRect.width(), tile.renderRect.height()
),
colorFilter = colorFilter, colorFilter = colorFilter,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High 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, targetWidth = data.targetWidth,
targetHeight = data.targetHeight, targetHeight = data.targetHeight,
colorFilter = data.colorFilter.item, 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?, drawingState: PdfDrawingState?,
onCanvasSizeChanged: (Float, Float) -> Unit, onCanvasSizeChanged: (Float, Float) -> Unit,
scale: Float, scale: Float,
uiScale: Float,
offset: Offset, offset: Offset,
startHandlePos: Offset?, startHandlePos: Offset?,
endHandlePos: 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 -> textBoxes.forEach { box ->
val isDraggingThisBox = (box.id == draggingBoxId) val isDraggingThisBox = (box.id == draggingBoxId)
val boxAlpha = if (isDraggingThisBox) 0f else 1f val boxAlpha = if (isDraggingThisBox) 0f else 1f
@ -4707,17 +4861,27 @@ private fun PdfPageRenderer(
isSelected = (box.id == selectedTextBoxId), isSelected = (box.id == selectedTextBoxId),
isEditMode = isEditMode, isEditMode = isEditMode,
isDarkMode = staticData.isDarkMode, isDarkMode = staticData.isDarkMode,
scale = uiScale,
pageWidthPx = staticData.targetWidth.toFloat(), pageWidthPx = staticData.targetWidth.toFloat(),
pageHeightPx = staticData.targetHeight.toFloat(), pageHeightPx = staticData.targetHeight.toFloat(),
handlePosition = HandlePosition.AUTO, handlePosition = HandlePosition.AUTO,
onBoundsChanged = { newBounds -> onBoundsChanged = { newBounds ->
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId")
if (draggingBoxId != box.id) {
onTextBoxChange(box.copy(relativeBounds = newBounds)) onTextBoxChange(box.copy(relativeBounds = newBounds))
} else {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally")
}
}, },
onTextChanged = { newText -> onTextChanged = { newText ->
onTextBoxChange(box.copy(text = 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 -> onDragStart = { touchOffset ->
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset")
if (isVerticalScroll) { if (isVerticalScroll) {
val topLeft = Offset( val topLeft = Offset(
box.relativeBounds.left * staticData.targetWidth, box.relativeBounds.left * staticData.targetWidth,
@ -4729,10 +4893,12 @@ private fun PdfPageRenderer(
} }
}, },
onDrag = { delta, currentBounds -> onDrag = { delta, currentBounds ->
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
if (isVerticalScroll) { if (isVerticalScroll) {
onTextBoxDrag(delta) onTextBoxDrag(delta)
} else { } else {
onTextBoxDrag(delta) val scaledDelta = delta * scale
onTextBoxDrag(scaledDelta)
val width = staticData.targetWidth val width = staticData.targetWidth
val edgeThreshold = 60f val edgeThreshold = 60f
@ -4746,9 +4912,11 @@ private fun PdfPageRenderer(
} }
}, },
onDragEnd = { onDragEnd = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
onTextBoxDragEnd() onTextBoxDragEnd()
}, },
onDragCancel = { onDragCancel = {
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
onTextBoxDragEnd() onTextBoxDragEnd()
}, },
modifier = Modifier 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.background
import androidx.compose.foundation.border 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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor 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.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -78,6 +84,49 @@ enum class HandlePosition {
TOP, BOTTOM, AUTO 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 @Composable
fun ResizableTextBox( fun ResizableTextBox(
box: PdfTextBox, box: PdfTextBox,
@ -86,6 +135,7 @@ fun ResizableTextBox(
isDarkMode: Boolean, isDarkMode: Boolean,
pageWidthPx: Float, pageWidthPx: Float,
pageHeightPx: Float, pageHeightPx: Float,
scale: Float = 1f,
onBoundsChanged: (Rect) -> Unit, onBoundsChanged: (Rect) -> Unit,
onTextChanged: (String) -> Unit, onTextChanged: (String) -> Unit,
onSelect: () -> Unit, onSelect: () -> Unit,
@ -101,8 +151,9 @@ fun ResizableTextBox(
val density = LocalDensity.current val density = LocalDensity.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val handleSize = 10.dp // Counter-scale fixed sizes so they render proportionally regardless of the zoom level
val handleTouchSize = 40.dp val handleSize = (10f / scale).dp
val handleTouchSize = (40f / scale).dp
val handleSizePx = with(density) { handleSize.toPx() } val handleSizePx = with(density) { handleSize.toPx() }
val halfHandlePx = handleSizePx / 2f val halfHandlePx = handleSizePx / 2f
val handleTouchSizePx = with(density) { handleTouchSize.toPx() } 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 { var currentRectPx by remember {
mutableStateOf( mutableStateOf(
Rect( Rect(
@ -152,6 +205,8 @@ fun ResizableTextBox(
right = box.relativeBounds.right * pageWidthPx, right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx 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 || if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f || kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
kotlin.math.abs(newPx.width - currentRectPx.width) > 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) { // Freeze handle position while dragging to prevent UI jumping
derivedStateOf { var isHandleAtTop by remember { mutableStateOf(false) }
when (handlePosition) {
LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) {
if (!isDraggingOrResizing) {
isHandleAtTop = when (handlePosition) {
HandlePosition.TOP -> true HandlePosition.TOP -> true
HandlePosition.BOTTOM -> false HandlePosition.BOTTOM -> false
HandlePosition.AUTO -> { HandlePosition.AUTO -> {
if (pageHeightPx <= 0f) { if (pageHeightPx <= 0f) false
false else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx
} else {
val spaceBelow = pageHeightPx - currentRectPx.bottom
spaceBelow < requiredBottomSpacePx
}
} }
} }
} }
@ -184,11 +238,9 @@ fun ResizableTextBox(
Box( Box(
modifier = modifier modifier = modifier
.zIndex(if (isSelected) 10f else 0f) .zIndex(if (isSelected) 10f else 0f)
.offset { .graphicsLayer {
IntOffset( translationX = currentRectPx.left - halfHandlePx
(currentRectPx.left - halfHandlePx).roundToInt(), translationY = currentRectPx.top - halfHandlePx
(currentRectPx.top - halfHandlePx).roundToInt()
)
} }
.size( .size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() }, width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
@ -201,10 +253,13 @@ fun ResizableTextBox(
.fillMaxSize() .fillMaxSize()
.padding(handleSize / 2) .padding(handleSize / 2)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { onSelect() } detectTapGestures {
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
onSelect()
}
} }
.then( .then(
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
) )
) { ) {
BasicTextField( BasicTextField(
@ -220,7 +275,7 @@ fun ResizableTextBox(
background = box.backgroundColor, background = box.backgroundColor,
fontFamily = fontFamily, fontFamily = fontFamily,
fontSize = with(LocalDensity.current) { fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp() (box.fontSize * pageHeightPx).toSp()
}, },
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
@ -267,8 +322,11 @@ fun ResizableTextBox(
} }
.size(handleTouchSize) .size(handleTouchSize)
.pointerInput(onBoundsChanged) { .pointerInput(onBoundsChanged) {
detectDragGestures( detectEagerDragGestures(
onDragStart = { isDraggingOrResizing = true }, onDragStart = {
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
isDraggingOrResizing = true
},
onDragEnd = { onDragEnd = {
isDraggingOrResizing = false isDraggingOrResizing = false
val normalized = Rect( val normalized = Rect(
@ -277,40 +335,42 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx, right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx bottom = currentRectPx.bottom / pageHeightPx
) )
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized) onBoundsChanged(normalized)
}, },
onDragCancel = { isDraggingOrResizing = false } onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount -> ) { change, dragAmount ->
change.consume() Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
var l = currentRectPx.left var l = currentRectPx.left
var t = currentRectPx.top var t = currentRectPx.top
var r = currentRectPx.right var r = currentRectPx.right
var b = currentRectPx.bottom var b = currentRectPx.bottom
val dx = dragAmount.x val dx = dragAmount.x
val dy = dragAmount.y val dy = dragAmount.y
val minSize = 50f val minSize = 50f / scale
when (handle) { when (handle) {
ResizeHandle.TOP_LEFT -> { ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize) l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
t = (t + dy).coerceIn(0f, b - 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 -> { ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx) r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
t = (t + dy).coerceIn(0f, b - minSize) 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 -> { ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx) r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
b = (b + dy).coerceIn(t + minSize, pageHeightPx) 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 -> { ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize) l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
b = (b + dy).coerceIn(t + minSize, pageHeightPx) 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 -> {} else -> {}
} }
currentRectPx = Rect(l, t, r, b) currentRectPx = Rect(l, t, r, b)
@ -328,13 +388,15 @@ fun ResizableTextBox(
DragPill( DragPill(
isDarkMode = isDarkMode, isDarkMode = isDarkMode,
scale = scale,
modifier = Modifier modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter) .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) .zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) { .pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
detectDragGestures( detectEagerDragGestures(
onDragStart = { offset -> onDragStart = { offset ->
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
isDraggingOrResizing = true isDraggingOrResizing = true
onDragStart(offset) onDragStart(offset)
}, },
@ -346,6 +408,7 @@ fun ResizableTextBox(
right = currentRectPx.right / pageWidthPx, right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx bottom = currentRectPx.bottom / pageHeightPx
) )
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized) onBoundsChanged(normalized)
onDragEnd() onDragEnd()
}, },
@ -354,13 +417,12 @@ fun ResizableTextBox(
onDragCancel() onDragCancel()
} }
) { change, dragAmount -> ) { change, dragAmount ->
change.consume()
val w = currentRectPx.width val w = currentRectPx.width
val h = currentRectPx.height val h = currentRectPx.height
val rawLeft = currentRectPx.left + dragAmount.x val rawLeft = currentRectPx.left + dragAmount.x
val rawTop = currentRectPx.top + dragAmount.y val rawTop = currentRectPx.top + dragAmount.y
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w) val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w))
val newTop = rawTop.coerceIn(0f, pageHeightPx - h) val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h) val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect currentRectPx = newRect
onDrag(dragAmount, newRect) onDrag(dragAmount, newRect)
@ -374,21 +436,22 @@ fun ResizableTextBox(
@Composable @Composable
private fun DragPill( private fun DragPill(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDarkMode: Boolean isDarkMode: Boolean,
scale: Float = 1f
) { ) {
Surface( Surface(
modifier = modifier modifier = modifier
.size(width = 48.dp, height = 24.dp), .size(width = (48f / scale).dp, height = (24f / scale).dp),
shape = CircleShape, shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black, color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White, contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = 4.dp shadowElevation = (4f / scale).dp
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
painter = painterResource(id = R.drawable.drag_handle), painter = painterResource(id = R.drawable.drag_handle),
contentDescription = "Drag to move text box", 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) @OptIn(FlowPreview::class)
@Composable @Composable
internal fun PdfVerticalReader( internal fun PdfVerticalReader(
modifier: Modifier = Modifier,
state: VerticalPdfReaderState, state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>, pdfDocument: StableHolder<ReaderDocument>,
activeTheme: com.aryan.reader.ReaderTheme, activeTheme: com.aryan.reader.ReaderTheme,
excludeImages: Boolean = false,
totalPages: Int, totalPages: Int,
modifier: Modifier = Modifier,
virtualPages: List<VirtualPage> = emptyList(), virtualPages: List<VirtualPage> = emptyList(),
pageAspectRatios: StableHolder<List<Float>>, pageAspectRatios: StableHolder<List<Float>>,
headerHeight: Dp, headerHeight: Dp,
@ -284,6 +285,11 @@ internal fun PdfVerticalReader(
val dividerHeightDp = 8.dp val dividerHeightDp = 8.dp
val dividerHeightPx = with(density) { dividerHeightDp.toPx() } 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) { val layoutState = remember(ratios, screenWidth, screenHeight, density) {
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float) data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
@ -373,8 +379,41 @@ internal fun PdfVerticalReader(
var isInitialLayout by remember { mutableStateOf(true) } var isInitialLayout by remember { mutableStateOf(true) }
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } } 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) { LaunchedEffect(layoutState.pages) {
if (!isInitialLayout) { if (!isInitialLayout && !isScrollLocked) {
val targetPageIdx = if (targetPageDuringResize.intValue != -1) { val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
targetPageDuringResize.intValue targetPageDuringResize.intValue
} else { } else {
@ -410,21 +449,13 @@ internal fun PdfVerticalReader(
launch { panXAnimatable.snapTo(targetPanX) } launch { panXAnimatable.snapTo(targetPanX) }
launch { panYAnimatable.snapTo(finalPanY) } launch { panYAnimatable.snapTo(finalPanY) }
} }
}
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
state.currentPage = targetPageIdx
if (isFit) onZoomChange(targetZoom)
} }
if (!isInitialLayout) {
delay(50) delay(50)
isResizing = false isResizing = false
targetPageDuringResize.intValue = -1 targetPageDuringResize.intValue = -1
} else if (isScrollLocked && lockedState != null) {
val (savedScale, savedPanX, _) = lockedState
coroutineScope {
launch { zoomAnimatable.snapTo(savedScale) }
launch { panXAnimatable.snapTo(savedPanX) }
}
} }
isInitialLayout = false isInitialLayout = false
} }
@ -461,11 +492,6 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY) 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( LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) { ) {
@ -483,6 +509,9 @@ internal fun PdfVerticalReader(
panYAnimatable.snapTo(y) panYAnimatable.snapTo(y)
} }
if (x != currentPanX) { if (x != currentPanX) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x")
}
panXAnimatable.snapTo(x) panXAnimatable.snapTo(x)
} }
} }
@ -585,8 +614,8 @@ internal fun PdfVerticalReader(
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone) val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f) val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = -15f * ratio scrollDelta = -15f * ratio
} else if (draggingBoxOffset.y + draggingBoxSize.height > bottomEdge - scrollZone) { } else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) {
val boxBottom = draggingBoxOffset.y + draggingBoxSize.height val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value)
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone) val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f) val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
scrollDelta = 15f * ratio scrollDelta = 15f * ratio
@ -744,6 +773,9 @@ internal fun PdfVerticalReader(
} }
if (!isAnimating) { if (!isAnimating) {
if (isScrollLocked) {
Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]")
}
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX) panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
} else { } else {
@ -1575,6 +1607,7 @@ internal fun PdfVerticalReader(
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalPages, totalPages = totalPages,
activeTheme = activeTheme, activeTheme = activeTheme,
excludeImages = excludeImages,
externalScale = highResScale, externalScale = highResScale,
onScaleChanged = {}, onScaleChanged = {},
showAllTextHighlights = showAllTextHighlights, showAllTextHighlights = showAllTextHighlights,
@ -1638,17 +1671,20 @@ internal fun PdfVerticalReader(
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom) val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
draggingBoxSize = Size( draggingBoxSize = Size(
box.relativeBounds.width * page.width * currentZoom, box.relativeBounds.width * page.width,
box.relativeBounds.height * page.height * currentZoom box.relativeBounds.height * page.height
) )
draggingBoxPageHeight = page.height * currentZoom draggingBoxPageHeight = page.height
draggingBoxOffset = Offset(boxScreenX, boxScreenY) draggingBoxOffset = Offset(boxScreenX, boxScreenY)
draggingBoxTouchDelta = touchOffset * currentZoom draggingBoxTouchDelta = touchOffset * currentZoom
draggingBoxId = box.id draggingBoxId = box.id
}, },
onTextBoxDrag = { dragDelta -> 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 = { onTextBoxDragEnd = {
scope.launch { scope.launch {
@ -1681,9 +1717,9 @@ internal fun PdfVerticalReader(
val rawRelY = val rawRelY =
(finalBoxY / currentZoom) / targetPage.height (finalBoxY / currentZoom) / targetPage.height
val relW = val relW =
(draggingBoxSize.width / currentZoom) / targetPage.width draggingBoxSize.width / targetPage.width
val relH = val relH =
(draggingBoxSize.height / currentZoom) / targetPage.height draggingBoxSize.height / targetPage.height
val clampedW = relW.coerceAtMost(1f) val clampedW = relW.coerceAtMost(1f)
val clampedH = relH.coerceAtMost(1f) val clampedH = relH.coerceAtMost(1f)
@ -2051,7 +2087,8 @@ internal fun PdfVerticalReader(
val fontScaleRatio = val fontScaleRatio =
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f 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 spaceBelow = screenHeight - boxBottomY
val overlayHandlePos = val overlayHandlePos =
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM 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() draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
) )
} }
.graphicsLayer {
scaleX = currentZoom
scaleY = currentZoom
transformOrigin = TransformOrigin(0f, 0f)
}
.zIndex(100f)) { .zIndex(100f)) {
ResizableTextBox( ResizableTextBox(
box = draggedBox.copy( box = draggedBox.copy(
@ -2073,6 +2115,7 @@ internal fun PdfVerticalReader(
isDarkMode = isDarkMode, isDarkMode = isDarkMode,
pageWidthPx = draggingBoxSize.width, pageWidthPx = draggingBoxSize.width,
pageHeightPx = draggingBoxSize.height, pageHeightPx = draggingBoxSize.height,
scale = currentZoom,
handlePosition = overlayHandlePos, handlePosition = overlayHandlePos,
onBoundsChanged = {}, onBoundsChanged = {},
onTextChanged = {}, 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -118,7 +115,6 @@ import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
private enum class ColorMenuMode { 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 fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
val focusManager = LocalFocusManager.current 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) { LaunchedEffect(activePopup) {
if (activePopup == ActivePopup.NONE) { if (activePopup == ActivePopup.NONE) {
activeMenuMode = ColorMenuMode.PALETTE activeMenuMode = ColorMenuMode.PALETTE
@ -200,7 +184,7 @@ fun TextAnnotationDock(
val dockBarHeight = 48.dp val dockBarHeight = 48.dp
val margin = 8.dp val margin = 8.dp
val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin) val finalOffsetY = -(bottomDockPadding + dockBarHeight + margin)
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY 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.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.materialkolor.PaletteStyle import com.materialkolor.PaletteStyle
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -118,36 +119,58 @@ fun AppTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 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 != null -> dynamicColorScheme(
seedColor = seedColor, seedColor = seedColor,
isDark = darkTheme, isDark = darkTheme,
contrastLevel = contrastLevel, contrastLevel = contrastLevel,
style = PaletteStyle.Fidelity style = PaletteStyle.Fidelity
) )
dynamicColor && supportsDynamicColor -> { dynamicColor && supportsDynamicColor -> {
val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context)
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> darkScheme darkTheme -> darkScheme
else -> lightScheme else -> lightScheme
} }
}
val finalColorScheme = colorScheme.copy( val finalColorScheme = remember(baseColorScheme, textDimFactor) {
onPrimary = colorScheme.onPrimary.copy(alpha = textDimFactor), if (textDimFactor >= 1.0f) {
onSecondary = colorScheme.onSecondary.copy(alpha = textDimFactor), baseColorScheme
onTertiary = colorScheme.onTertiary.copy(alpha = textDimFactor), } else {
onBackground = colorScheme.onBackground.copy(alpha = textDimFactor), baseColorScheme.copy(
onSurface = colorScheme.onSurface.copy(alpha = textDimFactor), primary = baseColorScheme.primary.copy(alpha = textDimFactor),
onSurfaceVariant = colorScheme.onSurfaceVariant.copy(alpha = textDimFactor), secondary = baseColorScheme.secondary.copy(alpha = textDimFactor),
onError = colorScheme.onError.copy(alpha = textDimFactor), tertiary = baseColorScheme.tertiary.copy(alpha = textDimFactor),
onPrimaryContainer = colorScheme.onPrimaryContainer.copy(alpha = textDimFactor), error = baseColorScheme.error.copy(alpha = textDimFactor),
onSecondaryContainer = colorScheme.onSecondaryContainer.copy(alpha = textDimFactor), primaryContainer = baseColorScheme.primaryContainer.copy(alpha = textDimFactor),
onTertiaryContainer = colorScheme.onTertiaryContainer.copy(alpha = textDimFactor), secondaryContainer = baseColorScheme.secondaryContainer.copy(alpha = textDimFactor),
onErrorContainer = colorScheme.onErrorContainer.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( MaterialTheme(
colorScheme = finalColorScheme, colorScheme = finalColorScheme,

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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> </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>