diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0cb028..0ea1194 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,10 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 44 - versionName = "1.0.43" + versionCode = 45 + versionName = "1.0.45" + + resourceConfigurations += setOf("en", "ar", "de", "tr") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { @@ -227,6 +229,13 @@ dependencies { implementation("org.zwobble.mammoth:mammoth:1.4.2") implementation("com.materialkolor:material-kolor:5.0.0-alpha07") + + debugImplementation("org.tensorflow:tensorflow-lite:2.17.0") + debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0") + debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0") + debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0") + + implementation("androidx.core:core-splashscreen:1.2.0") } spotless { diff --git a/app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt b/app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt new file mode 100644 index 0000000..c2a0e3a --- /dev/null +++ b/app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt @@ -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 { + 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() + + 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, iouThreshold: Float): List { + val sortedBoxes = boxes.sortedByDescending { it.confidence }.toMutableList() + val selected = mutableListOf() + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e04d604..0578e8c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ ) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val jsonArray = JSONArray() @@ -1868,6 +1880,9 @@ private fun calculateContrastRatio(color1: Color, color2: Color): Float { fun ReaderThemePanel( isVisible: Boolean, currentThemeId: String, + excludeImages: Boolean = false, + onExcludeImagesChange: (Boolean) -> Unit = {}, + showExcludeImagesOption: Boolean = false, customThemes: List, builtInThemes: List = BuiltInThemes, onThemeSelected: (String) -> Unit, @@ -1920,6 +1935,23 @@ fun ReaderThemePanel( modifier = Modifier.padding(bottom = 16.dp) ) + if (showExcludeImagesOption) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Preserve Image Colors", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text("Keep original image colors when theme changes", style = MaterialTheme.typography.bodySmall, color = Color.Gray) + } + androidx.compose.material3.Switch( + checked = excludeImages, + onCheckedChange = onExcludeImagesChange + ) + } + } + Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) Spacer(Modifier.height(8.dp)) ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected) diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 2b9b434..4c60f0f 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -22,13 +22,14 @@ package com.aryan.reader +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.ContextWrapper import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke +import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -41,6 +42,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -57,20 +59,18 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.FormatListNumbered import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material3.AlertDialog @@ -132,6 +132,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.os.LocaleListCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import coil.compose.AsyncImage @@ -183,6 +184,7 @@ fun HomeScreen( var showStrictFilterDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) } + var showLanguageDialog by remember { mutableStateOf(false) } val feedbackResult = navController.currentBackStackEntry?.savedStateHandle?.getLiveData("banner_message") @@ -335,7 +337,9 @@ fun HomeScreen( showStrictFilterDialog = true } }, - onAppThemeClick = { showAppThemePanel = true } + onAppThemeClick = { showAppThemePanel = true }, + onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, + onLanguageClick = { showLanguageDialog = true } ) } else { ContextualTopAppBar( @@ -509,6 +513,10 @@ fun HomeScreen( ) } + if (showLanguageDialog) { + LanguageSelectionDialog(onDismiss = { showLanguageDialog = false }) + } + if (showAppThemePanel) { AppThemeBottomSheet( uiState = uiState, @@ -749,6 +757,8 @@ fun RecentFileCard( isDownloading: Boolean, ) { val context = LocalContext.current + val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() + val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder @@ -757,88 +767,70 @@ fun RecentFileCard( item.coverImagePath?.let { File(it) } ?: placeholder } - Surface( - modifier = modifier.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }, - shape = MaterialTheme.shapes.medium, - tonalElevation = if (isSelected) 8.dp else 2.dp, - shadowElevation = 4.dp, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null - ) { - Column( - modifier = Modifier.combinedClickable( - onClick = onClick, onLongClick = onLongClick + androidx.compose.material3.ElevatedCard( + modifier = modifier + .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } + .then( + if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) + else Modifier ) - ) { - Box { + .clip(MaterialTheme.shapes.large) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + shape = MaterialTheme.shapes.large, + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( + defaultElevation = if (isSelected) 6.dp else 2.dp + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.74f) + ) { AsyncImage( model = ImageRequest.Builder(context).data(imageModel).error(placeholder) .fallback(placeholder).crossfade(true).build(), contentDescription = item.displayName, contentScale = ContentScale.Crop, - modifier = Modifier.height(160.dp).fillMaxWidth(), + modifier = Modifier.fillMaxSize() ) - if (item.sourceFolderUri != null) { - Box( - modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape - ).padding(4.dp) - ) { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = stringResource(R.string.local_folder), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer + Box( + modifier = Modifier.fillMaxSize().background( + androidx.compose.ui.graphics.Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.15f), + 0.3f to Color.Transparent, + 0.6f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.5f) ) - } - } + ) + ) - val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true - if (isOpdsStream) { - Box( - modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = CircleShape - ).padding(4.dp) - ) { - Icon( - imageVector = Icons.Default.Cloud, - contentDescription = stringResource(R.string.opds_stream), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } - - if (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.sourceFolderUri != null || item.isOpdsStream() || isPinned) { + FileStatusBadges( + item = item, + isPinned = isPinned, + overlay = true, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp) + ) } if (!item.isAvailable) { Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f)), + modifier = Modifier.matchParentSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { if (isDownloading) { CircularProgressIndicator(color = Color.White) } else { Icon( - imageVector = Icons.Filled.Info, + Icons.Filled.Info, contentDescription = stringResource(R.string.not_available_locally), modifier = Modifier.size(48.dp), tint = Color.White @@ -846,39 +838,131 @@ fun RecentFileCard( } } } - Box( - modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp) - ) { + + 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(48.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) { FileTypeBadge(type = item.type, overlay = true) } + + progressPercent?.let { percent -> + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + border = androidx.compose.foundation.BorderStroke( + 1.dp, + Color.White.copy(alpha = 0.14f) + ) + ) { + Text( + text = "$percent%", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } } Column( modifier = Modifier .fillMaxWidth() - .height(80.dp) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.Start ) { Text( - text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + text = item.cardTitle(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxWidth(), + lineHeight = 20.sp ) - Box( - modifier = Modifier.height(20.dp), contentAlignment = Alignment.Center - ) { - item.progressPercentage?.let { progress -> - Text( - text = stringResource(R.string.progress_complete, progress.toInt()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = authorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + minLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + + if (!item.isAvailable) { + Spacer(modifier = Modifier.height(12.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(28.dp), + contentAlignment = Alignment.CenterStart + ) { + Surface( + shape = RoundedCornerShape(50), + color = if (isDownloading) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + contentColor = if (isDownloading) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Filled.Info, + contentDescription = stringResource(R.string.not_available_locally), + modifier = Modifier.size(14.dp) + ) + } + Text( + text = if (isDownloading) { + stringResource(R.string.status_downloading) + } else { + stringResource(R.string.not_available_locally) + }, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium + ) + } + } } } } @@ -902,7 +986,9 @@ fun DefaultTopAppBar( onTabsToggle: (Boolean) -> Unit, onExternalFileBehaviorClick: () -> Unit, onStrictFilterToggleClick: () -> Unit, - onAppThemeClick: () -> Unit + onAppThemeClick: () -> Unit, + onTestPanelDetectionClick: () -> Unit, + onLanguageClick: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -985,6 +1071,13 @@ fun DefaultTopAppBar( } }) + HorizontalDivider() + + DropdownMenuItem(text = { Text("Language") }, onClick = { + onLanguageClick() + showOptionsMenu = false + }) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { onClearCache() @@ -995,6 +1088,14 @@ fun DefaultTopAppBar( showOptionsMenu = false }) + if (BuildConfig.DEBUG) { + HorizontalDivider() + DropdownMenuItem(text = { Text("Test Panel ML Detection") }, onClick = { + onTestPanelDetectionClick() + showOptionsMenu = false + }) + } + if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") { HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = { @@ -1760,6 +1861,7 @@ fun ThemeSwatch( } } +@SuppressLint("UnrememberedMutableState") @Composable fun CreateAppThemeDialog( initialColor: Color = Color(0xFF6750A4), @@ -1915,4 +2017,47 @@ fun CreateAppThemeDialog( } } } -} \ No newline at end of file +} + +@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)) } + } + ) +} diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 03e9935..1d15092 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -22,28 +22,17 @@ package com.aryan.reader +import android.annotation.SuppressLint import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import com.aryan.reader.opds.OpdsViewModel -import com.aryan.reader.opds.OpdsEntry -import com.aryan.reader.opds.OpdsCatalog -import org.jsoup.Jsoup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.AssistChip import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -52,6 +41,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -67,6 +58,9 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -74,24 +68,28 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -100,6 +98,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -133,9 +132,13 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem import com.aryan.reader.opds.OpdsAcquisition +import com.aryan.reader.opds.OpdsCatalog +import com.aryan.reader.opds.OpdsEntry +import com.aryan.reader.opds.OpdsViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jsoup.Jsoup import timber.log.Timber import java.io.File import java.text.SimpleDateFormat @@ -147,6 +150,7 @@ private fun getBookCountString(count: Int): String { return pluralStringResource(id = R.plurals.book_count, count, count) } +@SuppressLint("LocalContextGetResourceValueCall") @Composable fun LibraryScreen( viewModel: MainViewModel, @@ -605,6 +609,7 @@ fun LibraryScreenContent( Row( modifier = Modifier .fillMaxWidth() + .statusBarsPadding() .height(64.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -918,7 +923,7 @@ private fun ShelfDetailScreen( var showMoreMenu by remember { mutableStateOf(false) } Scaffold( - modifier = Modifier.statusBarsPadding(), + modifier = Modifier, topBar = { if (isContextualModeActive) { ContextualTopAppBar( @@ -1071,7 +1076,7 @@ private fun AddBooksModeScreen( var showSourceMenu by remember { mutableStateOf(false) } Scaffold( - modifier = Modifier.statusBarsPadding(), + modifier = Modifier, topBar = { CustomTopAppBar( title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, @@ -1256,13 +1261,21 @@ private fun ShelfListItem( onItemClick: () -> Unit, onItemLongClick: () -> Unit, ) { - Surface( - shape = MaterialTheme.shapes.medium, - tonalElevation = if (isSelected) 8.dp else 2.dp, - shadowElevation = 4.dp, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + androidx.compose.material3.ElevatedCard( + shape = MaterialTheme.shapes.large, + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( + defaultElevation = if (isSelected) 8.dp else 2.dp + ), modifier = Modifier .fillMaxWidth() + .then( + if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) + else Modifier + ) + .clip(MaterialTheme.shapes.large) .combinedClickable( onClick = onItemClick, onLongClick = { @@ -1318,123 +1331,170 @@ private fun LibraryListItem( item.coverImagePath?.let { File(it) } ?: placeholder } - Surface( - shape = MaterialTheme.shapes.medium, - tonalElevation = if (isSelected) 8.dp else 2.dp, - shadowElevation = 4.dp, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + androidx.compose.material3.ElevatedCard( + shape = MaterialTheme.shapes.large, + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( + defaultElevation = if (isSelected) 6.dp else 2.dp + ), modifier = Modifier .fillMaxWidth() .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } + .then( + if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) + else Modifier + ) + .clip(MaterialTheme.shapes.large) .combinedClickable( onClick = onItemClick, onLongClick = onItemLongClick ) ) { Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.Top + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp) + .height(132.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(imageModel) - .error(placeholder) - .fallback(placeholder) - .crossfade(true) - .build(), - contentDescription = item.displayName, - contentScale = ContentScale.Crop, + Box( modifier = Modifier - .size(width = 70.dp, height = 100.dp) - .clip(MaterialTheme.shapes.small) - ) + .fillMaxHeight() + .aspectRatio(0.7f) + .clip(MaterialTheme.shapes.medium) + .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium) + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageModel) + .error(placeholder) + .fallback(placeholder) + .crossfade(true) + .build(), + contentDescription = item.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (item.sourceFolderUri != null) { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.secondary - ) - Spacer(modifier = Modifier.width(4.dp)) + 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) } - 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 + } + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.cardTitle(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 20.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = item.cardAuthor(), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + minLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(4.dp)) } - if (isPinned) { - Icon( - imageVector = Icons.Default.PushPin, - contentDescription = "Pinned", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary + if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) { + FileStatusBadges( + item = item, + isPinned = isPinned ) - Spacer(modifier = Modifier.width(4.dp)) } + } - Text( - text = item.customName ?: item.title ?: item.displayName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - 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 - ) + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + FileTypeBadge(type = item.type, overlay = false) + + Box( + modifier = Modifier + .weight(1f) + .height(28.dp), + contentAlignment = Alignment.CenterStart + ) { + if (!item.isAvailable) { + Surface( + shape = RoundedCornerShape(50), + color = if (isDownloading) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + contentColor = if (isDownloading) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } 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 + ) + } + } } } } - item.author?.takeIf { it.isNotBlank() }?.let { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - FileTypeBadge(type = item.type, overlay = false) + Spacer(modifier = Modifier.weight(1f)) - item.progressPercentage?.let { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "•", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "${it.toInt()}% complete", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - } + ReadingProgressSection( + progressPercentage = item.progressPercentage, + compact = true, + modifier = Modifier.fillMaxWidth() + ) } } } @@ -2784,4 +2844,4 @@ fun OpdsBookDetailsSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index e115f65..1e325c4 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -23,7 +23,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.webkit.WebView -import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -43,9 +43,10 @@ import kotlinx.coroutines.launch import timber.log.Timber import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var platformFeaturesRepository: PlatformFeaturesRepository @@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt index cc624c8..c1195e6 100644 --- a/app/src/main/java/com/aryan/reader/MainScreen.kt +++ b/app/src/main/java/com/aryan/reader/MainScreen.kt @@ -91,6 +91,7 @@ fun MainScreen( } Scaffold( + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), bottomBar = { NavigationBar { bottomBarItems.forEachIndexed { index, screen -> diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index c06ab06..1fa5ec2 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -28,6 +28,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.database.Cursor +import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.provider.DocumentsContract @@ -115,7 +116,9 @@ import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import androidx.core.graphics.createBitmap import io.legere.pdfiumandroid.PdfiumCore +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.distinctUntilChanged +import java.util.concurrent.Executors.newSingleThreadExecutor private const val KEY_RENDER_MODE = "render_mode" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" @@ -276,15 +279,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val appContext: Context = application.applicationContext private val authRepository = AuthRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext) - private val pdfTextRepository = PdfTextRepository(appContext) - private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao() - private val epubParser = EpubParser(appContext) - private val mobiParser = MobiParser(appContext) - private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext) - private val odtParser = com.aryan.reader.epub.OdtParser(appContext) - private val singleFileImporter = SingleFileImporter(appContext) - private val bookImporter = BookImporter(appContext) + private val pdfTextRepository by lazy { PdfTextRepository(appContext) } + private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() } + private val epubParser by lazy { EpubParser(appContext) } + private val mobiParser by lazy { MobiParser(appContext) } + private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) } + private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) } + private val singleFileImporter by lazy { SingleFileImporter(appContext) } + private val bookImporter by lazy { BookImporter(appContext) } + private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) } + private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) } + private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) } + private val pdfHighlightRepository by lazy { PdfHighlightRepository(appContext) } + private val pdfAnnotationRepository by lazy { PdfAnnotationRepository(appContext) } + private val prefs: SharedPreferences = application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) private val firestoreRepository = FirestoreRepository() @@ -301,16 +310,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val feedbackRepository = FeedbackRepository(appContext) private var feedbackListener: Any? = null private val importMutex = Mutex() - private val pageLayoutRepository = PageLayoutRepository(appContext) - private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext) - private val pdfTextBoxRepository = PdfTextBoxRepository(appContext) - private val pdfHighlightRepository = PdfHighlightRepository(appContext) private val _navigationEvent = Channel(Channel.BUFFERED) @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() private var pendingSwitchDeferred: CompletableDeferred? = null private var externalOpenedBookId: String? = null + private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null + + private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher() + + private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? { + if (panelDetector == null && BuildConfig.DEBUG) { + val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite") + if (modelFile.exists()) { + try { + val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector") + panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector + } catch (e: Exception) { + Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection") + } + } else { + Timber.e("Model file best_float16.tflite not found in external files dir") + } + } + return panelDetector + } + data class PageModificationResult( val layout: List, val annotations: Map>, @@ -502,7 +528,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = ReaderScreenState() + initialValue = _internalState.value ) fun setTabsEnabled(enabled: Boolean) { @@ -796,7 +822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio init { Timber.d("ViewModel instance created.") - PDFBoxResourceLoader.init(getApplication()) + viewModelScope.launch(Dispatchers.IO) { + PDFBoxResourceLoader.init(getApplication()) + } val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0) prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) } @@ -1181,8 +1209,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private val pdfAnnotationRepository = PdfAnnotationRepository(appContext) - private fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() try { @@ -4410,6 +4436,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio super.onCleared() prefs.unregisterOnSharedPreferenceChangeListener(prefsListener) firestoreRepository.removeListener(feedbackListener) + panelDetector?.close() + panelDetector = null Timber.d("ViewModel instance cleared (onCleared).") } @@ -4627,6 +4655,104 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return authRepository.getIdToken() } + fun testPanelDetection(context: Context) { + viewModelScope.launch(mlDispatcher) { + try { + val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite") + if (!modelFile.exists()) { + withContext(Dispatchers.Main) { showBanner("Model not found", isError = true) } + return@launch + } + + val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ } + ?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ } + + if (cbzItem == null) { + withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) } + return@launch + } + + val uri = cbzItem.getUri() ?: return@launch + Timber.d("BATCH TEST START: ${cbzItem.displayName}") + + var cacheFile: File? = null + try { + val detector = getOrInitDetector(context) ?: run { + withContext(Dispatchers.Main) { showBanner("Model could not be loaded", isError = true) } + return@launch + } + cacheFile = File(context.cacheDir, "temp_test_batch.cbz") + context.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + + // 1. Initialize Model Once + val initStartTime = System.currentTimeMillis() + val initDuration = System.currentTimeMillis() - initStartTime + Timber.d(">>> [BATCH] Model Initialization: ${initDuration}ms") + + val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) + val totalPages = archiveDoc.getPageCount() + + val startIndex = 3 + val numPagesToTest = 10 + val endIndex = minOf(startIndex + numPagesToTest - 1, totalPages - 1) + + val resultsLog = StringBuilder() + resultsLog.append("Batch Results:\n") + + // 2. Loop through pages + for (i in startIndex..endIndex) { + val page = archiveDoc.openPage(i) + if (page != null) { + val w = page.getPageWidthPoint() + val h = page.getPageHeightPoint() + if (w > 0 && h > 0) { + val bitmap = androidx.core.graphics.createBitmap(w, h) + page.renderPageBitmap(bitmap, 0, 0, w, h, false) + + // Measure precise inference time + val pageStartTime = System.currentTimeMillis() + val panels = detector.detectPanels(bitmap) + val pageDuration = System.currentTimeMillis() - pageStartTime + + val logLine = "Page $i: ${pageDuration}ms (Found ${panels.size} panels)" + Timber.d(">>>[BATCH] $logLine") + resultsLog.append("$logLine\n") + bitmap.recycle() + delay(20) + } + page.close() + } + } + archiveDoc.close() + + withContext(Dispatchers.Main) { + Timber.i(resultsLog.toString()) + showBanner("Batch test complete! Check logs for page-by-page timings.") + } + + } finally { + cacheFile?.delete() + } + } catch (e: Exception) { + Timber.e(e, "Batch test failed") + } + } + } + + suspend fun detectComicPanels(bitmap: Bitmap, context: Context): List { + return withContext(mlDispatcher) { + try { + val detector = getOrInitDetector(context) + detector?.detectPanels(bitmap) ?: emptyList() + } catch (e: Exception) { + Timber.e(e, "Error during panel detection") + emptyList() + } + } + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 0b38143..402146e 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -28,10 +28,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.ui.graphics.Color import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -55,8 +57,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.SelectAll @@ -69,6 +73,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -85,6 +90,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -100,6 +106,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -111,6 +118,7 @@ import java.util.Date import java.util.Locale import kotlin.math.log10 import kotlin.math.pow +import kotlin.math.roundToInt internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html" internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html" @@ -254,15 +262,15 @@ fun CustomTopAppBar( actions: @Composable RowScope.() -> Unit = {} ) { Surface( - modifier = modifier - .fillMaxWidth() - .height(56.dp), + modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 2.dp ) { Row( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .statusBarsPadding() + .height(56.dp) .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -874,20 +882,168 @@ fun AutoSizeText( @Composable fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) { - val containerColor = if (overlay) androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer - val contentColor = if (overlay) androidx.compose.ui.graphics.Color.White else MaterialTheme.colorScheme.onSecondaryContainer + val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer + val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer Surface( modifier = modifier, - shape = RoundedCornerShape(4.dp), + shape = RoundedCornerShape(50), color = containerColor, - contentColor = contentColor + contentColor = contentColor, + border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null ) { Text( text = type.name.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } +} + +private const val UNKNOWN_AUTHOR_LABEL = "No author listed" + +fun RecentFileItem.cardTitle(): String { + return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName +} + +fun RecentFileItem.cardAuthor(): String { + return author + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?: UNKNOWN_AUTHOR_LABEL +} + +fun RecentFileItem.progressPercentValue(): Int { + return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt() +} + +fun RecentFileItem.progressFraction(): Float { + return progressPercentValue() / 100f +} + +fun RecentFileItem.isOpdsStream(): Boolean { + return uriString?.startsWith("opds-pse://") == true +} + +@Composable +private fun statusBadgeColors(overlay: Boolean): Pair { + 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) ) } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt index 29eb307..302396b 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt @@ -94,6 +94,7 @@ import com.aryan.reader.R import com.aryan.reader.RenderMode import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubTocEntry +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -329,29 +330,31 @@ private fun ChaptersList( mutableStateOf(allParentIndices) } - val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) { - val result = mutableListOf>() - val visibilityStack = BooleanArray(50) { false } - visibilityStack[0] = true + val visibleItemInfo by remember(effectiveToc) { + derivedStateOf { + val result = mutableListOf>() + val visibilityStack = BooleanArray(50) { false } + visibilityStack[0] = true - for (i in effectiveToc.indices) { - val entry = effectiveToc[i] - val depth = entry.depth.coerceIn(0, 49) + for (i in effectiveToc.indices) { + val entry = effectiveToc[i] + val depth = entry.depth.coerceIn(0, 49) - if (visibilityStack[depth]) { - result.add(i to entry) + if (visibilityStack[depth]) { + result.add(i to entry) - val isExpanded = expandedEntryIndices.contains(i) - if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = isExpanded - } - } else { - if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = false + val isExpanded = expandedEntryIndices.contains(i) + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = isExpanded + } + } else { + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = false + } } } + result } - result } val coroutineScope = rememberCoroutineScope() @@ -367,23 +370,29 @@ private fun ChaptersList( val targetEntry = activeTocEntry ?: return@launch val targetOriginalIndex = effectiveToc.indexOf(targetEntry) if (targetOriginalIndex != -1) { - // Ensure parents are expanded var currentLevel = targetEntry.depth val newExpanded = expandedEntryIndices.toMutableSet() + for (i in targetOriginalIndex downTo 0) { val entry = effectiveToc[i] if (entry.depth < currentLevel) { newExpanded.add(i) currentLevel = entry.depth } + if (currentLevel == 0) break } + expandedEntryIndices = newExpanded - // Delay to allow visibility array to recompose - kotlinx.coroutines.delay(100) - val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry } + if (visibleIdx != -1) { + var attempts = 0 + while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) { + delay(30) + attempts++ + } + listState.animateScrollToItem(visibleIdx) } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index f58519b..cc804a5 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -448,7 +448,7 @@ fun EpubReaderScreen( } @Suppress("ControlFlowWithEmptyBody") -@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") +@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @androidx.annotation.OptIn(UnstableApi::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(ExperimentalMaterial3Api::class) @@ -481,6 +481,13 @@ fun EpubReaderHost( val activity = context as? Activity val scope = rememberCoroutineScope() var bannerMessage by remember { mutableStateOf(null) } + DisposableEffect(window, view) { + onDispose { + window?.let { + WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars()) + } + } + } val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt b/app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt new file mode 100644 index 0000000..fa3e86b --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt @@ -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 + fun close() +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index d5f7fd2..8d05883 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -1908,7 +1908,6 @@ internal fun PaginatedReaderContent( val down = event.changes.firstOrNull { it.pressed } if (down != null) { pageTurnTouchY = down.position.y - Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}") } } } @@ -3952,10 +3951,6 @@ private fun Modifier.realisticBookPage( isDarkTheme: Boolean, touchY: Float? ): Modifier = composed { - // Log composition frequency - SideEffect { - Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}") - } val frontPath = remember { Path() } val backPath = remember { Path() } @@ -3965,7 +3960,6 @@ private fun Modifier.realisticBookPage( .graphicsLayer { val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction - // Log layer property updates if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) { Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset") } @@ -3994,7 +3988,14 @@ private fun Modifier.realisticBookPage( val h = size.height val startY = touchY ?: h - val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f) + val rawCenterDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f) + + val flattenFactor = if (progress > 0.75f) { + ((progress - 0.75f) / 0.25f).coerceIn(0f, 1f) + } else { + 0f + } + val centerDist = rawCenterDist * (1f - flattenFactor) val cornerY = if (centerDist >= 0) h else 0f diff --git a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt b/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt index 55782f0..9276160 100644 --- a/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt +++ b/app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt @@ -19,13 +19,23 @@ */ package com.aryan.reader.pdf +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -35,19 +45,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Redo import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DoNotTouch +import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.TouchApp import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -329,4 +349,160 @@ private fun DockIcon( modifier = Modifier.size(iconSize) ) } +} + +@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)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt new file mode 100644 index 0000000..1feb9fd --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt @@ -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)) } }) +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt new file mode 100644 index 0000000..3f7f9f9 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt @@ -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, + val flatTableOfContents: List +) + +internal class DocumentCache(val maxSize: Int = 3) { + val cache = object : LruCache(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?, + 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") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt new file mode 100644 index 0000000..cace9ec --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt @@ -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>) : 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) } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt new file mode 100644 index 0000000..2437e73 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt @@ -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, + bookmarks: Set, + userHighlights: List, + currentPage: Int, + customHighlightColors: Map, + 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>() + 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(null) } + var showDeleteConfirmDialogFor by remember { mutableStateOf(null) } + var showRenameBookmarkDialog by remember { mutableStateOf(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(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") } + } + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt new file mode 100644 index 0000000..898456e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 22de7c1..bcf1d4f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -347,7 +347,9 @@ data class PageStaticData( val targetWidth: Int, val targetHeight: Int, val colorFilter: StableHolder, - val isDarkMode: Boolean + val isDarkMode: Boolean, + val excludeImages: Boolean, + val imageRects: StableHolder> ) @Stable @@ -416,6 +418,7 @@ internal fun PdfPageComposable( onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), + excludeImages: Boolean = false, onDoubleTap: ((Offset) -> Unit)? = null, isEditMode: Boolean = false, drawingState: PdfDrawingState? = null, @@ -451,7 +454,9 @@ internal fun PdfPageComposable( customHighlightColors: Map = emptyMap(), onPaletteClick: (() -> Unit)? = null, lockedState: Triple? = null, - onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null + onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, + onDetectPanels: suspend (Bitmap) -> List = { emptyList() }, + onShowPanelPopup: (Bitmap) -> Unit = {} ) { val pdfDocumentItem = pdfDocument.item var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } @@ -852,11 +857,13 @@ internal fun PdfPageComposable( @Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf>(emptyList()) } var standardAnnotScreenRects by remember { mutableStateOf>>(emptyList()) } + var imageScreenRects by remember { mutableStateOf>(emptyList()) } LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) { if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) { if (pageLinks.isNotEmpty()) pageLinks = emptyList() if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList() + if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList() return@LaunchedEffect } @@ -864,6 +871,7 @@ internal fun PdfPageComposable( val allLinks = mutableListOf() var finalDisplayList = emptyList() var mappedAnnots = emptyList>() + var mappedImageRects = emptyList() val annotLink = 2 try { @@ -931,6 +939,39 @@ internal fun PdfPageComposable( Timber.e(e, "Error fetching web links") } + // --- Extract Image Bounds --- + try { + val pagePtr = pageWrapper.getNativePointer() + if (pagePtr != 0L) { + val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr) + val imgRects = mutableListOf() + val outRect = FloatArray(4) + + for (i in 0 until objCount) { + if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE + if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) { + val pdfRectF = android.graphics.RectF( + min(outRect[0], outRect[2]), + max(outRect[1], outRect[3]), + max(outRect[0], outRect[2]), + min(outRect[1], outRect[3]) + ) + val deviceRect = pageWrapper.mapRectToDevice( + 0, 0, actualBitmapWidthPx, actualBitmapHeightPx, + currentPageRotation, pdfRectF + ) + if (deviceRect.width() > 0 && deviceRect.height() > 0) { + imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)) + } + } + } + } + mappedImageRects = imgRects + } + } catch (e: Exception) { + Timber.tag("PdfImageDebug").e(e, "Error extracting image rects") + } + // 3. Extract Embedded Annotations try { val pagePtr = pageWrapper.getNativePointer() @@ -1035,6 +1076,7 @@ internal fun PdfPageComposable( pageLinks = allLinks embeddedAnnotations = finalDisplayList standardAnnotScreenRects = mappedAnnots + imageScreenRects = mappedImageRects } } } @@ -2627,7 +2669,38 @@ internal fun PdfPageComposable( coroutineScope.launch { val startScale = scale val targetScale = if (startScale > 1.1f) 1f else 2.5f - Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale") + + if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) { + val tapInContentCoords = screenToContentCoordinates(tapOffset) + + val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat() + val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat() + val tapXInBitmap = tapInContentCoords.x * ratioX + val tapYInBitmap = tapInContentCoords.y * ratioY + + val panels = onDetectPanels(bitmapState!!) + + val tappedPanel = panels.firstOrNull { + it.contains(tapXInBitmap, tapYInBitmap) + } + + if (tappedPanel != null) { + Timber.d("Popup: Cropping panel $tappedPanel") + val left = tappedPanel.left.coerceAtLeast(0f).toInt() + val top = tappedPanel.top.coerceAtLeast(0f).toInt() + val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt() + val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt() + val width = right - left + val height = bottom - top + + if (width > 0 && height > 0) { + val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height) + onShowPanelPopup(cropped) + return@launch + } + } + } + val startOffset = offset val targetOffsetUnbounded = if (targetScale <= 1.1f) { Offset.Zero @@ -2658,10 +2731,10 @@ internal fun PdfPageComposable( ) ) { val progress = value - scale = lerp( + scale = androidx.compose.ui.util.lerp( startScale, targetScale, progress ) - offset = lerp( + offset = androidx.compose.ui.geometry.lerp( startOffset, targetOffset, progress ) onScaleChanged(scale) @@ -3555,6 +3628,7 @@ internal fun PdfPageComposable( val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) } val stableTiles = remember(tiles) { StableHolder(tiles) } val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) } + val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) } val staticData = remember( stableBitmapState, @@ -3567,7 +3641,9 @@ internal fun PdfPageComposable( actualBitmapWidthPx, actualBitmapHeightPx, stableColorFilter, - isDarkMode + isDarkMode, + excludeImages, + stableImageRects ) { Timber.tag("PdfDrawPerf").v( "STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}" @@ -3583,7 +3659,9 @@ internal fun PdfPageComposable( targetWidth = actualBitmapWidthPx, targetHeight = actualBitmapHeightPx, colorFilter = stableColorFilter, - isDarkMode = isDarkMode + isDarkMode = isDarkMode, + excludeImages = excludeImages, + imageRects = stableImageRects ) } @@ -3659,6 +3737,7 @@ internal fun PdfPageComposable( } }, scale = scale, + uiScale = effectiveScale, offset = offset, startHandlePos = startHandleContentPosition.value, endHandlePos = endHandleContentPosition.value, @@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer( targetWidth: Int, targetHeight: Int, colorFilter: ColorFilter? = null, - isDarkMode: Boolean = false + isDarkMode: Boolean = false, + excludeImages: Boolean = false, + imageRects: List = emptyList() ) { - Canvas(modifier = Modifier - .fillMaxSize() - .graphicsLayer()) { + Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) { translate(left = centeringOffsetX, top = centeringOffsetY) { clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) { if (bitmapState != null && !bitmapState.isRecycled) { @@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer( filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) + if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { + imageRects.forEach { rect -> + val scaleX = bitmapState.width.toFloat() / dstW.toFloat() + val scaleY = bitmapState.height.toFloat() / dstH.toFloat() + + val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0) + val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0) + val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width) + val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height) + + val w = srcRectRight - srcRectLeft + val h = srcRectBottom - srcRectTop + if (w > 0 && h > 0) { + drawImage( + image = bitmapState.asImageBitmap(), + srcOffset = IntOffset(srcRectLeft, srcRectTop), + srcSize = IntSize(w, h), + dstOffset = IntOffset(rect.left, rect.top), + dstSize = IntSize(rect.width(), rect.height()), + colorFilter = null, + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + } + } + } + val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000 if (needsTiling) { tiles.forEach { tile -> @@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer( srcOffset = IntOffset.Zero, srcSize = IntSize(tile.bitmap.width, tile.bitmap.height), dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top), - dstSize = IntSize( - tile.renderRect.width(), tile.renderRect.height() - ), + dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()), colorFilter = colorFilter, filterQuality = androidx.compose.ui.graphics.FilterQuality.High ) + + if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { + imageRects.forEach { imgRect -> + val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt() + val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt() + val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt() + val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt() + + val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left) + val intersectTop = max(scaledImgRectTop, tile.renderRect.top) + val intersectRight = min(scaledImgRectRight, tile.renderRect.right) + val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom) + + val iw = intersectRight - intersectLeft + val ih = intersectBottom - intersectTop + + if (iw > 0 && ih > 0) { + val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width() + val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height() + + val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt() + val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt() + val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt() + val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt() + + val srcW = srcRight - srcLeft + val srcH = srcBottom - srcTop + + if (srcW > 0 && srcH > 0) { + drawImage( + image = tile.bitmap.asImageBitmap(), + srcOffset = IntOffset(srcLeft, srcTop), + srcSize = IntSize(srcW, srcH), + dstOffset = IntOffset(intersectLeft, intersectTop), + dstSize = IntSize(iw, ih), + colorFilter = null, + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + } + } + } + } } } } @@ -4497,7 +4642,9 @@ private fun PdfPageStaticLayer(data: PageStaticData) { targetWidth = data.targetWidth, targetHeight = data.targetHeight, colorFilter = data.colorFilter.item, - isDarkMode = data.isDarkMode + isDarkMode = data.isDarkMode, + excludeImages = data.excludeImages, + imageRects = data.imageRects.item ) } @@ -4571,6 +4718,7 @@ private fun PdfPageRenderer( drawingState: PdfDrawingState?, onCanvasSizeChanged: (Float, Float) -> Unit, scale: Float, + uiScale: Float, offset: Offset, startHandlePos: Offset?, endHandlePos: Offset?, @@ -4697,6 +4845,12 @@ private fun PdfPageRenderer( } } + if (textBoxes.isNotEmpty()) { + androidx.compose.runtime.SideEffect { + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}") + } + } + textBoxes.forEach { box -> val isDraggingThisBox = (box.id == draggingBoxId) val boxAlpha = if (isDraggingThisBox) 0f else 1f @@ -4707,17 +4861,27 @@ private fun PdfPageRenderer( isSelected = (box.id == selectedTextBoxId), isEditMode = isEditMode, isDarkMode = staticData.isDarkMode, + scale = uiScale, pageWidthPx = staticData.targetWidth.toFloat(), pageHeightPx = staticData.targetHeight.toFloat(), handlePosition = HandlePosition.AUTO, onBoundsChanged = { newBounds -> - onTextBoxChange(box.copy(relativeBounds = newBounds)) + Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId") + if (draggingBoxId != box.id) { + onTextBoxChange(box.copy(relativeBounds = newBounds)) + } else { + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally") + } }, onTextChanged = { newText -> onTextBoxChange(box.copy(text = newText)) }, - onSelect = { onTextBoxSelect(box.id) }, + onSelect = { + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]") + onTextBoxSelect(box.id) + }, onDragStart = { touchOffset -> + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset") if (isVerticalScroll) { val topLeft = Offset( box.relativeBounds.left * staticData.targetWidth, @@ -4729,10 +4893,12 @@ private fun PdfPageRenderer( } }, onDrag = { delta, currentBounds -> + Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale") if (isVerticalScroll) { onTextBoxDrag(delta) } else { - onTextBoxDrag(delta) + val scaledDelta = delta * scale + onTextBoxDrag(scaledDelta) val width = staticData.targetWidth val edgeThreshold = 60f @@ -4746,9 +4912,11 @@ private fun PdfPageRenderer( } }, onDragEnd = { + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]") onTextBoxDragEnd() }, onDragCancel = { + Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]") onTextBoxDragEnd() }, modifier = Modifier diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt new file mode 100644 index 0000000..8ad1040 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -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 { + 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) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } +} + +internal fun loadCustomHighlightColors(context: Context): Map { + 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) { + 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? { + 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? { + 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 { + 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) +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt new file mode 100644 index 0000000..0175d56 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt @@ -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, + 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, + 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() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt new file mode 100644 index 0000000..d531e0b --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt @@ -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, + onUpdate: (Set) -> 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 } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt b/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt index b1a9d00..c9ff4a3 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt @@ -21,7 +21,8 @@ package com.aryan.reader.pdf import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -52,7 +53,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle @@ -78,6 +84,49 @@ enum class HandlePosition { TOP, BOTTOM, AUTO } +// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it +suspend fun PointerInputScope.detectEagerDragGestures( + onDragStart: (Offset) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + onDrag: (PointerInputChange, Offset) -> Unit +) { + awaitEachGesture { + var dragStarted = false + try { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() // Consume immediately + onDragStart(down.position) + dragStarted = true + val pointerId = down.id + var canceled = false + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null) { + canceled = true + break + } + if (change.changedToUp()) { + change.consume() + break + } + if (change.positionChanged()) { + val dragAmount = change.position - change.previousPosition + change.consume() + onDrag(change, dragAmount) + } + } + if (canceled) onDragCancel() else onDragEnd() + dragStarted = false + } finally { + if (dragStarted) { + onDragCancel() + } + } + } +} + @Composable fun ResizableTextBox( box: PdfTextBox, @@ -86,6 +135,7 @@ fun ResizableTextBox( isDarkMode: Boolean, pageWidthPx: Float, pageHeightPx: Float, + scale: Float = 1f, onBoundsChanged: (Rect) -> Unit, onTextChanged: (String) -> Unit, onSelect: () -> Unit, @@ -101,8 +151,9 @@ fun ResizableTextBox( val density = LocalDensity.current val focusRequester = remember { FocusRequester() } - val handleSize = 10.dp - val handleTouchSize = 40.dp + // Counter-scale fixed sizes so they render proportionally regardless of the zoom level + val handleSize = (10f / scale).dp + val handleTouchSize = (40f / scale).dp val handleSizePx = with(density) { handleSize.toPx() } val halfHandlePx = handleSizePx / 2f val handleTouchSizePx = with(density) { handleTouchSize.toPx() } @@ -126,7 +177,9 @@ fun ResizableTextBox( } } } - + androidx.compose.runtime.SideEffect { + Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}") + } var currentRectPx by remember { mutableStateOf( Rect( @@ -152,6 +205,8 @@ fun ResizableTextBox( right = box.relativeBounds.right * pageWidthPx, bottom = box.relativeBounds.bottom * pageHeightPx ) + Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx") + if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f || kotlin.math.abs(newPx.top - currentRectPx.top) > 1f || kotlin.math.abs(newPx.width - currentRectPx.width) > 1f || @@ -162,20 +217,19 @@ fun ResizableTextBox( } } - val requiredBottomSpacePx = with(density) { 60.dp.toPx() } + val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale - val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) { - derivedStateOf { - when (handlePosition) { + // Freeze handle position while dragging to prevent UI jumping + var isHandleAtTop by remember { mutableStateOf(false) } + + LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) { + if (!isDraggingOrResizing) { + isHandleAtTop = when (handlePosition) { HandlePosition.TOP -> true HandlePosition.BOTTOM -> false HandlePosition.AUTO -> { - if (pageHeightPx <= 0f) { - false - } else { - val spaceBelow = pageHeightPx - currentRectPx.bottom - spaceBelow < requiredBottomSpacePx - } + if (pageHeightPx <= 0f) false + else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx } } } @@ -184,11 +238,9 @@ fun ResizableTextBox( Box( modifier = modifier .zIndex(if (isSelected) 10f else 0f) - .offset { - IntOffset( - (currentRectPx.left - halfHandlePx).roundToInt(), - (currentRectPx.top - halfHandlePx).roundToInt() - ) + .graphicsLayer { + translationX = currentRectPx.left - halfHandlePx + translationY = currentRectPx.top - halfHandlePx } .size( width = with(density) { (currentRectPx.width + handleSizePx).toDp() }, @@ -201,10 +253,13 @@ fun ResizableTextBox( .fillMaxSize() .padding(handleSize / 2) .pointerInput(Unit) { - detectTapGestures { onSelect() } + detectTapGestures { + Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]") + onSelect() + } } .then( - if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier + if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier ) ) { BasicTextField( @@ -220,7 +275,7 @@ fun ResizableTextBox( background = box.backgroundColor, fontFamily = fontFamily, fontSize = with(LocalDensity.current) { - (box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp() + (box.fontSize * pageHeightPx).toSp() }, fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, @@ -267,8 +322,11 @@ fun ResizableTextBox( } .size(handleTouchSize) .pointerInput(onBoundsChanged) { - detectDragGestures( - onDragStart = { isDraggingOrResizing = true }, + detectEagerDragGestures( + onDragStart = { + Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle") + isDraggingOrResizing = true + }, onDragEnd = { isDraggingOrResizing = false val normalized = Rect( @@ -277,40 +335,42 @@ fun ResizableTextBox( right = currentRectPx.right / pageWidthPx, bottom = currentRectPx.bottom / pageHeightPx ) + Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized") onBoundsChanged(normalized) }, onDragCancel = { isDraggingOrResizing = false } ) { change, dragAmount -> - change.consume() + Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount") + var l = currentRectPx.left var t = currentRectPx.top var r = currentRectPx.right var b = currentRectPx.bottom val dx = dragAmount.x val dy = dragAmount.y - val minSize = 50f + val minSize = 50f / scale when (handle) { ResizeHandle.TOP_LEFT -> { - l = (l + dx).coerceIn(0f, r - minSize) - t = (t + dy).coerceIn(0f, b - minSize) + l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) + t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) } - ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize) + ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) ResizeHandle.TOP_RIGHT -> { - r = (r + dx).coerceIn(l + minSize, pageWidthPx) - t = (t + dy).coerceIn(0f, b - minSize) + r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) + t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize)) } - ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx) + ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) ResizeHandle.BOTTOM_RIGHT -> { - r = (r + dx).coerceIn(l + minSize, pageWidthPx) - b = (b + dy).coerceIn(t + minSize, pageHeightPx) + r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx)) + b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) } - ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx) + ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) ResizeHandle.BOTTOM_LEFT -> { - l = (l + dx).coerceIn(0f, r - minSize) - b = (b + dy).coerceIn(t + minSize, pageHeightPx) + l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) + b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx)) } - ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize) + ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize)) else -> {} } currentRectPx = Rect(l, t, r, b) @@ -328,13 +388,15 @@ fun ResizableTextBox( DragPill( isDarkMode = isDarkMode, + scale = scale, modifier = Modifier .align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter) - .offset(y = if (isHandleAtTop) (-32).dp else 32.dp) + .offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp) .zIndex(20f) .pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) { - detectDragGestures( + detectEagerDragGestures( onDragStart = { offset -> + Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset") isDraggingOrResizing = true onDragStart(offset) }, @@ -346,6 +408,7 @@ fun ResizableTextBox( right = currentRectPx.right / pageWidthPx, bottom = currentRectPx.bottom / pageHeightPx ) + Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized") onBoundsChanged(normalized) onDragEnd() }, @@ -354,13 +417,12 @@ fun ResizableTextBox( onDragCancel() } ) { change, dragAmount -> - change.consume() val w = currentRectPx.width val h = currentRectPx.height val rawLeft = currentRectPx.left + dragAmount.x val rawTop = currentRectPx.top + dragAmount.y - val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w) - val newTop = rawTop.coerceIn(0f, pageHeightPx - h) + val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w)) + val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h)) val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h) currentRectPx = newRect onDrag(dragAmount, newRect) @@ -374,21 +436,22 @@ fun ResizableTextBox( @Composable private fun DragPill( modifier: Modifier = Modifier, - isDarkMode: Boolean + isDarkMode: Boolean, + scale: Float = 1f ) { Surface( modifier = modifier - .size(width = 48.dp, height = 24.dp), + .size(width = (48f / scale).dp, height = (24f / scale).dp), shape = CircleShape, color = if (isDarkMode) Color.White else Color.Black, contentColor = if (isDarkMode) Color.Black else Color.White, - shadowElevation = 4.dp + shadowElevation = (4f / scale).dp ) { Box(contentAlignment = Alignment.Center) { Icon( painter = painterResource(id = R.drawable.drag_handle), contentDescription = "Drag to move text box", - modifier = Modifier.size(20.dp) + modifier = Modifier.size((20f / scale).dp) ) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt b/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt new file mode 100644 index 0000000..440a571 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt @@ -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 { + 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() + val visited = mutableSetOf() + + /** + * Corrected traversal: Iterative for siblings, recursive for children. + */ + fun walk(parentList: MutableList, 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, level: Int = 0): List { + Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items") + val entries = mutableListOf() + 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 { + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt new file mode 100644 index 0000000..245e085 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -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, + isScrollLocked: Boolean, + isEditMode: Boolean, + displayMode: DisplayMode, + isKeepScreenOn: Boolean, + isTtsSessionActive: Boolean, + isBookmarked: Boolean, + canDeletePage: Boolean, + isReflowingThisBook: Boolean, + hasReflowFile: Boolean, + isPdfDocumentLoaded: Boolean, + isTabsEnabled: Boolean, + openTabs: List, + 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, + 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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index c41e8a5..2e7ba9d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -184,11 +184,12 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo @OptIn(FlowPreview::class) @Composable internal fun PdfVerticalReader( + modifier: Modifier = Modifier, state: VerticalPdfReaderState, pdfDocument: StableHolder, activeTheme: com.aryan.reader.ReaderTheme, + excludeImages: Boolean = false, totalPages: Int, - modifier: Modifier = Modifier, virtualPages: List = emptyList(), pageAspectRatios: StableHolder>, headerHeight: Dp, @@ -284,6 +285,11 @@ internal fun PdfVerticalReader( val dividerHeightDp = 8.dp val dividerHeightPx = with(density) { dividerHeightDp.toPx() } + var isFlinging by remember { mutableStateOf(false) } + var isFastFlinging by remember { mutableStateOf(false) } + var isInteracting by remember { mutableStateOf(false) } + var isDragging by remember { mutableStateOf(false) } + val layoutState = remember(ratios, screenWidth, screenHeight, density) { data class LayoutResult(val pages: List, val totalHeight: Float) @@ -373,8 +379,41 @@ internal fun PdfVerticalReader( var isInitialLayout by remember { mutableStateOf(true) } val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } } + var hasRestoredLockedState by remember { mutableStateOf(false) } + + LaunchedEffect(isScrollLocked, lockedState, totalDocHeight, screenWidth, isInteracting) { + if (!hasRestoredLockedState && isScrollLocked && lockedState != null && totalDocHeight > 0f && screenWidth > 0f && !isInteracting) { + val (savedScale, savedPanX, savedPanY) = lockedState + + Timber.tag("PdfLockDiagnostic").i("RESTORING: Scale=$savedScale, X=$savedPanX, Y=$savedPanY") + + val zoomedDocWidth = screenWidth * savedScale + val minPanX = if (zoomedDocWidth < screenWidth) (screenWidth - zoomedDocWidth) / 2f else -(zoomedDocWidth - screenWidth) + val maxPanX = if (zoomedDocWidth < screenWidth) minPanX else 0f + + val zoomedDocHeight = totalDocHeight * savedScale + val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) + val maxPanY = headerHeightPx + + zoomAnimatable.stop() + panXAnimatable.stop() + panYAnimatable.stop() + + panXAnimatable.updateBounds(minPanX, maxPanX) + panYAnimatable.updateBounds(minPanY, maxPanY) + + zoomAnimatable.snapTo(savedScale) + panXAnimatable.snapTo(savedPanX) + panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY)) + + Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}") + + hasRestoredLockedState = true + } + } + LaunchedEffect(layoutState.pages) { - if (!isInitialLayout) { + if (!isInitialLayout && !isScrollLocked) { val targetPageIdx = if (targetPageDuringResize.intValue != -1) { targetPageDuringResize.intValue } else { @@ -410,21 +449,13 @@ internal fun PdfVerticalReader( launch { panXAnimatable.snapTo(targetPanX) } launch { panYAnimatable.snapTo(finalPanY) } } - - panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) - state.currentPage = targetPageIdx - if (isFit) onZoomChange(targetZoom) } + } + if (!isInitialLayout) { delay(50) isResizing = false targetPageDuringResize.intValue = -1 - } else if (isScrollLocked && lockedState != null) { - val (savedScale, savedPanX, _) = lockedState - coroutineScope { - launch { zoomAnimatable.snapTo(savedScale) } - launch { panXAnimatable.snapTo(savedPanX) } - } } isInitialLayout = false } @@ -461,11 +492,6 @@ internal fun PdfVerticalReader( return clampValues(targetZoom, targetPanX, targetPanY) } - var isFlinging by remember { mutableStateOf(false) } - var isFastFlinging by remember { mutableStateOf(false) } - var isInteracting by remember { mutableStateOf(false) } - var isDragging by remember { mutableStateOf(false) } - LaunchedEffect( totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing ) { @@ -483,6 +509,9 @@ internal fun PdfVerticalReader( panYAnimatable.snapTo(y) } if (x != currentPanX) { + if (isScrollLocked) { + Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x") + } panXAnimatable.snapTo(x) } } @@ -585,8 +614,8 @@ internal fun PdfVerticalReader( val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone) val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f) scrollDelta = -15f * ratio - } else if (draggingBoxOffset.y + draggingBoxSize.height > bottomEdge - scrollZone) { - val boxBottom = draggingBoxOffset.y + draggingBoxSize.height + } else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) { + val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone) val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f) scrollDelta = 15f * ratio @@ -744,6 +773,9 @@ internal fun PdfVerticalReader( } if (!isAnimating) { + if (isScrollLocked) { + Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]") + } panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX) } else { @@ -1575,6 +1607,7 @@ internal fun PdfVerticalReader( virtualPage = virtualPage, totalPages = totalPages, activeTheme = activeTheme, + excludeImages = excludeImages, externalScale = highResScale, onScaleChanged = {}, showAllTextHighlights = showAllTextHighlights, @@ -1638,17 +1671,20 @@ internal fun PdfVerticalReader( val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom) draggingBoxSize = Size( - box.relativeBounds.width * page.width * currentZoom, - box.relativeBounds.height * page.height * currentZoom + box.relativeBounds.width * page.width, + box.relativeBounds.height * page.height ) - draggingBoxPageHeight = page.height * currentZoom + draggingBoxPageHeight = page.height draggingBoxOffset = Offset(boxScreenX, boxScreenY) draggingBoxTouchDelta = touchOffset * currentZoom draggingBoxId = box.id }, onTextBoxDrag = { dragDelta -> - draggingBoxOffset += dragDelta + val currentZoom = zoomAnimatable.value + val scaledDelta = dragDelta * currentZoom + Timber.tag("PdfTextBoxDebug").v("VerticalReader onTextBoxDrag dragDelta=$dragDelta zoom=$currentZoom scaledDelta=$scaledDelta") + draggingBoxOffset += scaledDelta }, onTextBoxDragEnd = { scope.launch { @@ -1681,9 +1717,9 @@ internal fun PdfVerticalReader( val rawRelY = (finalBoxY / currentZoom) / targetPage.height val relW = - (draggingBoxSize.width / currentZoom) / targetPage.width + draggingBoxSize.width / targetPage.width val relH = - (draggingBoxSize.height / currentZoom) / targetPage.height + draggingBoxSize.height / targetPage.height val clampedW = relW.coerceAtMost(1f) val clampedH = relH.coerceAtMost(1f) @@ -2051,7 +2087,8 @@ internal fun PdfVerticalReader( val fontScaleRatio = if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f - val boxBottomY = draggingBoxOffset.y + draggingBoxSize.height + val currentZoom = zoomAnimatable.value + val boxBottomY = draggingBoxOffset.y + (draggingBoxSize.height * currentZoom) val spaceBelow = screenHeight - boxBottomY val overlayHandlePos = if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM @@ -2062,6 +2099,11 @@ internal fun PdfVerticalReader( draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt() ) } + .graphicsLayer { + scaleX = currentZoom + scaleY = currentZoom + transformOrigin = TransformOrigin(0f, 0f) + } .zIndex(100f)) { ResizableTextBox( box = draggedBox.copy( @@ -2073,6 +2115,7 @@ internal fun PdfVerticalReader( isDarkMode = isDarkMode, pageWidthPx = draggingBoxSize.width, pageHeightPx = draggingBoxSize.height, + scale = currentZoom, handlePosition = overlayHandlePos, onBoundsChanged = {}, onTextChanged = {}, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index c182c6b..d901006 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -30,37 +30,20 @@ import android.app.Activity import android.content.ClipData import android.content.Context import android.content.pm.PackageManager -import androidx.compose.material3.Switch -import androidx.compose.material.icons.filled.Settings -import androidx.compose.foundation.rememberScrollState import android.graphics.Bitmap -import com.aryan.reader.epubreader.SystemUiMode -import com.aryan.reader.epubreader.loadSystemUiMode -import com.aryan.reader.epubreader.saveSystemUiMode -import com.aryan.reader.epubreader.OptionSegmentedControl import android.graphics.RectF import android.net.Uri import android.os.Build -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.print.PrintManager -import android.provider.OpenableColumns import android.util.Base64 -import android.util.LruCache import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.widthIn import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -69,23 +52,16 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -97,7 +73,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset @@ -105,75 +80,41 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow 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.selection.selectable -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Star -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.CircularProgressIndicator import androidx.compose.material3.DrawerValue -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState @@ -201,9 +142,6 @@ import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset @@ -212,8 +150,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.key.Key @@ -234,7 +172,6 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -242,9 +179,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -252,8 +186,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -262,11 +194,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi -import androidx.paging.LoadState -import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey import androidx.work.WorkInfo import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult @@ -276,17 +204,15 @@ import com.aryan.reader.FileType import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.MainViewModel import com.aryan.reader.R -import com.aryan.reader.ReaderTheme import com.aryan.reader.ReaderThemePanel import com.aryan.reader.SearchResult -import com.aryan.reader.SearchTopBar import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager -import com.aryan.reader.TooltipIconButton import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.ExternalDictionaryHelper +import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.TtsOverlayControls import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes @@ -308,10 +234,7 @@ import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks -import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.suspend.PdfDocumentKt -import io.legere.pdfiumandroid.suspend.PdfPageKt -import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -329,8 +252,6 @@ import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import kotlin.math.PI @@ -339,855 +260,9 @@ import kotlin.math.atan2 import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -import kotlin.random.Random - -private const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" -private const val SETTINGS_PREFS_NAME = "epub_reader_settings" -private const val TTS_MODE_KEY = "tts_mode" -private const val DISPLAY_MODE_KEY = "pdf_display_mode" -private const val PDF_DARK_MODE_KEY = "pdf_dark_mode" -private const val OCR_LANGUAGE_KEY = "ocr_language_key" -private const val OCR_LANGUAGE_SELECTED_KEY = "ocr_language_selected_key" -private const val DOCK_LOCATION_KEY = "dock_location" -private const val DOCK_OFFSET_X_KEY = "dock_offset_x" -private const val DOCK_OFFSET_Y_KEY = "dock_offset_y" -private const val PDF_AUTO_SCROLL_SPEED_KEY = "pdf_auto_scroll_speed" -private const val PDF_AUTO_SCROLL_USE_SLIDER_KEY = "pdf_auto_scroll_use_slider" -private const val PDF_AUTO_SCROLL_MIN_SPEED_KEY = "pdf_auto_scroll_min_speed" -private const val PDF_AUTO_SCROLL_MAX_SPEED_KEY = "pdf_auto_scroll_max_speed" -private 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_" -private 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 fun loadPdfHiddenTools(context: Context): Set { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet() -} - -private fun savePdfHiddenTools(context: Context, hiddenTools: Set) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) } -} - -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") -} - -private fun loadCustomHighlightColors(context: Context): Map { - 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) - } -} - -private fun saveCustomHighlightColors(context: Context, colors: Map) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { - colors.forEach { (colorEnum, color) -> - putInt("custom_highlight_${colorEnum.name}", color.toArgb()) - } - } -} - -private 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) } -} - -private fun loadKeepScreenOn(context: Context): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(PDF_KEEP_SCREEN_ON_KEY, false) -} - -private fun savePdfThemeId(context: Context, themeId: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(PDF_THEME_KEY, themeId) } -} - -private fun loadPdfThemeId(context: Context): String { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(PDF_THEME_KEY, "no_theme") ?: "no_theme" -} - -val PdfBuiltInThemes = listOf( - 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) -) - -object PdfiumCoreProvider { - val core: PdfiumCoreKt by lazy { - PdfiumCoreKt(Dispatchers.Default) - } -} - -private 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) -} - -private fun saveUseOnlineDict(context: Context, useOnline: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) } -} - -private fun loadExternalDictPackage(context: Context): String? { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(PREF_EXTERNAL_DICT_PKG, null) -} - -private fun saveExternalDictPackage(context: Context, packageName: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) } -} - -private fun loadExternalTranslatePackage(context: Context): String? { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null) -} - -private fun saveExternalTranslatePackage(context: Context, packageName: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) } -} - -private fun loadExternalSearchPackage(context: Context): String? { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null) -} - -private fun saveExternalSearchPackage(context: Context, packageName: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) } -} - -private fun savePdfMusicianMode(context: Context, isEnabled: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) } -} - -private fun loadPdfMusicianMode(context: Context): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(PDF_MUSICIAN_MODE_KEY, false) -} - -private 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) } -} - -private 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) -} - -private 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) } -} - -private 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) -} - -private 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) - } -} - -private fun loadPdfAutoScrollLocalSettings(context: Context, bookId: String): Triple? { - 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) -} - -private 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) } -} - -private fun saveStylusOnlyMode(context: Context, isEnabled: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putBoolean(STYLUS_ONLY_MODE_KEY, isEnabled) } -} - -private fun loadStylusOnlyMode(context: Context): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(STYLUS_ONLY_MODE_KEY, false) -} - -private 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) -} - -private 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) } -} - -private 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) -} - -private 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) } -} - -private 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) -} - -private 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) } -} - -private 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) -} - -private fun generateShortId(): String { - return Random.nextInt(1000, 9999).toString() -} - -private 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" -} - -private 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) - } -} - -private fun loadPdfLockedState(context: Context, bookId: String): Triple? { - 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) - ) -} - -private enum class SaveMode { - ORIGINAL, ANNOTATED -} - -enum class SearchHighlightMode { - FOCUSED, ALL -} - -private sealed interface HistoryAction { - data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction - data class Remove(val items: Map>) : HistoryAction -} - -private enum class DockLocation { - TOP, BOTTOM, FLOATING -} - -private 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) - } -} - -private 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 - } -} - -private fun hasUserSelectedOcrLanguage(context: Context): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(OCR_LANGUAGE_SELECTED_KEY, false) -} - -private 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) - } -} - -private fun loadDockState(context: Context): Pair { - 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) -} - -private enum class DisplayMode { - PAGINATION, VERTICAL_SCROLL -} - -private fun saveDisplayMode(context: Context, mode: DisplayMode) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(DISPLAY_MODE_KEY, mode.name) } -} - -private 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 - } -} - -private const val MAX_FIXED_RECURSION = 128 - -/** - * Patches the library bug where siblings are truncated due to depth-state leakage. - */ -suspend fun PdfDocumentKt.getFixedTableOfContents(): List { - 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() - val visited = mutableSetOf() - - /** - * Corrected traversal: Iterative for siblings, recursive for children. - */ - fun walk(parentList: MutableList, 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 data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) - -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?, - 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) - } - } -} - -private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set { - 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() - } -} - -private fun savePdfDarkMode(context: Context, isDark: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putBoolean(PDF_DARK_MODE_KEY, isDark) } -} - -private fun loadPdfDarkMode(context: Context): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(PDF_DARK_MODE_KEY, false) -} - -private data class TtsPageData( - val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean -) - -private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) - -private fun flattenToc(bookmarks: List, level: Int = 0): List { - Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items") - val entries = mutableListOf() - 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 -} - -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 -private 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) - ) - } -} - -@OptIn(UnstableApi::class) -@Suppress("unused") -private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { - val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) - prefs.edit { putString(TTS_MODE_KEY, mode.name) } -} - -private 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") - } - } - } -} - -private 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 -} - -private data class DocumentCacheItem( - val doc: ReaderDocument, - val pfd: ParcelFileDescriptor, - val totalPages: Int, - val pageAspectRatios: List, - val flatTableOfContents: List -) - -private class DocumentCache(val maxSize: Int = 3) { - val cache = object : LruCache(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() } -} @Suppress("KotlinConstantConditions") -@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") +@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @ExperimentalMaterial3Api @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) @@ -1217,11 +292,13 @@ fun PdfViewerScreen( var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } var showThemePanel by remember { mutableStateOf(false) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } + var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } val documentCache = remember { DocumentCache(3) } val summaryCacheManager = remember(context) { SummaryCacheManager(context) } val tabStateMap = remember { mutableStateMapOf() } var showInsufficientCreditsDialog by remember { mutableStateOf(false) } + var poppedUpPanelBitmap by remember { mutableStateOf(null) } val activeTheme = remember(currentThemeId, customThemes) { PdfBuiltInThemes.find { it.id == currentThemeId } @@ -1231,7 +308,7 @@ fun PdfViewerScreen( val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse" var pageAspectRatios by remember { mutableStateOf>(emptyList()) } var showBars by rememberSaveable { mutableStateOf(true) } - var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } + var systemUiMode by remember { mutableStateOf(loadPdfSystemUiMode(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) } var documentPassword by rememberSaveable { mutableStateOf(null) } @@ -1400,7 +477,7 @@ fun PdfViewerScreen( isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId) } - val onPrintDocument = { + val onPrintDocument: () -> Unit = { val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager val jobName = "${context.getString(R.string.app_name)} - $originalFileName" @@ -1523,7 +600,17 @@ fun PdfViewerScreen( } val window = (view.context as? Activity)?.window - LaunchedEffect(systemUiMode, showBars) { + val showStandardBars = showBars && !isEditMode + + DisposableEffect(window, view) { + onDispose { + window?.let { + WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars()) + } + } + } + + LaunchedEffect(systemUiMode, showStandardBars) { if (window != null) { val insetsController = WindowCompat.getInsetsController(window, view) when (systemUiMode) { @@ -1531,7 +618,7 @@ fun PdfViewerScreen( insetsController.show(WindowInsetsCompat.Type.systemBars()) } SystemUiMode.SYNC -> { - if (showBars) { + if (showStandardBars) { insetsController.show(WindowInsetsCompat.Type.systemBars()) } else { insetsController.hide(WindowInsetsCompat.Type.systemBars()) @@ -1555,10 +642,11 @@ fun PdfViewerScreen( val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val dummySearcher: suspend (String) -> List = { emptyList() } val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher) + val navBarHeight = WindowInsets.systemBars.getBottom(density) - val showStandardBars = showBars && !isEditMode + val effectiveNavBarForSnackbar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp val snackbarPadding by animateDpAsState( - targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp, + targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp + effectiveNavBarForSnackbar else effectiveNavBarForSnackbar, label = "SnackbarPadding" ) @@ -1569,7 +657,6 @@ fun PdfViewerScreen( isDockDragging, showStandardBars, systemUiMode, - showBars, statusBarHeightDp ) { if (!isEditMode) { @@ -1578,11 +665,7 @@ fun PdfViewerScreen( h += 56.dp } - val isStatusBarVisible = when (systemUiMode) { - SystemUiMode.DEFAULT -> true - SystemUiMode.SYNC -> showBars - SystemUiMode.HIDDEN -> false - } + val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars) if (isStatusBarVisible) { h += statusBarHeightDp @@ -1591,7 +674,9 @@ fun PdfViewerScreen( } else { val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging val isPreviewingTop = snapPreviewLocation == DockLocation.TOP - if (isStickyTop || isPreviewingTop) dockHeight else 0.dp + if (isStickyTop || isPreviewingTop) { + dockHeight + if (systemUiMode == SystemUiMode.DEFAULT) statusBarHeightDp else 0.dp + } else 0.dp } } @@ -1605,7 +690,10 @@ fun PdfViewerScreen( dockLocation, snapPreviewLocation, isEditMode, - isDockDragging + isDockDragging, + systemUiMode, + navBarHeight, + density ) { derivedStateOf { if (!isEditMode) { @@ -1614,7 +702,9 @@ fun PdfViewerScreen( val isStickyBottom = dockLocation == DockLocation.BOTTOM && !isDockDragging val isPreviewingBottom = snapPreviewLocation == DockLocation.BOTTOM - if (isStickyBottom || isPreviewingBottom) dockHeight else 0.dp + if (isStickyBottom || isPreviewingBottom) { + dockHeight + if (systemUiMode == SystemUiMode.DEFAULT) with(density) { navBarHeight.toDp() } else 0.dp + } else 0.dp } } } @@ -1626,6 +716,8 @@ fun PdfViewerScreen( LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) { if (isScrollLocked) { delay(500) + Timber.tag("PdfLockDiagnostic").d("SAVING: BookId=$bookId | Scale=$currentActiveScale | X=${currentActiveOffset.x} | Y=${currentActiveOffset.y}") + lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) } @@ -2265,7 +1357,12 @@ fun PdfViewerScreen( val startY = 0.45f val newStyle = toolSettings.textStyle - val fontSizeNorm = 0.02f + + val pageRatio = displayPageRatios.getOrElse(currentP) { 1f } + val screenWidthPx = view.width.toFloat().takeIf { it > 0f } ?: with(density) { 360.dp.toPx() } + val estimatedPageHeightPx = if (pageRatio > 0) screenWidthPx / pageRatio else screenWidthPx + val newFontSizePx = with(density) { newStyle.fontSize.sp.toPx() } + val fontSizeNorm = if (estimatedPageHeightPx > 0) newFontSizePx / estimatedPageHeightPx else 0.02f val newBox = PdfTextBox( id = generateShortId(), @@ -2278,7 +1375,9 @@ fun PdfViewerScreen( isBold = newStyle.isBold, isItalic = newStyle.isItalic, isUnderline = newStyle.isUnderline, - isStrikeThrough = newStyle.isStrikeThrough + isStrikeThrough = newStyle.isStrikeThrough, + fontPath = newStyle.fontPath, + fontName = newStyle.fontName ) textBoxes.add(newBox) @@ -2447,16 +1546,12 @@ fun PdfViewerScreen( attempts++ } if (verticalReaderState.snapToPageHandler != null) { - Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)") - verticalReaderState.snapToPage(targetPage) - - var waitAttempts = 0 - while (verticalReaderState.currentPage != targetPage && waitAttempts < 20) { - delay(16) - waitAttempts++ + if (!isScrollLocked) { + Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)") + verticalReaderState.snapToPage(targetPage) + } else { + Timber.tag("PdfLockDiagnostic").d("UI: Skipping snapToPage request because Scroll is Locked.") } - } else { - Timber.tag("PdfPositionDebug").w("UI: snapToPageHandler is null after timeout") } } } @@ -3174,7 +2269,7 @@ fun PdfViewerScreen( if (rawPageText.isNullOrBlank()) { Timber.i("TTS: Pdfium text is blank or extraction failed. OCR fallback is temporarily disabled.") } else { - Timber.d("TTS: Closed page $pageToRead after successful Pdfium text extraction.") + Timber.d("TTS: Closed page $pageToRead after successful Pdfium text extraction.") } if (rawPageText != null && rawPageText!!.isNotBlank()) { @@ -3216,9 +2311,21 @@ fun PdfViewerScreen( ocrAttempted -> "OCR found no text on this page." else -> "Page seems empty or text not extractable." } - ttsController.stop() - isAutoPagingForTts = false - Timber.w("TTS start failed: $finalError") + + val nextPage = pageToRead + 1 + if (nextPage < totalPages) { + Timber.i("TTS found no text on page $pageToRead. Skipping to $nextPage.") + isAutoPagingForTts = true + if (displayMode == DisplayMode.PAGINATION) { + coroutineScope.launch { pagerState.animateScrollToPage(nextPage) } + } else { + startTts(pageToReadOverride = nextPage) + } + } else { + ttsController.stop() + isAutoPagingForTts = false + Timber.w("TTS start failed (reached end of document): $finalError") + } } } } @@ -3741,31 +2848,43 @@ fun PdfViewerScreen( var smartSearchResult by remember { mutableStateOf(null) } var currentPdfSearchResult by remember { mutableStateOf(null) } - val navBarHeight = WindowInsets.systemBars.getBottom(density) val imeHeight = WindowInsets.ime.getBottom(density) - val bottomScrollLimitPx = remember(isEditMode, imeHeight, navBarHeight, dockLocation, isDockMinimized) { + val bottomScrollLimitPx = remember(isEditMode, imeHeight, navBarHeight, dockLocation, isDockMinimized, systemUiMode, showStandardBars) { + val effectiveNavBar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) navBarHeight else 0 if (isEditMode) { if (imeHeight > 0) { imeHeight.toFloat() } else { if (dockLocation == DockLocation.BOTTOM && !isDockMinimized) { - with(density) { 100.dp.toPx() } + with(density) { 64.dp.toPx() } + effectiveNavBar } else { - with(density) { 16.dp.toPx() } + navBarHeight + with(density) { 16.dp.toPx() } + effectiveNavBar } } } else { - if (showBars) { - with(density) { 56.dp.toPx() } + navBarHeight + if (showStandardBars) { + with(density) { 56.dp.toPx() } + effectiveNavBar } else { - navBarHeight.toFloat() + effectiveNavBar.toFloat() } } } val topScrollLimitPx = with(density) { verticalHeaderHeight.toPx() } + LaunchedEffect(imeHeight, navBarHeight, systemUiMode, showStandardBars, isEditMode, bottomScrollLimitPx) { + Timber.tag(PDF_LAYOUT_DEBUG_TAG).d(""" + [Global Metrics] + - IME Height: ${imeHeight}px (${with(density) { imeHeight.toDp() }}) + - Nav Bar Height: ${navBarHeight}px (${with(density) { navBarHeight.toDp() }}) + - System UI Mode: $systemUiMode + - Show Standard Bars: $showStandardBars + - Is Edit Mode: $isEditMode + - Bottom Scroll Limit: ${bottomScrollLimitPx}px + """.trimIndent()) + } + LaunchedEffect(searchState.searchQuery, currentBookId) { val query = searchState.searchQuery if (query.isBlank() || currentBookId == null) { @@ -3862,7 +2981,7 @@ fun PdfViewerScreen( } val isTtsSessionActive = - (ttsState.currentText != null || ttsState.isLoading) && ttsState.playbackSource == "READER" + ((ttsState.currentText != null || ttsState.isLoading) && ttsState.playbackSource == "READER") || isAutoPagingForTts val onInternalLinkNav: (Int) -> Unit = { targetPage -> coroutineScope.launch { @@ -3972,536 +3091,52 @@ fun PdfViewerScreen( ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { - 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 = remember(flatTableOfContents, expandedEntryIndices) { - val result = mutableListOf>() - 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(pagerState.currentPage, verticalReaderState.currentPage, displayMode, flatTableOfContents) { - derivedStateOf { - val activePage = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - flatTableOfContents.lastOrNull { it.pageIndex <= activePage } - } - } - - 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 - } - } - expandedEntryIndices = newExpanded - - delay(100) - - val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry } - if (visibleIdx != -1) { - 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 = { - coroutineScope.launch { - drawerState.close() - if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(entry.pageIndex) - } else { - verticalReaderState.scrollToPage(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(null) - } - var showDeleteConfirmDialogFor by remember { - mutableStateOf(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 { - coroutineScope.launch { - drawerState.close() - if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage( - bookmark.pageIndex - ) - } else { - verticalReaderState.scrollToPage( - 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 = { - if (newTitle.isNotBlank()) { - val updatedBookmark = bookmarkToRename.copy( - title = newTitle - ) - bookmarks = - (bookmarks - bookmarkToRename) + updatedBookmark - } - 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 = { - bookmarks = bookmarks - 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(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 - coroutineScope.launch { drawerState.close() } - } - ) - DropdownMenuItem( - text = { Text("Delete") }, - onClick = { - showDeleteConfirmDialogFor = highlight - highlightMenuExpanded = false - } - ) - } - } - }, - modifier = Modifier.clickable { - coroutineScope.launch { - drawerState.close() - if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(highlight.pageIndex) - } else { - verticalReaderState.scrollToPage(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 = { - userHighlights.removeAll { it.id == highlightToDelete.id } - showDeleteConfirmDialogFor = null - } - ) { Text("Delete") } - }, - dismissButton = { - TextButton( - onClick = { showDeleteConfirmDialogFor = null } - ) { Text("Cancel") } - } - ) - } - } + PdfNavigationDrawerContent( + flatTableOfContents = flatTableOfContents, + bookmarks = bookmarks, + userHighlights = userHighlights, + currentPage = currentPage, + customHighlightColors = customHighlightColors, + onPageSelected = { targetPage -> + coroutineScope.launch { + if (displayMode == DisplayMode.PAGINATION) { + pagerState.scrollToPage(targetPage) + } else { + verticalReaderState.scrollToPage(targetPage) } } + }, + onRenameBookmark = { bookmarkToRename, newTitle -> + if (newTitle.isNotBlank()) { + val updatedBookmark = bookmarkToRename.copy(title = newTitle) + bookmarks = (bookmarks - bookmarkToRename) + updatedBookmark + } + }, + onDeleteBookmark = { bookmarkToDelete -> + bookmarks = bookmarks - bookmarkToDelete + }, + onDeleteHighlight = { highlightToDelete -> + onHighlightDelete(highlightToDelete.id) + }, + onNoteRequested = onNoteRequested, + onCloseDrawer = { + coroutineScope.launch { drawerState.close() } } - } + ) } }) { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost( hostState = snackbarHostState, modifier = Modifier.padding(bottom = snackbarPadding) ) } - ) { paddingValues -> - BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())) { - IntSize(constraints.maxWidth, constraints.maxHeight) + ) { _ -> + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + IntSize(constraints.maxWidth, constraints.maxHeight) val boxConstraints = constraints val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat() val boxMaxHeightFloat = boxConstraints.maxHeight.toFloat() @@ -4520,6 +3155,9 @@ fun PdfViewerScreen( textDecoration = richTextController.currentStyle.textDecoration ), modifier = Modifier + .align(Alignment.BottomStart) + .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)) + .padding(start = 16.dp, bottom = 120.dp) .size(1.dp) .alpha(0f) .clearAndSetSemantics { } @@ -4532,8 +3170,7 @@ fun PdfViewerScreen( } else { false } - } - .align(Alignment.TopStart), + }, ) } @@ -4746,6 +3383,7 @@ fun PdfViewerScreen( virtualPage = virtualPage, totalPages = totalDisplayPages, activeTheme = activeTheme, + excludeImages = excludeImages, isScrollLocked = isScrollLocked, customHighlightColors = customHighlightColors, onPaletteClick = { @@ -4832,6 +3470,13 @@ fun PdfViewerScreen( currentActiveOffset = newOffset } }, + onDetectPanels = { bitmap -> + Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show() + viewModel.detectComicPanels(bitmap, context) + }, + onShowPanelPopup = { croppedBitmap -> + poppedUpPanelBitmap = croppedBitmap + }, onTwoFingerSwipe = { direction -> coroutineScope.launch { val targetPage = @@ -4860,6 +3505,7 @@ fun PdfViewerScreen( }, draggingBoxId = paginationDraggingBoxId, onTextBoxDragStart = { box, _, _ -> + Timber.tag("PdfTextBoxDebug").d("Pagination onTextBoxDragStart [ID: ${box.id}] initialized") val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f } val containerWidthPx = boxConstraints.maxWidth @@ -4885,13 +3531,19 @@ fun PdfViewerScreen( box.relativeBounds.width * renderedWidth, box.relativeBounds.height * renderedHeight ) - paginationDraggingOffset = Offset( - offsetX + (box.relativeBounds.left * renderedWidth), - offsetY + (box.relativeBounds.top * renderedHeight) - ) paginationDragPageHeight = renderedHeight + + val baseBoxLeft = offsetX + (box.relativeBounds.left * renderedWidth) + val baseBoxTop = offsetY + (box.relativeBounds.top * renderedHeight) + val centerX = containerWidthPx / 2f + val centerY = containerHeightPx / 2f + val screenX = (baseBoxLeft - centerX) * currentActiveScale + centerX + currentActiveOffset.x + val screenY = (baseBoxTop - centerY) * currentActiveScale + centerY + currentActiveOffset.y + + paginationDraggingOffset = Offset(screenX, screenY) }, onTextBoxDrag = { dragDelta -> + Timber.tag("PdfTextBoxDebug").v("Pagination onTextBoxDrag delta=$dragDelta | currentOffset=$paginationDraggingOffset") paginationDraggingOffset += dragDelta val edgeThreshold = 60f @@ -4915,6 +3567,7 @@ fun PdfViewerScreen( } }, onTextBoxDragEnd = { + Timber.tag("PdfTextBoxDebug").d("Pagination onTextBoxDragEnd called [ID: $paginationDraggingBoxId] | EndOffset=$paginationDraggingOffset") val boxId = paginationDraggingBoxId if (boxId != null) { coroutineScope.launch { @@ -4948,8 +3601,14 @@ fun PdfViewerScreen( val relW = paginationOriginalRelSize.width val relH = paginationOriginalRelSize.height - val rawRelX = (paginationDraggingOffset.x - offsetX) / renderedWidth - val rawRelY = (paginationDraggingOffset.y - offsetY) / renderedHeight + val centerX = containerWidthPx / 2f + val centerY = containerHeightPx / 2f + + val unzoomedX = (paginationDraggingOffset.x - currentActiveOffset.x - centerX) / currentActiveScale + centerX + val unzoomedY = (paginationDraggingOffset.y - currentActiveOffset.y - centerY) / currentActiveScale + centerY + + val rawRelX = (unzoomedX - offsetX) / renderedWidth + val rawRelY = (unzoomedY - offsetY) / renderedHeight val maxRelX = (1f - relW - padRelX).coerceAtLeast(padRelX) val maxRelY = (1f - relH - padRelY).coerceAtLeast(padRelY) @@ -4957,9 +3616,11 @@ fun PdfViewerScreen( val finalRelX = rawRelX.coerceIn(padRelX, maxRelX) val finalRelY = rawRelY.coerceIn(padRelY, maxRelY) + val targetOffsetUnzoomedX = offsetX + (finalRelX * renderedWidth) + val targetOffsetUnzoomedY = offsetY + (finalRelY * renderedHeight) val targetOffset = Offset( - offsetX + (finalRelX * renderedWidth), - offsetY + (finalRelY * renderedHeight) + (targetOffsetUnzoomedX - centerX) * currentActiveScale + centerX + currentActiveOffset.x, + (targetOffsetUnzoomedY - centerY) * currentActiveScale + centerY + currentActiveOffset.y ) val startOffset = paginationDraggingOffset @@ -5000,7 +3661,7 @@ fun PdfViewerScreen( paginationDragPageHeight / paginationDraggingSize.height else 1f val screenHeight = boxConstraints.maxHeight.toFloat() - val boxBottomY = paginationDraggingOffset.y + paginationDraggingSize.height + val boxBottomY = paginationDraggingOffset.y + (paginationDraggingSize.height * currentActiveScale) val spaceBelow = screenHeight - boxBottomY val overlayHandlePos = if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM @@ -5012,6 +3673,11 @@ fun PdfViewerScreen( paginationDraggingOffset.y.roundToInt() ) } + .graphicsLayer { + scaleX = currentActiveScale + scaleY = currentActiveScale + transformOrigin = TransformOrigin(0f, 0f) + } ) { ResizableTextBox( box = draggedBox.copy( @@ -5019,10 +3685,11 @@ fun PdfViewerScreen( fontSize = draggedBox.fontSize * fontScaleRatio ), isSelected = true, - isEditMode = false, // Purely visual + isEditMode = false, isDarkMode = isPdfDarkMode, pageWidthPx = paginationDraggingSize.width, pageHeightPx = paginationDraggingSize.height, + scale = currentActiveScale, handlePosition = overlayHandlePos, onBoundsChanged = {}, onTextChanged = {}, @@ -5143,6 +3810,7 @@ fun PdfViewerScreen( state = verticalReaderState, pdfDocument = docHolder, activeTheme = activeTheme, + excludeImages = excludeImages, isScrollLocked = isScrollLocked, customHighlightColors = customHighlightColors, onPaletteClick = { showHighlightColorPicker = true }, @@ -5232,6 +3900,7 @@ fun PdfViewerScreen( bottomContentPaddingPx = bottomScrollLimitPx, topContentPaddingPx = topScrollLimitPx, onTextBoxMoved = { boxId, newPageIndex, newBounds -> + Timber.tag("PdfTextBoxDebug").d("Vertical Reader onTextBoxMoved[ID: $boxId] newPage=$newPageIndex bounds=$newBounds") val idx = textBoxes.indexOfFirst { it.id == boxId } if (idx != -1) { val oldBox = textBoxes[idx] @@ -5439,9 +4108,7 @@ fun PdfViewerScreen( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .padding( - top = if (showBars) 56.dp else 0.dp - ) // Push down if top bar is visible + .padding(top = if (showBars) verticalHeaderHeight else 0.dp) .padding(8.dp) ) { Surface( @@ -5519,12 +4186,13 @@ fun PdfViewerScreen( .clickable( indication = null, interactionSource = remember { MutableInteractionSource() - }) {}, // Consume clicks + }) {}, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp), + .padding(horizontal = 32.dp, vertical = 16.dp) + .padding(bottom = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -5672,576 +4340,160 @@ fun PdfViewerScreen( } // Custom Top Bar - 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.align(Alignment.TopCenter) - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (searchState.isSearchActive) { - SearchTopBar( - searchState = searchState, - focusRequester = focusRequester, - onCloseSearch = { - searchState.isSearchActive = false - searchState.onQueryChange("") - keyboardController?.hide() - focusManager.clearFocus() - }) + PdfTopBar( + modifier = Modifier.align(Alignment.TopCenter), + showStandardBars = showStandardBars, + systemUiMode = systemUiMode, + statusBarHeightDp = statusBarHeightDp, + searchState = searchState, + focusRequester = focusRequester, + onCloseSearch = { + searchState.isSearchActive = false + searchState.onQueryChange("") + keyboardController?.hide() + focusManager.clearFocus() + }, + isLoadingDocument = isLoadingDocument, + errorMessage = errorMessage, + currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) { + pagerState.currentPage + } else { + verticalReaderState.currentPage + }, + totalPages = totalPages, + pagerStatePageCount = pagerState.pageCount, + hiddenTools = hiddenTools, + isScrollLocked = isScrollLocked, + isEditMode = isEditMode, + displayMode = displayMode, + isKeepScreenOn = isKeepScreenOn, + isTtsSessionActive = isTtsSessionActive, + isBookmarked = isBookmarked, + canDeletePage = virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage, + isReflowingThisBook = isReflowingThisBook, + hasReflowFile = hasReflowFile, + isPdfDocumentLoaded = pdfDocument != null, + isTabsEnabled = isTabsEnabled, + openTabs = openTabs, + activeTabBookId = activeTabBookId, + effectiveFileType = effectiveFileType, + onNavigateBack = { saveStateAndExit() }, + onShowThemePanel = { showThemePanel = true }, + onToggleScrollLock = { + isScrollLocked = !isScrollLocked + savePdfScrollLocked(context, bookId, isScrollLocked) + if (isScrollLocked) { + savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + } + }, + onShowDictionarySettings = { showDictionarySettingsSheet = true }, + onShowPenPlayground = { showPenPlayground = true }, + onImportSvg = { + val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + + coroutineScope.launch(Dispatchers.IO) { + val svgAnnotations = SvgToAnnotationConverter.importSvgFromAssets( + context = context, + fileName = "demo_art.svg", + pageIndex = page + ) + + withContext(Dispatchers.Main) { + if (svgAnnotations.isNotEmpty()) { + val existing = allAnnotations[page] ?: emptyList() + allAnnotations = allAnnotations + (page to (existing + svgAnnotations)) + + svgAnnotations.forEach { annot -> + undoStack.add(HistoryAction.Add(page, annot)) + } + redoStack.clear() + + snackbarHostState.showSnackbar(context.getString(R.string.msg_imported_svg_strokes)) } else { - TooltipIconButton( - text = stringResource(R.string.tooltip_back), - description = stringResource(R.string.tooltip_back_desc), - onClick = { saveStateAndExit() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - val currentPageForDisplay = - if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage - } else { - verticalReaderState.currentPage - } - val titleText = when { - isLoadingDocument -> stringResource(R.string.loading_pdf) - errorMessage != null -> stringResource(R.string.error_loading_pdf) - totalPages > 0 && pagerState.pageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages" - totalPages > 0 && pagerState.pageCount == 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 = { showThemePanel = true }) { - Icon( - painter = 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 = { - isScrollLocked = !isScrollLocked - savePdfScrollLocked(context, bookId, isScrollLocked) - if (isScrollLocked) { - savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) - lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) - } - }) { - Icon( - imageVector = 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 = { showDictionarySettingsSheet = true }) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = "Dictionary Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - if (BuildConfig.DEBUG) { - TooltipIconButton( - text = stringResource(R.string.pen_playground), - onClick = { showPenPlayground = true }) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "Open Pen Playground", - tint = MaterialTheme.colorScheme.primary - ) - } - - TooltipIconButton(text = stringResource(R.string.import_svg), onClick = { - val page = - if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - - coroutineScope.launch(Dispatchers.IO) { - val svgAnnotations = - SvgToAnnotationConverter.importSvgFromAssets( - context = context, - fileName = "demo_art.svg", - pageIndex = page - ) - - withContext(Dispatchers.Main) { - if (svgAnnotations.isNotEmpty()) { - val existing = - allAnnotations[page] ?: emptyList() - allAnnotations = - allAnnotations + (page to (existing + svgAnnotations)) - - svgAnnotations.forEach { annot -> - undoStack.add( - HistoryAction.Add( - page, - annot - ) - ) - } - redoStack.clear() - - snackbarHostState.showSnackbar(context.getString(R.string.msg_imported_svg_strokes)) - } else { - snackbarHostState.showSnackbar(context.getString(R.string.error_import_svg_failed)) - } - } - } - }) { - Icon( - imageVector = 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( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.tooltip_more_options) - ) - } - - // Main "More" menu - DropdownMenu( - expanded = showMoreMenu, - onDismissRequest = { showMoreMenu = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.title_customize_toolbar)) }, - onClick = { - showMoreMenu = false - showCustomizeToolsSheet = true - }, - 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 - hasSelectedOcrLanguage = true - showOcrLanguageDialog = true - }) - HorizontalDivider() - } - - if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_visual_options)) }, - onClick = { - showMoreMenu = false - showVisualOptionsSheet = true - }, - leadingIcon = { - Icon( - imageVector = 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 = { - displayMode = DisplayMode.VERTICAL_SCROLL - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.VERTICAL_SCROLL) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - } - }) - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, - enabled = !isTtsSessionActive, - onClick = { - displayMode = DisplayMode.PAGINATION - showMoreMenu = false - }, - trailingIcon = { - if (displayMode == DisplayMode.PAGINATION) { - Icon( - imageVector = 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 = { - isKeepScreenOn = !isKeepScreenOn - saveKeepScreenOn(context, isKeepScreenOn) - showMoreMenu = false - }, - trailingIcon = { - if (isKeepScreenOn) { - Icon( - imageVector = 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 - isAutoScrollModeActive = true - isAutoScrollPlaying = true - showBars = !isMusicianMode - }) - - HorizontalDivider() - } - if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, - enabled = !isTtsSessionActive, - onClick = { - showMoreMenu = false - showTtsSettingsSheet = true - }, - leadingIcon = { - Icon( - imageVector = 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 - onBookmarkClick() - }) - HorizontalDivider() - } - if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_insert_blank_page)) }, - onClick = { - showMoreMenu = false - onInsertPage() - }) - - val canDelete = - virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage - if (canDelete) { - 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 = pdfDocument != null && !isReflowingThisBook, - onClick = { - showMoreMenu = false - - coroutineScope.launch { - if (richTextController != null) { - withContext(NonCancellable) { richTextController.saveImmediate() } - } - saveAllData(true).join() - - val resolvedPage = - if (!initialScrollDone && currentPage == 0) { - pendingRestorePage ?: 0 - } else { - currentPage - } - - if (hasReflowFile) { - val item = - uiState.allRecentFiles.find { it.bookId == reflowBookId } - if (item != null) { - viewModel.switchToFileSeamlessly( - item, resolvedPage - ) - } else { - viewModel.generateAndImportReflowFile( - pdfBookId = bookId, - pdfUri = effectivePdfUri, - originalTitle = originalFileName, - autoOpenPage = resolvedPage - ) - } - } else { - viewModel.generateAndImportReflowFile( - pdfBookId = bookId, - pdfUri = effectivePdfUri, - originalTitle = originalFileName, - autoOpenPage = resolvedPage - ) - } - } - }, - leadingIcon = { - Icon( - painter = 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 - showShareDialog = true - }, - leadingIcon = { - Icon( - Icons.Default.Share, - contentDescription = null - ) - } - ) - } - if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_copy_to_device)) }, - onClick = { - showMoreMenu = false - showSaveDialog = true - }, - leadingIcon = { - Icon( - Icons.Default.Save, - contentDescription = null - ) - }) - } - if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_print)) }, - onClick = { - showMoreMenu = false - onPrintDocument() - }, - leadingIcon = { - Icon( - painter = 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 { - Timber.tag("PdfTabSync").i("UI: Tab clicked: ${tab.bookId}") - if (tab.bookId != activeTabBookId) { - coroutineScope.launch { - Timber.tag("PdfTabSync").d("UI: Dispatching switchTab for ${tab.bookId}") - currentBookId?.let { tabStateMap[it] = currentPage } - saveAllData(true).join() - viewModel.switchTab(tab.bookId) - } - } else { - Timber.tag("PdfTabSync").d("UI: Ignored click - Tab already active.") - } - } - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Tab Title - 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 = { - coroutineScope.launch { - if (isSelected) saveAllData(true).join() - viewModel.closeTab(tab.bookId) - if (isSelected && openTabs.size == 1) { - onNavigateBack() - } - } - }, - 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 = { showNewTabSheet = true }, - modifier = Modifier - .padding(start = 8.dp, bottom = 4.dp) - .size(36.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "New Tab", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + snackbarHostState.showSnackbar(context.getString(R.string.error_import_svg_failed)) } } } - } - } + }, + onShowCustomizeTools = { showCustomizeToolsSheet = true }, + onShowOcrLanguage = { + hasSelectedOcrLanguage = true + showOcrLanguageDialog = true + }, + onShowVisualOptions = { showVisualOptionsSheet = true }, + onChangeDisplayMode = { displayMode = it }, + onToggleKeepScreenOn = { + isKeepScreenOn = !isKeepScreenOn + saveKeepScreenOn(context, isKeepScreenOn) + }, + onStartAutoScroll = { + isAutoScrollModeActive = true + isAutoScrollPlaying = true + showBars = !isMusicianMode + }, + onShowTtsSettings = { showTtsSettingsSheet = true }, + onToggleBookmark = onBookmarkClick, + onInsertPage = onInsertPage, + onDeletePage = onDeletePage, + onReflowAction = { + coroutineScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() - AnimatedVisibility( - visible = showStandardBars && isReflowingThisBook, - enter = fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)), - exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(animationSpec = tween(200)), + val resolvedPage = if (!initialScrollDone && currentPage == 0) { + pendingRestorePage ?: 0 + } else { + currentPage + } + + if (hasReflowFile) { + val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.switchToFileSeamlessly(item, resolvedPage) + } else { + viewModel.generateAndImportReflowFile(bookId, effectivePdfUri, originalFileName, resolvedPage) + } + } else { + viewModel.generateAndImportReflowFile(bookId, effectivePdfUri, originalFileName, resolvedPage) + } + } + }, + onShare = { showShareDialog = true }, + onSaveCopy = { showSaveDialog = true }, + onPrint = onPrintDocument, + onTabClick = { tabBookId -> + coroutineScope.launch { + currentBookId?.let { tabStateMap[it] = currentPage } + saveAllData(true).join() + viewModel.switchTab(tabBookId) + } + }, + onTabClose = { tabBookId -> + coroutineScope.launch { + val isSelected = tabBookId == activeTabBookId + if (isSelected) saveAllData(true).join() + viewModel.closeTab(tabBookId) + if (isSelected && openTabs.size == 1) { + onNavigateBack() + } + } + }, + onNewTabClick = { showNewTabSheet = true } + ) + + ReflowProgressOverlay( modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 56.dp) + .padding(top = verticalHeaderHeight) .fillMaxWidth() - .padding(horizontal = 8.dp) - ) { - 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), - ) - } - } - } + .padding(horizontal = 8.dp), + showStandardBars = showStandardBars, + isReflowingThisBook = isReflowingThisBook, + reflowProgressValue = reflowProgressValue + ) // Search Results Panel AnimatedVisibility( @@ -6250,7 +4502,7 @@ fun PdfViewerScreen( Column( modifier = Modifier .fillMaxSize() - .padding(top = 56.dp) + .padding(top = verticalHeaderHeight) .background(MaterialTheme.colorScheme.surface) ) { if (isBackgroundIndexing) { @@ -6315,7 +4567,7 @@ fun PdfViewerScreen( exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 24.dp) + .padding(bottom = 24.dp + if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp) ) { val currentResult = currentPdfSearchResult val searchData = smartSearchResult @@ -6422,208 +4674,70 @@ fun PdfViewerScreen( } // Bottom Bar - AnimatedVisibility( - visible = showStandardBars && !searchState.isSearchActive, - enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)), - exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)), - modifier = Modifier.align(Alignment.BottomCenter) - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp - ) { - val bottomBarScrollState = rememberScrollState() + PdfBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + showStandardBars = showStandardBars, + searchStateActive = searchState.isSearchActive, + systemUiMode = systemUiMode, + navBarHeightDp = with(density) { navBarHeight.toDp() }, + hiddenTools = hiddenTools, + isTtsPlayingOrLoading = ttsState.isPlaying || ttsState.isLoading, + showAllTextHighlights = showAllTextHighlights, + isHighlightingLoading = isHighlightingLoading, + isEditMode = isEditMode, + isTtsSessionActive = isTtsSessionActive, + ttsErrorMessage = ttsState.errorMessage, + onShowSlider = { + val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + sliderStartPage = currentPageForSlider + sliderCurrentPage = currentPageForSlider.toFloat() + isPageSliderVisible = true + showBars = false + }, + onShowToc = { coroutineScope.launch { drawerState.open() } }, + onSearchClick = { + executeWithOcrCheck { + searchState.isSearchActive = true + showBars = true + } + }, + onToggleHighlights = { + if (!showAllTextHighlights && !isHighlightingLoading) { + showAllTextHighlights = true + isHighlightingLoading = true + } else if (showAllTextHighlights) { + showAllTextHighlights = false + isHighlightingLoading = false + } + }, + onShowAiHub = { showAiHubSheet = true }, + onToggleEditMode = { + val newEditMode = !isEditMode + val currentActivePage = richTextController?.activePageIndex ?: -1 + Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp) - .horizontalScroll(bottomBarScrollState), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Slider Navigation Trigger - if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_slider), - description = stringResource(R.string.tooltip_slider_desc), - onClick = { - val currentPage = - if (displayMode == DisplayMode.PAGINATION) { - pagerState.currentPage - } else { - verticalReaderState.currentPage - } - sliderStartPage = currentPage - sliderCurrentPage = currentPage.toFloat() - isPageSliderVisible = true - showBars = false - }, - enabled = !(ttsState.isPlaying || ttsState.isLoading) - ) { - Icon( - painter = painterResource(id = R.drawable.slider), - contentDescription = stringResource(R.string.content_desc_navigate_slider) - ) + if (!newEditMode && richTextController != null) { + coroutineScope.launch { + richTextController.saveImmediate() + withContext(Dispatchers.Main) { + keyboardController?.hide() } } - if (!hiddenTools.contains(PdfReaderTool.TOC.name)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_toc), - description = stringResource(R.string.tooltip_toc_desc), - onClick = { coroutineScope.launch { drawerState.open() } }, - enabled = !(ttsState.isPlaying || ttsState.isLoading), - modifier = Modifier.testTag("TocButton") - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Table of Contents" - ) - } - } - - // Search Button - if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_search), - description = stringResource(R.string.tooltip_search_desc), - onClick = { - executeWithOcrCheck { - searchState.isSearchActive = true - showBars = true - } - }, - enabled = !(ttsState.isPlaying || ttsState.isLoading), - modifier = Modifier.testTag("SearchButton") - ) { - Icon( - imageVector = 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 = { - val newState = !showAllTextHighlights - if (newState) { - if (isHighlightingLoading) return@TooltipIconButton - showAllTextHighlights = true - isHighlightingLoading = true - } else { - showAllTextHighlights = false - isHighlightingLoading = false - } - }) { - if (isHighlightingLoading) { - CircularProgressIndicator(Modifier.size(24.dp)) - } else { - Icon( - painter = painterResource(id = R.drawable.highlight_text), - contentDescription = "Highlight all text", - tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - // AI feat - 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 = { showAiHubSheet = true } - ) { - Icon( - painter = painterResource(id = R.drawable.ai), - contentDescription = stringResource(R.string.tooltip_ai) - ) - } - } - - // Edit Button - 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 = { - val newEditMode = !isEditMode - val currentActivePage = - richTextController?.activePageIndex ?: -1 - - Timber.tag("RichTextMigration") - .i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") - - if (!newEditMode && richTextController != null) { - coroutineScope.launch { - richTextController.saveImmediate() - withContext(Dispatchers.Main) { - keyboardController?.hide() - } - } - } - - isEditMode = newEditMode - if (!newEditMode) showBars = true - }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Toggle Editing Mode", - tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // TTS - 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 = { - if (isTtsSessionActive) { - Timber.d("TTS button clicked: Stopping TTS") - ttsController.stop() - } else { - startTtsWithPermissionCheck(null, null) - } - }) { - Icon( - painter = 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 - ) - } - } - - // Error Message Area - ttsState.errorMessage?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .weight(1f) - .padding(start = 8.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + } + + isEditMode = newEditMode + if (!newEditMode) showBars = true + }, + onToggleTts = { + if (isTtsSessionActive) { + Timber.d("TTS button clicked: Stopping TTS") + ttsController.stop() + isAutoPagingForTts = false + } else { + startTtsWithPermissionCheck(null, null) } } - } + ) if (isEditMode) { val density = LocalDensity.current @@ -6752,9 +4866,13 @@ fun PdfViewerScreen( ) } + val effectiveNavBarForDock = if (systemUiMode == SystemUiMode.DEFAULT) with(density) { navBarHeight.toDp() } else 0.dp val paddingModifier = if ((dockLocation == DockLocation.TOP || dockLocation == DockLocation.BOTTOM) && !isDockDragging) { - Modifier + Modifier.padding( + bottom = if (dockLocation == DockLocation.BOTTOM) effectiveNavBarForDock else 0.dp, + top = if (dockLocation == DockLocation.TOP && systemUiMode == SystemUiMode.DEFAULT) statusBarHeightDp else 0.dp + ) } else { Modifier.padding(vertical = 16.dp) } @@ -7043,6 +5161,9 @@ fun PdfViewerScreen( } val currentDensity = LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(currentDensity) > 0 + + val extraPadding = if (isImeVisible) 0.dp else bottomPadding val effectiveStyle by remember(selectedTextBoxId, textBoxes, richTextController.currentStyle, displayPageRatios, boxMaxWidthFloat) { derivedStateOf { @@ -7077,7 +5198,10 @@ fun PdfViewerScreen( Box(modifier = Modifier .align(Alignment.BottomCenter) - .fillMaxWidth()) { + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)) + .padding(bottom = extraPadding) + ) { TextAnnotationDock( currentStyle = effectiveStyle, textColorPalette = penPalette, @@ -7089,6 +5213,19 @@ fun PdfViewerScreen( annotationSettingsRepo.updateHighlighterPalette(newPalette) }, onUpdateStyle = { newStyle -> + val newConfig = TextStyleConfig( + colorArgb = newStyle.color.toArgb(), + backgroundColorArgb = newStyle.background.toArgb(), + fontSize = newStyle.fontSize.value, + isBold = newStyle.fontWeight == FontWeight.Bold, + isItalic = newStyle.fontStyle == FontStyle.Italic, + isUnderline = newStyle.textDecoration?.contains(TextDecoration.Underline) == true, + isStrikeThrough = newStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, + fontPath = toolSettings.textStyle.fontPath, + fontName = toolSettings.textStyle.fontName + ) + annotationSettingsRepo.updateTextStyle(newConfig) + if (selectedTextBoxId != null) { val idx = textBoxes.indexOfFirst { it.id == selectedTextBoxId } if (idx != -1) { @@ -7111,19 +5248,6 @@ fun PdfViewerScreen( } } else { richTextController.updateCurrentStyle(newStyle) - - val newConfig = TextStyleConfig( - colorArgb = newStyle.color.toArgb(), - backgroundColorArgb = newStyle.background.toArgb(), - fontSize = newStyle.fontSize.value, - isBold = newStyle.fontWeight == FontWeight.Bold, - isItalic = newStyle.fontStyle == FontStyle.Italic, - isUnderline = newStyle.textDecoration?.contains(TextDecoration.Underline) == true, - isStrikeThrough = newStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, - fontPath = toolSettings.textStyle.fontPath, - fontName = toolSettings.textStyle.fontName - ) - annotationSettingsRepo.updateTextStyle(newConfig) } }, onApplyToSelection = {}, @@ -7137,24 +5261,22 @@ fun PdfViewerScreen( selectedTextBoxId = null richTextController.clearSelection() }, - bottomDockPadding = bottomPadding, + bottomDockPadding = 0.dp, customFonts = customFonts, onImportFont = viewModel::importFont, onFontSelected = { name, path -> Timber.tag("PdfFontDebug").i("UI Action: Font Selected -> Name: $name, Path: $path") + val currentConfig = toolSettings.textStyle + val newConfig = currentConfig.copy(fontPath = path, fontName = name) + annotationSettingsRepo.updateTextStyle(newConfig) + if (selectedTextBoxId != null) { val idx = textBoxes.indexOfFirst { it.id == selectedTextBoxId } if (idx != -1) { val oldBox = textBoxes[idx] textBoxes[idx] = oldBox.copy(fontPath = path, fontName = name) - Timber.tag("PdfTextBoxDebug").d("Updated TextBox ${oldBox.id} font to: $name ($path)") } - Timber.tag("PdfFontDebug").d("UI: Updating TextBox $selectedTextBoxId with font path: $path") } else { - val currentConfig = toolSettings.textStyle - val newConfig = currentConfig.copy(fontPath = path, fontName = name) - annotationSettingsRepo.updateTextStyle(newConfig) - richTextController.let { controller -> val style = SpanStyle( color = Color(newConfig.colorArgb), @@ -7170,7 +5292,6 @@ fun PdfViewerScreen( }, fontFamily = PdfFontCache.getFontFamily(path) ) - Timber.tag("PdfFontDebug").d("UI Action: Manually updating controller with font $path") controller.updateCurrentStyle(style, path, name) } } @@ -7327,6 +5448,51 @@ fun PdfViewerScreen( ) } + // --- PANEL POPUP --- + if (poppedUpPanelBitmap != null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.85f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + poppedUpPanelBitmap?.recycle() + poppedUpPanelBitmap = null + }, + contentAlignment = Alignment.Center + ) { + Image( + bitmap = poppedUpPanelBitmap!!.asImageBitmap(), + contentDescription = "Zoomed Panel", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Fit + ) + + IconButton( + onClick = { + poppedUpPanelBitmap?.recycle() + poppedUpPanelBitmap = null + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(24.dp) + .background(Color.Black.copy(alpha = 0.6f), CircleShape) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close Panel", + tint = Color.White + ) + } + } + } + // --- END PANEL POPUP --- + if (showPasswordDialog) { PasswordDialog( isError = isPasswordError, @@ -7659,6 +5825,12 @@ fun PdfViewerScreen( ReaderThemePanel( isVisible = true, currentThemeId = currentThemeId, + excludeImages = excludeImages, + onExcludeImagesChange = { + excludeImages = it + com.aryan.reader.saveExcludeImages(context, it) + }, + showExcludeImagesOption = true, builtInThemes = PdfBuiltInThemes, onThemeSelected = { currentThemeId = it @@ -7834,13 +6006,14 @@ fun PdfViewerScreen( } } + val effectiveNavBarPaddingForOverlays = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp val autoScrollPadding by animateDpAsState( - targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, + targetValue = if (showStandardBars) (56.dp + 16.dp + effectiveNavBarPaddingForOverlays) else (16.dp + effectiveNavBarPaddingForOverlays), label = "AutoScrollPadding" ) val ttsOverlayPadding by animateDpAsState( - targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, + targetValue = if (showStandardBars) (56.dp + 16.dp + effectiveNavBarPaddingForOverlays) else (16.dp + effectiveNavBarPaddingForOverlays), label = "TtsOverlayPadding" ) @@ -7867,6 +6040,7 @@ fun PdfViewerScreen( onOpenTtsSettings = { showTtsSettingsSheet = true }, onClose = { ttsController.stop() + isAutoPagingForTts = false }, credits = uiState.credits ) @@ -7976,7 +6150,7 @@ fun PdfViewerScreen( systemUiMode = systemUiMode, onSystemUiModeChange = { mode -> systemUiMode = mode - saveSystemUiMode(context, mode) + savePdfSystemUiMode(context, mode) }, onDismiss = { showVisualOptionsSheet = false } ) @@ -7991,761 +6165,4 @@ fun PdfViewerScreen( } } } -} - -@Composable -private 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 -private 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)) - } -} - -private fun debugPdfLinks( - context: Context, pdfUri: Uri, pdfiumCore: PdfiumCoreKt, coroutineScope: CoroutineScope -) { - Timber.d("--- Starting PDF Link Analysis ---") - Timber.d("URI: $pdfUri") - - coroutineScope.launch(Dispatchers.IO) { - var pfd: ParcelFileDescriptor? - var doc: PdfDocumentKt? = null - var page: PdfPageKt? = null - try { - pfd = context.contentResolver.openFileDescriptor(pdfUri, "r") - if (pfd == null) { - Timber.e("Failed to open ParcelFileDescriptor.") - return@launch - } - doc = pdfiumCore.newDocument(pfd) - val pageCount = doc.getPageCount() - Timber.d("Document loaded. Page count: $pageCount") - - if (pageCount > 0) { - val pageIndex = 0 // Testing the first page - page = doc.openPage(pageIndex) - if (page == null) return@launch - Timber.d("Opened page $pageIndex") - - Timber.d( - "Performing a dummy 1x1 render with renderAnnot=true to force annotation parsing..." - ) - val dummyBitmap = createBitmap(1, 1) - page.renderPageBitmap( - bitmap = dummyBitmap, - startX = 0, - startY = 0, - drawSizeX = 1, - drawSizeY = 1, - renderAnnot = true // This is the crucial flag - ) - dummyBitmap.recycle() // Clean up immediately - Timber.d("Dummy render complete. Now checking for links again.") - - // Method 1: The one that is failing (now should work) - val annotationLinks = page.getPageLinks() - Timber.d("[METHOD 1] getPageLinks() found ${annotationLinks.size} links.") - if (annotationLinks.isNotEmpty()) { - annotationLinks.forEachIndexed { index, link -> - Timber.d(" - Link ${index}: URI='${link.uri}', Bounds='${link.bounds}'") - } - } - - // Method 2: The one that is working - page.openTextPage().use { textPage -> - textPage.loadWebLink()?.use { webLinks -> - val webLinkCount = webLinks.countWebLinks() - Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.") - if (webLinkCount > 0) { - for (i in 0 until webLinkCount) { - val url = webLinks.getURL(i, 2048) - Timber.d( - " - WebLink ${i}: URL='${url?.substringBefore('\u0000')}'" - ) - } - } - } - } - } - } catch (e: Exception) { - Timber.e(e, "An error occurred during link debugging.") - } finally { - try { - page?.close() - } catch (_: Exception) { - } - try { - doc?.close() - } catch (_: Exception) { - } - Timber.d("--- PDF Link Analysis Finished ---") - } - } -} - -@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 -private 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) - ) - } -} - -@Composable -private 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 -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)) - } - } -} - -@Composable -private 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)) } }) -} - -@Composable -private 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, - 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, - 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() - } - } - } - } - } -} - -@Composable -fun PdfCustomizeToolsSheet( - hiddenTools: Set, - onUpdate: (Set) -> 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) - } - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@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 } - ) - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt b/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt index ebd0851..bee38ce 100644 --- a/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt +++ b/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt @@ -35,14 +35,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -118,7 +115,6 @@ import com.aryan.reader.R import com.aryan.reader.data.CustomFontEntity import timber.log.Timber import java.io.File -import kotlin.math.max import kotlin.math.roundToInt private enum class ColorMenuMode { @@ -176,18 +172,6 @@ fun TextAnnotationDock( val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp) val focusManager = LocalFocusManager.current - val density = LocalDensity.current - val imeBottom = WindowInsets.ime.getBottom(density) - val navBottom = WindowInsets.navigationBars.getBottom(density) - val spacerHeightPx = max(0, imeBottom - navBottom) - val spacerHeightDp = with(density) { spacerHeightPx.toDp() } - - val effectiveSpacerHeight = if (spacerHeightDp > 0.dp) { - spacerHeightDp - } else { - bottomDockPadding - } - LaunchedEffect(activePopup) { if (activePopup == ActivePopup.NONE) { activeMenuMode = ColorMenuMode.PALETTE @@ -200,7 +184,7 @@ fun TextAnnotationDock( val dockBarHeight = 48.dp val margin = 8.dp - val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin) + val finalOffsetY = -(bottomDockPadding + dockBarHeight + margin) val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY @@ -724,7 +708,7 @@ fun TextAnnotationDock( } } } - Spacer(modifier = Modifier.height(effectiveSpacerHeight)) + Spacer(modifier = Modifier.height(bottomDockPadding)) } } } diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt index 771bed4..a283137 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import com.materialkolor.PaletteStyle import androidx.compose.ui.platform.LocalContext @@ -118,36 +119,58 @@ fun AppTheme( content: @Composable () -> Unit ) { val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current - val colorScheme = when { - seedColor != null -> dynamicColorScheme( - seedColor = seedColor, - isDark = darkTheme, - contrastLevel = contrastLevel, - style = PaletteStyle.Fidelity - ) - - dynamicColor && supportsDynamicColor -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val baseColorScheme = remember( + darkTheme, dynamicColor, supportsDynamicColor, seedColor, contrastLevel + ) { + when { + seedColor != null -> dynamicColorScheme( + seedColor = seedColor, + isDark = darkTheme, + contrastLevel = contrastLevel, + style = PaletteStyle.Fidelity + ) + dynamicColor && supportsDynamicColor -> { + if (darkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + darkTheme -> darkScheme + else -> lightScheme } - darkTheme -> darkScheme - else -> lightScheme } - val finalColorScheme = colorScheme.copy( - onPrimary = colorScheme.onPrimary.copy(alpha = textDimFactor), - onSecondary = colorScheme.onSecondary.copy(alpha = textDimFactor), - onTertiary = colorScheme.onTertiary.copy(alpha = textDimFactor), - onBackground = colorScheme.onBackground.copy(alpha = textDimFactor), - onSurface = colorScheme.onSurface.copy(alpha = textDimFactor), - onSurfaceVariant = colorScheme.onSurfaceVariant.copy(alpha = textDimFactor), - onError = colorScheme.onError.copy(alpha = textDimFactor), - onPrimaryContainer = colorScheme.onPrimaryContainer.copy(alpha = textDimFactor), - onSecondaryContainer = colorScheme.onSecondaryContainer.copy(alpha = textDimFactor), - onTertiaryContainer = colorScheme.onTertiaryContainer.copy(alpha = textDimFactor), - onErrorContainer = colorScheme.onErrorContainer.copy(alpha = textDimFactor) - ) + val finalColorScheme = remember(baseColorScheme, textDimFactor) { + if (textDimFactor >= 1.0f) { + baseColorScheme + } else { + baseColorScheme.copy( + primary = baseColorScheme.primary.copy(alpha = textDimFactor), + secondary = baseColorScheme.secondary.copy(alpha = textDimFactor), + tertiary = baseColorScheme.tertiary.copy(alpha = textDimFactor), + error = baseColorScheme.error.copy(alpha = textDimFactor), + primaryContainer = baseColorScheme.primaryContainer.copy(alpha = textDimFactor), + secondaryContainer = baseColorScheme.secondaryContainer.copy(alpha = textDimFactor), + tertiaryContainer = baseColorScheme.tertiaryContainer.copy(alpha = textDimFactor), + errorContainer = baseColorScheme.errorContainer.copy(alpha = textDimFactor), + outline = baseColorScheme.outline.copy(alpha = textDimFactor), + outlineVariant = baseColorScheme.outlineVariant.copy(alpha = textDimFactor), + inversePrimary = baseColorScheme.inversePrimary.copy(alpha = textDimFactor), + inverseOnSurface = baseColorScheme.inverseOnSurface.copy(alpha = textDimFactor), + onPrimary = baseColorScheme.onPrimary.copy(alpha = textDimFactor), + onSecondary = baseColorScheme.onSecondary.copy(alpha = textDimFactor), + onTertiary = baseColorScheme.onTertiary.copy(alpha = textDimFactor), + onBackground = baseColorScheme.onBackground.copy(alpha = textDimFactor), + onSurface = baseColorScheme.onSurface.copy(alpha = textDimFactor), + onSurfaceVariant = baseColorScheme.onSurfaceVariant.copy(alpha = textDimFactor), + onError = baseColorScheme.onError.copy(alpha = textDimFactor), + onPrimaryContainer = baseColorScheme.onPrimaryContainer.copy(alpha = textDimFactor), + onSecondaryContainer = baseColorScheme.onSecondaryContainer.copy(alpha = textDimFactor), + onTertiaryContainer = baseColorScheme.onTertiaryContainer.copy(alpha = textDimFactor), + onErrorContainer = baseColorScheme.onErrorContainer.copy(alpha = textDimFactor), + ) + } + } MaterialTheme( colorScheme = finalColorScheme, diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 446efe3..1cbd8cf 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,11 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..f56fafc --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file