v1.0.45-oss (#221)
* Implemented ML-based comic panel detection and a panel popup viewer. * Optimized `ComicPanelDetector` for performance and memory efficiency. * Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization. * Refactored system UI handling and layout padding in `PdfViewerScreen`. * Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files. * replace hardcoded padding with dynamic header height and adjust IME layout logic * Improve `PdfTextBox` interaction and visual consistency during zoom and pan. * improve PDF text box dragging and scaling behavior across zoom levels * optimize color scheme calculation using remember and expand text dimming coverage * Implement in-app language selection and per-app language preferences. * Improve PDF lock stability in `PdfVerticalReader` * Implement "Preserve Image Colors" option for PDF themes * Optimize TOC locate in ReaderDrawer * Update library and home screen UI components * smoother page turn animation in epub pagination mode * Implement automatic page skipping for TTS when no text is found in PDF viewer * Refine status bar handling and window insets across main screens * Optimize ViewModel initialization and integrate AndroidX SplashScreen * Move ComicPanelDetector to debug source set and introduce IPanelDetector interface * Optimize TOC scrolling in PdfNavigationDrawerContent * Bump version to 1.0.45(45)
This commit is contained in:
parent
71e3614ad6
commit
cb7de86224
33 changed files with 4795 additions and 3447 deletions
|
|
@ -30,8 +30,10 @@ android {
|
||||||
applicationId = "com.aryan.reader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 44
|
versionCode = 45
|
||||||
versionName = "1.0.43"
|
versionName = "1.0.45"
|
||||||
|
|
||||||
|
resourceConfigurations += setOf("en", "ar", "de", "tr")
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
@ -227,6 +229,13 @@ dependencies {
|
||||||
implementation("org.zwobble.mammoth:mammoth:1.4.2")
|
implementation("org.zwobble.mammoth:mammoth:1.4.2")
|
||||||
|
|
||||||
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
||||||
|
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
|
||||||
|
|
||||||
|
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
spotless {
|
spotless {
|
||||||
|
|
|
||||||
202
app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt
Normal file
202
app/src/debug/java/com/aryan/reader/ml/ComicPanelDetector.kt
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
package com.aryan.reader.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
import org.tensorflow.lite.DataType
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import org.tensorflow.lite.gpu.CompatibilityList
|
||||||
|
import org.tensorflow.lite.gpu.GpuDelegate
|
||||||
|
import org.tensorflow.lite.support.common.ops.NormalizeOp
|
||||||
|
import org.tensorflow.lite.support.image.ImageProcessor
|
||||||
|
import org.tensorflow.lite.support.image.TensorImage
|
||||||
|
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
data class PanelResult(
|
||||||
|
val rect: RectF,
|
||||||
|
val confidence: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
class ComicPanelDetector(modelFile: File) : IPanelDetector {
|
||||||
|
|
||||||
|
private var interpreter: Interpreter? = null
|
||||||
|
private val inputSize = 640
|
||||||
|
private var gpuDelegate: GpuDelegate? = null
|
||||||
|
|
||||||
|
private var isTransposed: Boolean = false
|
||||||
|
private var numBoxes: Int = 0
|
||||||
|
private var numElementsPerBox: Int = 0
|
||||||
|
private var outputBuffer: ByteBuffer? = null
|
||||||
|
private var floatOutputBuffer: java.nio.FloatBuffer? = null
|
||||||
|
private var flatOutput: FloatArray? = null
|
||||||
|
|
||||||
|
private val preAllocatedTensorImage = TensorImage(DataType.FLOAT32)
|
||||||
|
|
||||||
|
private val imageProcessor = ImageProcessor.Builder()
|
||||||
|
.add(ResizeOp(inputSize, inputSize, ResizeOp.ResizeMethod.BILINEAR))
|
||||||
|
.add(NormalizeOp(0f, 255f))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
val compatList = CompatibilityList()
|
||||||
|
val options = Interpreter.Options().apply {
|
||||||
|
numThreads = 4
|
||||||
|
|
||||||
|
if (compatList.isDelegateSupportedOnThisDevice) {
|
||||||
|
val delegateOptions = compatList.bestOptionsForThisDevice.apply {
|
||||||
|
isPrecisionLossAllowed = true
|
||||||
|
|
||||||
|
val cacheDir = File(modelFile.parentFile, "gpu_cache")
|
||||||
|
if (!cacheDir.exists()) cacheDir.mkdirs()
|
||||||
|
setSerializationParams(cacheDir.absolutePath, "${modelFile.name}_${modelFile.length()}")
|
||||||
|
}
|
||||||
|
gpuDelegate = GpuDelegate(delegateOptions)
|
||||||
|
addDelegate(gpuDelegate)
|
||||||
|
Timber.i("GPU Delegate added successfully with serialization caching.")
|
||||||
|
} else {
|
||||||
|
Timber.i("GPU not supported on this device. Falling back to 4 CPU threads.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interpreter = Interpreter(modelFile, options)
|
||||||
|
|
||||||
|
val outputTensor = interpreter!!.getOutputTensor(0)
|
||||||
|
val shape = outputTensor.shape()
|
||||||
|
Timber.d("Model Output Tensor Shape: ${shape.contentToString()}")
|
||||||
|
|
||||||
|
isTransposed = shape.size == 3 && shape[1] > shape[2]
|
||||||
|
numBoxes = if (isTransposed) shape[1] else shape[2]
|
||||||
|
numElementsPerBox = if (isTransposed) shape[2] else shape[1]
|
||||||
|
|
||||||
|
val outputBytes = numBoxes * numElementsPerBox * 4
|
||||||
|
outputBuffer = ByteBuffer.allocateDirect(outputBytes).order(ByteOrder.nativeOrder())
|
||||||
|
floatOutputBuffer = outputBuffer!!.asFloatBuffer()
|
||||||
|
flatOutput = FloatArray(numBoxes * numElementsPerBox)
|
||||||
|
|
||||||
|
Timber.i("TFLite Model loaded and buffers allocated successfully from ${modelFile.absolutePath}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error loading TFLite model or allocating buffers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detectPanels(bitmap: Bitmap, confidenceThreshold: Float, iouThreshold: Float): List<RectF> {
|
||||||
|
val tflite = interpreter ?: return emptyList()
|
||||||
|
val buffer = outputBuffer ?: return emptyList()
|
||||||
|
val floatBuf = floatOutputBuffer ?: return emptyList()
|
||||||
|
val flatOut = flatOutput ?: return emptyList()
|
||||||
|
|
||||||
|
if (numBoxes <= 0) {
|
||||||
|
Timber.w("Detector not initialized correctly: numBoxes is 0")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
preAllocatedTensorImage.load(bitmap)
|
||||||
|
val processedImage = imageProcessor.process(preAllocatedTensorImage)
|
||||||
|
|
||||||
|
buffer.rewind()
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
tflite.run(processedImage.buffer, buffer)
|
||||||
|
Timber.d("Inference took ${System.currentTimeMillis() - startTime}ms")
|
||||||
|
|
||||||
|
floatBuf.rewind()
|
||||||
|
floatBuf.get(flatOut)
|
||||||
|
|
||||||
|
var maxCoord = 0f
|
||||||
|
for (i in 0 until min(100, numBoxes)) {
|
||||||
|
val cx = if (isTransposed) flatOutput!![i * numElementsPerBox + 0] else flatOutput!![0 * numBoxes + i]
|
||||||
|
if (cx > maxCoord) maxCoord = cx
|
||||||
|
}
|
||||||
|
val isNormalized = maxCoord <= 1.5f
|
||||||
|
Timber.d("Are coordinates normalized? $isNormalized (Sample Max: $maxCoord)")
|
||||||
|
|
||||||
|
val scaleX = if (isNormalized) bitmap.width.toFloat() else bitmap.width.toFloat() / inputSize
|
||||||
|
val scaleY = if (isNormalized) bitmap.height.toFloat() else bitmap.height.toFloat() / inputSize
|
||||||
|
|
||||||
|
val parsedResults = mutableListOf<PanelResult>()
|
||||||
|
|
||||||
|
for (i in 0 until numBoxes) {
|
||||||
|
val confidence = if (isTransposed) flatOutput!![i * numElementsPerBox + 4] else flatOutput!![4 * numBoxes + i]
|
||||||
|
|
||||||
|
if (confidence > confidenceThreshold) {
|
||||||
|
val cx = if (isTransposed) flatOutput!![i * numElementsPerBox + 0] else flatOutput!![0 * numBoxes + i]
|
||||||
|
val cy = if (isTransposed) flatOutput!![i * numElementsPerBox + 1] else flatOutput!![1 * numBoxes + i]
|
||||||
|
val w = if (isTransposed) flatOutput!![i * numElementsPerBox + 2] else flatOutput!![2 * numBoxes + i]
|
||||||
|
val h = if (isTransposed) flatOutput!![i * numElementsPerBox + 3] else flatOutput!![3 * numBoxes + i]
|
||||||
|
|
||||||
|
val scaledCx = cx * scaleX
|
||||||
|
val scaledCy = cy * scaleY
|
||||||
|
val scaledW = w * scaleX
|
||||||
|
val scaledH = h * scaleY
|
||||||
|
|
||||||
|
val left = scaledCx - scaledW / 2
|
||||||
|
val top = scaledCy - scaledH / 2
|
||||||
|
val right = scaledCx + scaledW / 2
|
||||||
|
val bottom = scaledCy + scaledH / 2
|
||||||
|
|
||||||
|
parsedResults.add(
|
||||||
|
PanelResult(
|
||||||
|
rect = RectF(left, top, right, bottom),
|
||||||
|
confidence = confidence
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalPanels = applyNMS(parsedResults, iouThreshold)
|
||||||
|
|
||||||
|
return finalPanels.map { it.rect }.sortedWith { r1, r2 ->
|
||||||
|
if (abs(r1.top - r2.top) < (bitmap.height * 0.05f)) {
|
||||||
|
r2.right.compareTo(r1.right)
|
||||||
|
} else {
|
||||||
|
r1.top.compareTo(r2.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNMS(boxes: List<PanelResult>, iouThreshold: Float): List<PanelResult> {
|
||||||
|
val sortedBoxes = boxes.sortedByDescending { it.confidence }.toMutableList()
|
||||||
|
val selected = mutableListOf<PanelResult>()
|
||||||
|
|
||||||
|
while (sortedBoxes.isNotEmpty()) {
|
||||||
|
val current = sortedBoxes.removeAt(0)
|
||||||
|
selected.add(current)
|
||||||
|
sortedBoxes.removeAll { box ->
|
||||||
|
calculateIoU(current.rect, box.rect) > iouThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateIoU(box1: RectF, box2: RectF): Float {
|
||||||
|
val intersectionLeft = max(box1.left, box2.left)
|
||||||
|
val intersectionTop = max(box1.top, box2.top)
|
||||||
|
val intersectionRight = min(box1.right, box2.right)
|
||||||
|
val intersectionBottom = min(box1.bottom, box2.bottom)
|
||||||
|
|
||||||
|
if (intersectionRight < intersectionLeft || intersectionBottom < intersectionTop) return 0f
|
||||||
|
|
||||||
|
val intersectionArea = (intersectionRight - intersectionLeft) * (intersectionBottom - intersectionTop)
|
||||||
|
val box1Area = (box1.right - box1.left) * (box1.bottom - box1.top)
|
||||||
|
val box2Area = (box2.right - box2.left) * (box2.bottom - box2.top)
|
||||||
|
|
||||||
|
return intersectionArea / (box1Area + box2Area - intersectionArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
interpreter?.close()
|
||||||
|
interpreter = null
|
||||||
|
|
||||||
|
gpuDelegate?.close()
|
||||||
|
gpuDelegate = null
|
||||||
|
|
||||||
|
outputBuffer = null
|
||||||
|
floatOutputBuffer = null
|
||||||
|
flatOutput = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MyApplication"
|
android:name=".MyApplication"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|
@ -41,7 +42,7 @@
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Reader"
|
android:theme="@style/Theme.App.Starting"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -1814,6 +1814,18 @@ fun loadReaderThemeId(context: Context): String {
|
||||||
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
|
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const val PREF_EXCLUDE_IMAGES = "exclude_images"
|
||||||
|
|
||||||
|
fun saveExcludeImages(context: Context, excludeImages: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PREF_EXCLUDE_IMAGES, excludeImages) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadExcludeImages(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PREF_EXCLUDE_IMAGES, false)
|
||||||
|
}
|
||||||
|
|
||||||
fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
|
fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
val jsonArray = JSONArray()
|
val jsonArray = JSONArray()
|
||||||
|
|
@ -1868,6 +1880,9 @@ private fun calculateContrastRatio(color1: Color, color2: Color): Float {
|
||||||
fun ReaderThemePanel(
|
fun ReaderThemePanel(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
currentThemeId: String,
|
currentThemeId: String,
|
||||||
|
excludeImages: Boolean = false,
|
||||||
|
onExcludeImagesChange: (Boolean) -> Unit = {},
|
||||||
|
showExcludeImagesOption: Boolean = false,
|
||||||
customThemes: List<ReaderTheme>,
|
customThemes: List<ReaderTheme>,
|
||||||
builtInThemes: List<ReaderTheme> = BuiltInThemes,
|
builtInThemes: List<ReaderTheme> = BuiltInThemes,
|
||||||
onThemeSelected: (String) -> Unit,
|
onThemeSelected: (String) -> Unit,
|
||||||
|
|
@ -1920,6 +1935,23 @@ fun ReaderThemePanel(
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showExcludeImagesOption) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Preserve Image Colors", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("Keep original image colors when theme changes", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
|
||||||
|
}
|
||||||
|
androidx.compose.material3.Switch(
|
||||||
|
checked = excludeImages,
|
||||||
|
onCheckedChange = onExcludeImagesChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
|
ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@
|
||||||
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
@ -41,6 +42,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -57,20 +59,18 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.FormatListNumbered
|
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
|
||||||
import androidx.compose.material.icons.filled.VerifiedUser
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
import androidx.compose.material.icons.outlined.AccountCircle
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -132,6 +132,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
@ -183,6 +184,7 @@ fun HomeScreen(
|
||||||
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val feedbackResult =
|
val feedbackResult =
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message")
|
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message")
|
||||||
|
|
@ -335,7 +337,9 @@ fun HomeScreen(
|
||||||
showStrictFilterDialog = true
|
showStrictFilterDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAppThemeClick = { showAppThemePanel = true }
|
onAppThemeClick = { showAppThemePanel = true },
|
||||||
|
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||||
|
onLanguageClick = { showLanguageDialog = true }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
|
|
@ -509,6 +513,10 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLanguageDialog) {
|
||||||
|
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
|
||||||
|
}
|
||||||
|
|
||||||
if (showAppThemePanel) {
|
if (showAppThemePanel) {
|
||||||
AppThemeBottomSheet(
|
AppThemeBottomSheet(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
|
@ -749,6 +757,8 @@ fun RecentFileCard(
|
||||||
isDownloading: Boolean,
|
isDownloading: Boolean,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
|
||||||
|
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
|
||||||
val placeholder = when (item.type) {
|
val placeholder = when (item.type) {
|
||||||
FileType.PDF -> R.drawable.pdf_placeholder
|
FileType.PDF -> R.drawable.pdf_placeholder
|
||||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
||||||
|
|
@ -757,88 +767,70 @@ fun RecentFileCard(
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = modifier.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f },
|
modifier = modifier
|
||||||
shape = MaterialTheme.shapes.medium,
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
tonalElevation = if (isSelected) 8.dp else 2.dp,
|
.then(
|
||||||
shadowElevation = 4.dp,
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
else Modifier
|
||||||
) {
|
)
|
||||||
Column(
|
.clip(MaterialTheme.shapes.large)
|
||||||
modifier = Modifier.combinedClickable(
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
onClick = onClick, onLongClick = onLongClick
|
shape = MaterialTheme.shapes.large,
|
||||||
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
),
|
||||||
|
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
|
||||||
|
defaultElevation = if (isSelected) 6.dp else 2.dp
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Box {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(0.74f)
|
||||||
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
|
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
|
||||||
.fallback(placeholder).crossfade(true).build(),
|
.fallback(placeholder).crossfade(true).build(),
|
||||||
contentDescription = item.displayName,
|
contentDescription = item.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.height(160.dp).fillMaxWidth(),
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (item.sourceFolderUri != null) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
|
modifier = Modifier.fillMaxSize().background(
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
shape = CircleShape
|
0f to Color.Black.copy(alpha = 0.15f),
|
||||||
).padding(4.dp)
|
0.3f to Color.Transparent,
|
||||||
) {
|
0.6f to Color.Transparent,
|
||||||
Icon(
|
1f to Color.Black.copy(alpha = 0.5f)
|
||||||
imageVector = Icons.Default.Folder,
|
)
|
||||||
contentDescription = stringResource(R.string.local_folder),
|
)
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
|
||||||
if (isOpdsStream) {
|
FileStatusBadges(
|
||||||
Box(
|
item = item,
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
|
isPinned = isPinned,
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
overlay = true,
|
||||||
shape = CircleShape
|
modifier = Modifier
|
||||||
).padding(4.dp)
|
.align(Alignment.TopStart)
|
||||||
) {
|
.padding(10.dp)
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Cloud,
|
|
||||||
contentDescription = stringResource(R.string.opds_stream),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isPinned) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp).background(
|
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
shape = CircleShape
|
|
||||||
).padding(4.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PushPin,
|
|
||||||
contentDescription = stringResource(R.string.pinned),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.isAvailable) {
|
if (!item.isAvailable) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.matchParentSize()
|
||||||
.matchParentSize()
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f)),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
CircularProgressIndicator(color = Color.White)
|
CircularProgressIndicator(color = Color.White)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Info,
|
Icons.Filled.Info,
|
||||||
contentDescription = stringResource(R.string.not_available_locally),
|
contentDescription = stringResource(R.string.not_available_locally),
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
|
|
@ -846,39 +838,131 @@ fun RecentFileCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)
|
modifier = Modifier.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(8.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) {
|
||||||
FileTypeBadge(type = item.type, overlay = true)
|
FileTypeBadge(type = item.type, overlay = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progressPercent?.let { percent ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(8.dp),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
Color.White.copy(alpha = 0.14f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$percent%",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(80.dp)
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.Start
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName,
|
text = item.cardTitle(),
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
minLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.weight(1f)
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
modifier = Modifier.height(20.dp), contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
item.progressPercentage?.let { progress ->
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.progress_complete, progress.toInt()),
|
text = authorText,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
minLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!item.isAvailable) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(28.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = if (isDownloading) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
},
|
||||||
|
contentColor = if (isDownloading) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (isDownloading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Info,
|
||||||
|
contentDescription = stringResource(R.string.not_available_locally),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (isDownloading) {
|
||||||
|
stringResource(R.string.status_downloading)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.not_available_locally)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -902,7 +986,9 @@ fun DefaultTopAppBar(
|
||||||
onTabsToggle: (Boolean) -> Unit,
|
onTabsToggle: (Boolean) -> Unit,
|
||||||
onExternalFileBehaviorClick: () -> Unit,
|
onExternalFileBehaviorClick: () -> Unit,
|
||||||
onStrictFilterToggleClick: () -> Unit,
|
onStrictFilterToggleClick: () -> Unit,
|
||||||
onAppThemeClick: () -> Unit
|
onAppThemeClick: () -> Unit,
|
||||||
|
onTestPanelDetectionClick: () -> Unit,
|
||||||
|
onLanguageClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
var showLimitMenu by remember { mutableStateOf(false) }
|
var showLimitMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -985,6 +1071,13 @@ fun DefaultTopAppBar(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(text = { Text("Language") }, onClick = {
|
||||||
|
onLanguageClick()
|
||||||
|
showOptionsMenu = false
|
||||||
|
})
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||||
onClearCache()
|
onClearCache()
|
||||||
|
|
@ -995,6 +1088,14 @@ fun DefaultTopAppBar(
|
||||||
showOptionsMenu = false
|
showOptionsMenu = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(text = { Text("Test Panel ML Detection") }, onClick = {
|
||||||
|
onTestPanelDetectionClick()
|
||||||
|
showOptionsMenu = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
|
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
|
||||||
|
|
@ -1760,6 +1861,7 @@ fun ThemeSwatch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnrememberedMutableState")
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateAppThemeDialog(
|
fun CreateAppThemeDialog(
|
||||||
initialColor: Color = Color(0xFF6750A4),
|
initialColor: Color = Color(0xFF6750A4),
|
||||||
|
|
@ -1916,3 +2018,46 @@ fun CreateAppThemeDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LanguageSelectionDialog(onDismiss: () -> Unit) {
|
||||||
|
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
||||||
|
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.language ?: "en" else "en"
|
||||||
|
|
||||||
|
val languages = listOf(
|
||||||
|
"en" to "English (Default)",
|
||||||
|
"ar" to "العربية (Arabic)",
|
||||||
|
"de" to "Deutsch (German)",
|
||||||
|
"tr" to "Türkçe (Turkish)"
|
||||||
|
)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Language") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
languages.forEach { (tag, name) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
AppCompatDelegate.setApplicationLocales(
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = currentTag == tag, onClick = null)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,17 @@
|
||||||
|
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import com.aryan.reader.opds.OpdsViewModel
|
|
||||||
import com.aryan.reader.opds.OpdsEntry
|
|
||||||
import com.aryan.reader.opds.OpdsCatalog
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -52,6 +41,8 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -67,6 +58,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
|
@ -74,24 +68,28 @@ import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
|
@ -100,6 +98,7 @@ import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -133,9 +132,13 @@ import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.opds.OpdsAcquisition
|
import com.aryan.reader.opds.OpdsAcquisition
|
||||||
|
import com.aryan.reader.opds.OpdsCatalog
|
||||||
|
import com.aryan.reader.opds.OpdsEntry
|
||||||
|
import com.aryan.reader.opds.OpdsViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jsoup.Jsoup
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
@ -147,6 +150,7 @@ private fun getBookCountString(count: Int): String {
|
||||||
return pluralStringResource(id = R.plurals.book_count, count, count)
|
return pluralStringResource(id = R.plurals.book_count, count, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("LocalContextGetResourceValueCall")
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel,
|
||||||
|
|
@ -605,6 +609,7 @@ fun LibraryScreenContent(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
.height(64.dp),
|
.height(64.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -918,7 +923,7 @@ private fun ShelfDetailScreen(
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
if (isContextualModeActive) {
|
if (isContextualModeActive) {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
|
|
@ -1071,7 +1076,7 @@ private fun AddBooksModeScreen(
|
||||||
var showSourceMenu by remember { mutableStateOf(false) }
|
var showSourceMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
CustomTopAppBar(
|
CustomTopAppBar(
|
||||||
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
|
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
|
||||||
|
|
@ -1256,13 +1261,21 @@ private fun ShelfListItem(
|
||||||
onItemClick: () -> Unit,
|
onItemClick: () -> Unit,
|
||||||
onItemLongClick: () -> Unit,
|
onItemLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
androidx.compose.material3.ElevatedCard(
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.large,
|
||||||
tonalElevation = if (isSelected) 8.dp else 2.dp,
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
shadowElevation = 4.dp,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
),
|
||||||
|
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
|
||||||
|
defaultElevation = if (isSelected) 8.dp else 2.dp
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onItemClick,
|
onClick = onItemClick,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
|
|
@ -1318,22 +1331,41 @@ private fun LibraryListItem(
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
androidx.compose.material3.ElevatedCard(
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.large,
|
||||||
tonalElevation = if (isSelected) 8.dp else 2.dp,
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
shadowElevation = 4.dp,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
),
|
||||||
|
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
|
||||||
|
defaultElevation = if (isSelected) 6.dp else 2.dp
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
|
||||||
|
.then(
|
||||||
|
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onItemClick,
|
onClick = onItemClick,
|
||||||
onLongClick = onItemLongClick
|
onLongClick = onItemLongClick
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.Top
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
|
.height(132.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.aspectRatio(0.7f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ImageRequest.Builder(context)
|
model = ImageRequest.Builder(context)
|
||||||
|
|
@ -1344,100 +1376,128 @@ private fun LibraryListItem(
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = item.displayName,
|
contentDescription = item.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Selected", modifier = Modifier.size(36.dp).background(MaterialTheme.colorScheme.primary, CircleShape).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 70.dp, height = 100.dp)
|
.weight(1f)
|
||||||
.clip(MaterialTheme.shapes.small)
|
.fillMaxHeight()
|
||||||
)
|
) {
|
||||||
|
Row(
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (item.sourceFolderUri != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Folder,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
|
||||||
if (isOpdsStream) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Cloud,
|
|
||||||
contentDescription = "OPDS Stream",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPinned) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PushPin,
|
|
||||||
contentDescription = "Pinned",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = item.customName ?: item.title ?: item.displayName,
|
text = item.cardTitle(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
minLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
if (!item.isAvailable) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
if (isDownloading) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Info,
|
|
||||||
contentDescription = "Not available locally",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item.author?.takeIf { it.isNotBlank() }?.let {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = item.cardAuthor(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
minLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
FileStatusBadges(
|
||||||
|
item = item,
|
||||||
|
isPinned = isPinned
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
FileTypeBadge(type = item.type, overlay = false)
|
FileTypeBadge(type = item.type, overlay = false)
|
||||||
|
|
||||||
item.progressPercentage?.let {
|
Box(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
modifier = Modifier
|
||||||
Text(
|
.weight(1f)
|
||||||
text = "•",
|
.height(28.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
contentAlignment = Alignment.CenterStart
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
|
if (!item.isAvailable) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = if (isDownloading) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
},
|
||||||
|
contentColor = if (isDownloading) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (isDownloading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Info,
|
||||||
|
contentDescription = stringResource(R.string.not_available_locally),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${it.toInt()}% complete",
|
text = if (isDownloading) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
stringResource(R.string.status_downloading)
|
||||||
color = MaterialTheme.colorScheme.primary
|
} else {
|
||||||
|
stringResource(R.string.not_available_locally)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
ReadingProgressSection(
|
||||||
|
progressPercentage = item.progressPercentage,
|
||||||
|
compact = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
@ -43,9 +43,10 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
||||||
|
|
@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() {
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ fun MainScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
bottomBarItems.forEachIndexed { index, screen ->
|
bottomBarItems.forEachIndexed { index, screen ->
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
|
@ -115,7 +116,9 @@ import java.util.concurrent.CancellationException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
import io.legere.pdfiumandroid.PdfiumCore
|
import io.legere.pdfiumandroid.PdfiumCore
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||||
|
|
||||||
private const val KEY_RENDER_MODE = "render_mode"
|
private const val KEY_RENDER_MODE = "render_mode"
|
||||||
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
||||||
|
|
@ -276,15 +279,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private val appContext: Context = application.applicationContext
|
private val appContext: Context = application.applicationContext
|
||||||
private val authRepository = AuthRepository(appContext)
|
private val authRepository = AuthRepository(appContext)
|
||||||
private val recentFilesRepository = RecentFilesRepository(appContext)
|
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||||
private val pdfTextRepository = PdfTextRepository(appContext)
|
|
||||||
|
|
||||||
private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao()
|
private val pdfTextRepository by lazy { PdfTextRepository(appContext) }
|
||||||
private val epubParser = EpubParser(appContext)
|
private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() }
|
||||||
private val mobiParser = MobiParser(appContext)
|
private val epubParser by lazy { EpubParser(appContext) }
|
||||||
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
|
private val mobiParser by lazy { MobiParser(appContext) }
|
||||||
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
|
private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) }
|
||||||
private val singleFileImporter = SingleFileImporter(appContext)
|
private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) }
|
||||||
private val bookImporter = BookImporter(appContext)
|
private val singleFileImporter by lazy { SingleFileImporter(appContext) }
|
||||||
|
private val bookImporter by lazy { BookImporter(appContext) }
|
||||||
|
private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) }
|
||||||
|
private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) }
|
||||||
|
private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) }
|
||||||
|
private val pdfHighlightRepository by lazy { PdfHighlightRepository(appContext) }
|
||||||
|
private val pdfAnnotationRepository by lazy { PdfAnnotationRepository(appContext) }
|
||||||
|
|
||||||
private val prefs: SharedPreferences =
|
private val prefs: SharedPreferences =
|
||||||
application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
private val firestoreRepository = FirestoreRepository()
|
private val firestoreRepository = FirestoreRepository()
|
||||||
|
|
@ -301,16 +310,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private val feedbackRepository = FeedbackRepository(appContext)
|
private val feedbackRepository = FeedbackRepository(appContext)
|
||||||
private var feedbackListener: Any? = null
|
private var feedbackListener: Any? = null
|
||||||
private val importMutex = Mutex()
|
private val importMutex = Mutex()
|
||||||
private val pageLayoutRepository = PageLayoutRepository(appContext)
|
|
||||||
private val pdfRichTextRepository = com.aryan.reader.pdf.PdfRichTextRepository(appContext)
|
|
||||||
private val pdfTextBoxRepository = PdfTextBoxRepository(appContext)
|
|
||||||
private val pdfHighlightRepository = PdfHighlightRepository(appContext)
|
|
||||||
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
|
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
val navigationEvent = _navigationEvent.receiveAsFlow()
|
val navigationEvent = _navigationEvent.receiveAsFlow()
|
||||||
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
||||||
private var externalOpenedBookId: String? = null
|
private var externalOpenedBookId: String? = null
|
||||||
|
|
||||||
|
private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null
|
||||||
|
|
||||||
|
private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
|
private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? {
|
||||||
|
if (panelDetector == null && BuildConfig.DEBUG) {
|
||||||
|
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
|
||||||
|
if (modelFile.exists()) {
|
||||||
|
try {
|
||||||
|
val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector")
|
||||||
|
panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.e("Model file best_float16.tflite not found in external files dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return panelDetector
|
||||||
|
}
|
||||||
|
|
||||||
data class PageModificationResult(
|
data class PageModificationResult(
|
||||||
val layout: List<VirtualPage>,
|
val layout: List<VirtualPage>,
|
||||||
val annotations: Map<Int, List<PdfAnnotation>>,
|
val annotations: Map<Int, List<PdfAnnotation>>,
|
||||||
|
|
@ -502,7 +528,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = ReaderScreenState()
|
initialValue = _internalState.value
|
||||||
)
|
)
|
||||||
|
|
||||||
fun setTabsEnabled(enabled: Boolean) {
|
fun setTabsEnabled(enabled: Boolean) {
|
||||||
|
|
@ -796,7 +822,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Timber.d("ViewModel instance created.")
|
Timber.d("ViewModel instance created.")
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
PDFBoxResourceLoader.init(getApplication())
|
PDFBoxResourceLoader.init(getApplication())
|
||||||
|
}
|
||||||
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
|
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
|
||||||
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
|
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
|
||||||
|
|
||||||
|
|
@ -1181,8 +1209,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pdfAnnotationRepository = PdfAnnotationRepository(appContext)
|
|
||||||
|
|
||||||
private fun getFastFileId(context: Context, uri: Uri): String {
|
private fun getFastFileId(context: Context, uri: Uri): String {
|
||||||
var result = uri.toString()
|
var result = uri.toString()
|
||||||
try {
|
try {
|
||||||
|
|
@ -4410,6 +4436,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
|
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
|
||||||
firestoreRepository.removeListener(feedbackListener)
|
firestoreRepository.removeListener(feedbackListener)
|
||||||
|
panelDetector?.close()
|
||||||
|
panelDetector = null
|
||||||
Timber.d("ViewModel instance cleared (onCleared).")
|
Timber.d("ViewModel instance cleared (onCleared).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4627,6 +4655,104 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
return authRepository.getIdToken()
|
return authRepository.getIdToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun testPanelDetection(context: Context) {
|
||||||
|
viewModelScope.launch(mlDispatcher) {
|
||||||
|
try {
|
||||||
|
val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite")
|
||||||
|
if (!modelFile.exists()) {
|
||||||
|
withContext(Dispatchers.Main) { showBanner("Model not found", isError = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ }
|
||||||
|
?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ }
|
||||||
|
|
||||||
|
if (cbzItem == null) {
|
||||||
|
withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = cbzItem.getUri() ?: return@launch
|
||||||
|
Timber.d("BATCH TEST START: ${cbzItem.displayName}")
|
||||||
|
|
||||||
|
var cacheFile: File? = null
|
||||||
|
try {
|
||||||
|
val detector = getOrInitDetector(context) ?: run {
|
||||||
|
withContext(Dispatchers.Main) { showBanner("Model could not be loaded", isError = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
cacheFile = File(context.cacheDir, "temp_test_batch.cbz")
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initialize Model Once
|
||||||
|
val initStartTime = System.currentTimeMillis()
|
||||||
|
val initDuration = System.currentTimeMillis() - initStartTime
|
||||||
|
Timber.d(">>> [BATCH] Model Initialization: ${initDuration}ms")
|
||||||
|
|
||||||
|
val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
|
||||||
|
val totalPages = archiveDoc.getPageCount()
|
||||||
|
|
||||||
|
val startIndex = 3
|
||||||
|
val numPagesToTest = 10
|
||||||
|
val endIndex = minOf(startIndex + numPagesToTest - 1, totalPages - 1)
|
||||||
|
|
||||||
|
val resultsLog = StringBuilder()
|
||||||
|
resultsLog.append("Batch Results:\n")
|
||||||
|
|
||||||
|
// 2. Loop through pages
|
||||||
|
for (i in startIndex..endIndex) {
|
||||||
|
val page = archiveDoc.openPage(i)
|
||||||
|
if (page != null) {
|
||||||
|
val w = page.getPageWidthPoint()
|
||||||
|
val h = page.getPageHeightPoint()
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
val bitmap = androidx.core.graphics.createBitmap(w, h)
|
||||||
|
page.renderPageBitmap(bitmap, 0, 0, w, h, false)
|
||||||
|
|
||||||
|
// Measure precise inference time
|
||||||
|
val pageStartTime = System.currentTimeMillis()
|
||||||
|
val panels = detector.detectPanels(bitmap)
|
||||||
|
val pageDuration = System.currentTimeMillis() - pageStartTime
|
||||||
|
|
||||||
|
val logLine = "Page $i: ${pageDuration}ms (Found ${panels.size} panels)"
|
||||||
|
Timber.d(">>>[BATCH] $logLine")
|
||||||
|
resultsLog.append("$logLine\n")
|
||||||
|
bitmap.recycle()
|
||||||
|
delay(20)
|
||||||
|
}
|
||||||
|
page.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
archiveDoc.close()
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Timber.i(resultsLog.toString())
|
||||||
|
showBanner("Batch test complete! Check logs for page-by-page timings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
cacheFile?.delete()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Batch test failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun detectComicPanels(bitmap: Bitmap, context: Context): List<android.graphics.RectF> {
|
||||||
|
return withContext(mlDispatcher) {
|
||||||
|
try {
|
||||||
|
val detector = getOrInitDetector(context)
|
||||||
|
detector?.detectPanels(bitmap) ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error during panel detection")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
private const val KEY_SORT_ORDER = "sort_order"
|
||||||
internal const val KEY_SHELVES = "shelf_names"
|
internal const val KEY_SHELVES = "shelf_names"
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -55,8 +57,10 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
|
@ -69,6 +73,7 @@ import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
|
@ -85,6 +90,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
|
@ -100,6 +106,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
@ -111,6 +118,7 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.log10
|
import kotlin.math.log10
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
||||||
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
||||||
|
|
@ -254,15 +262,15 @@ fun CustomTopAppBar(
|
||||||
actions: @Composable RowScope.() -> Unit = {}
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
shadowElevation = 2.dp
|
shadowElevation = 2.dp
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(56.dp)
|
||||||
.padding(horizontal = 4.dp),
|
.padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -874,20 +882,168 @@ fun AutoSizeText(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
|
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
|
||||||
val containerColor = if (overlay) androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
|
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
|
||||||
val contentColor = if (overlay) androidx.compose.ui.graphics.Color.White else MaterialTheme.colorScheme.onSecondaryContainer
|
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(50),
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = contentColor
|
contentColor = contentColor,
|
||||||
|
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = type.name.uppercase(),
|
text = type.name.uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.ExtraBold,
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
|
||||||
|
|
||||||
|
fun RecentFileItem.cardTitle(): String {
|
||||||
|
return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.cardAuthor(): String {
|
||||||
|
return author
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
?: UNKNOWN_AUTHOR_LABEL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.progressPercentValue(): Int {
|
||||||
|
return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.progressFraction(): Float {
|
||||||
|
return progressPercentValue() / 100f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RecentFileItem.isOpdsStream(): Boolean {
|
||||||
|
return uriString?.startsWith("opds-pse://") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
|
||||||
|
val container = if (overlay) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.92f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
}
|
||||||
|
val content = if (overlay) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
return container to content
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusIconBadge(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
overlay: Boolean = false,
|
||||||
|
) {
|
||||||
|
val (containerColor, contentColor) = statusBadgeColors(overlay)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
tonalElevation = if (overlay) 0.dp else 2.dp,
|
||||||
|
shadowElevation = if (overlay) 0.dp else 1.dp,
|
||||||
|
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) else null
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.padding(6.dp).size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileStatusBadges(
|
||||||
|
item: RecentFileItem,
|
||||||
|
isPinned: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
overlay: Boolean = false,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (item.sourceFolderUri != null) {
|
||||||
|
StatusIconBadge(
|
||||||
|
icon = Icons.Default.Folder,
|
||||||
|
contentDescription = stringResource(R.string.local_folder),
|
||||||
|
overlay = overlay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.isOpdsStream()) {
|
||||||
|
StatusIconBadge(
|
||||||
|
icon = Icons.Default.Cloud,
|
||||||
|
contentDescription = stringResource(R.string.opds_stream),
|
||||||
|
overlay = overlay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isPinned) {
|
||||||
|
StatusIconBadge(
|
||||||
|
icon = Icons.Default.PushPin,
|
||||||
|
contentDescription = stringResource(R.string.pinned),
|
||||||
|
overlay = overlay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReadingProgressSection(
|
||||||
|
progressPercentage: Float?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String? = null,
|
||||||
|
compact: Boolean = false,
|
||||||
|
) {
|
||||||
|
val percent = (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
|
||||||
|
val progress = percent / 100f
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (label != null) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$percent%",
|
||||||
|
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(if (compact) 6.dp else 8.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(if (compact) 5.dp else 6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +94,7 @@ import com.aryan.reader.R
|
||||||
import com.aryan.reader.RenderMode
|
import com.aryan.reader.RenderMode
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.aryan.reader.epub.EpubTocEntry
|
import com.aryan.reader.epub.EpubTocEntry
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -329,7 +330,8 @@ private fun ChaptersList(
|
||||||
mutableStateOf(allParentIndices)
|
mutableStateOf(allParentIndices)
|
||||||
}
|
}
|
||||||
|
|
||||||
val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) {
|
val visibleItemInfo by remember(effectiveToc) {
|
||||||
|
derivedStateOf {
|
||||||
val result = mutableListOf<Pair<Int, EpubTocEntry>>()
|
val result = mutableListOf<Pair<Int, EpubTocEntry>>()
|
||||||
val visibilityStack = BooleanArray(50) { false }
|
val visibilityStack = BooleanArray(50) { false }
|
||||||
visibilityStack[0] = true
|
visibilityStack[0] = true
|
||||||
|
|
@ -353,6 +355,7 @@ private fun ChaptersList(
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -367,23 +370,29 @@ private fun ChaptersList(
|
||||||
val targetEntry = activeTocEntry ?: return@launch
|
val targetEntry = activeTocEntry ?: return@launch
|
||||||
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
|
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
|
||||||
if (targetOriginalIndex != -1) {
|
if (targetOriginalIndex != -1) {
|
||||||
// Ensure parents are expanded
|
|
||||||
var currentLevel = targetEntry.depth
|
var currentLevel = targetEntry.depth
|
||||||
val newExpanded = expandedEntryIndices.toMutableSet()
|
val newExpanded = expandedEntryIndices.toMutableSet()
|
||||||
|
|
||||||
for (i in targetOriginalIndex downTo 0) {
|
for (i in targetOriginalIndex downTo 0) {
|
||||||
val entry = effectiveToc[i]
|
val entry = effectiveToc[i]
|
||||||
if (entry.depth < currentLevel) {
|
if (entry.depth < currentLevel) {
|
||||||
newExpanded.add(i)
|
newExpanded.add(i)
|
||||||
currentLevel = entry.depth
|
currentLevel = entry.depth
|
||||||
}
|
}
|
||||||
|
if (currentLevel == 0) break
|
||||||
}
|
}
|
||||||
|
|
||||||
expandedEntryIndices = newExpanded
|
expandedEntryIndices = newExpanded
|
||||||
|
|
||||||
// Delay to allow visibility array to recompose
|
|
||||||
kotlinx.coroutines.delay(100)
|
|
||||||
|
|
||||||
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
||||||
|
|
||||||
if (visibleIdx != -1) {
|
if (visibleIdx != -1) {
|
||||||
|
var attempts = 0
|
||||||
|
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
|
||||||
|
delay(30)
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
listState.animateScrollToItem(visibleIdx)
|
listState.animateScrollToItem(visibleIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@ fun EpubReaderScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ControlFlowWithEmptyBody")
|
@Suppress("ControlFlowWithEmptyBody")
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt")
|
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
||||||
@androidx.annotation.OptIn(UnstableApi::class)
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -481,6 +481,13 @@ fun EpubReaderHost(
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
|
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
|
||||||
|
DisposableEffect(window, view) {
|
||||||
|
onDispose {
|
||||||
|
window?.let {
|
||||||
|
WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val containerFocusRequester = remember { FocusRequester() }
|
val containerFocusRequester = remember { FocusRequester() }
|
||||||
|
|
|
||||||
9
app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt
Normal file
9
app/src/main/java/com/aryan/reader/ml/IPanelDetector.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.aryan.reader.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
|
||||||
|
interface IPanelDetector {
|
||||||
|
fun detectPanels(bitmap: Bitmap, confidenceThreshold: Float = 0.25f, iouThreshold: Float = 0.45f): List<RectF>
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
@ -1908,7 +1908,6 @@ internal fun PaginatedReaderContent(
|
||||||
val down = event.changes.firstOrNull { it.pressed }
|
val down = event.changes.firstOrNull { it.pressed }
|
||||||
if (down != null) {
|
if (down != null) {
|
||||||
pageTurnTouchY = down.position.y
|
pageTurnTouchY = down.position.y
|
||||||
Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3952,10 +3951,6 @@ private fun Modifier.realisticBookPage(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
touchY: Float?
|
touchY: Float?
|
||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
// Log composition frequency
|
|
||||||
SideEffect {
|
|
||||||
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val frontPath = remember { Path() }
|
val frontPath = remember { Path() }
|
||||||
val backPath = remember { Path() }
|
val backPath = remember { Path() }
|
||||||
|
|
@ -3965,7 +3960,6 @@ private fun Modifier.realisticBookPage(
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
|
||||||
// Log layer property updates
|
|
||||||
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
|
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
|
||||||
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
|
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
|
||||||
}
|
}
|
||||||
|
|
@ -3994,7 +3988,14 @@ private fun Modifier.realisticBookPage(
|
||||||
val h = size.height
|
val h = size.height
|
||||||
|
|
||||||
val startY = touchY ?: h
|
val startY = touchY ?: h
|
||||||
val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
|
val rawCenterDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
|
||||||
|
|
||||||
|
val flattenFactor = if (progress > 0.75f) {
|
||||||
|
((progress - 0.75f) / 0.25f).coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
val centerDist = rawCenterDist * (1f - flattenFactor)
|
||||||
|
|
||||||
val cornerY = if (centerDist >= 0) h else 0f
|
val cornerY = if (centerDist >= 0) h else 0f
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,23 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
|
@ -35,19 +45,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Redo
|
import androidx.compose.material.icons.automirrored.filled.Redo
|
||||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.DoNotTouch
|
import androidx.compose.material.icons.filled.DoNotTouch
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.TouchApp
|
import androidx.compose.material.icons.filled.TouchApp
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
|
@ -330,3 +350,159 @@ private fun DockIcon(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PenPlayground(onClose: () -> Unit) {
|
||||||
|
var selectedPen by remember { mutableStateOf(PenType.FOUNTAIN_PEN) }
|
||||||
|
var selectedColor by remember { mutableStateOf(Color(0xFF2196F3)) } // Default Blue
|
||||||
|
val colors = listOf(
|
||||||
|
Color(0xFFF44336), // Red
|
||||||
|
Color(0xFFFFEB3B), // Yellow
|
||||||
|
Color(0xFF2196F3), // Blue
|
||||||
|
Color(0xFF4CAF50), // Green
|
||||||
|
Color(0xFF9C27B0), // Purple
|
||||||
|
Color.White, // White
|
||||||
|
Color.Black // Black
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.95f)
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
shadowElevation = 16.dp,
|
||||||
|
tonalElevation = 0.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Gray,
|
||||||
|
modifier = Modifier.padding(start = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
id = R.drawable.close
|
||||||
|
),
|
||||||
|
contentDescription = "Close", tint = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
|
// --- The Pen Rack ---
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(140.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
PenType.entries.forEach { type ->
|
||||||
|
val isSelected = selectedPen == type
|
||||||
|
|
||||||
|
val offsetY by animateDpAsState(
|
||||||
|
targetValue = if (isSelected) (-20).dp else 0.dp, label = "offset"
|
||||||
|
)
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isSelected) 1.2f else 1.0f, label = "scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(y = offsetY)
|
||||||
|
.scale(scale)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { selectedPen = type }) {
|
||||||
|
// Drawing Area
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(40.dp)
|
||||||
|
.height(120.dp),
|
||||||
|
contentAlignment = Alignment.BottomCenter
|
||||||
|
) {
|
||||||
|
PenIcon(
|
||||||
|
color = selectedColor,
|
||||||
|
type = type,
|
||||||
|
isSelected = isSelected,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
color = Color.White.copy(alpha = 0.1f),
|
||||||
|
thickness = 1.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// --- Color Palette ---
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
colors.forEach { color ->
|
||||||
|
val isSelected = selectedColor == color
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clickable { selectedColor = color }) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawCircle(color = color)
|
||||||
|
if (isSelected) {
|
||||||
|
drawCircle(
|
||||||
|
color = Color.White,
|
||||||
|
radius = size.minDimension / 2,
|
||||||
|
style = Stroke(width = 3.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected && color == Color.White) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
} else if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (color == Color.Black) Color.White
|
||||||
|
else Color.Black.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt
Normal file
149
app/src/main/java/com/aryan/reader/pdf/PdfDialogs.kt
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PasswordDialog(isError: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_password_protected)) }, text = {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.desc_password_protected))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { onConfirm(password) }),
|
||||||
|
isError = isError,
|
||||||
|
supportingText = if (isError) {
|
||||||
|
{ Text(stringResource(R.string.error_incorrect_password)) }
|
||||||
|
} else null,
|
||||||
|
trailingIcon = {
|
||||||
|
val image = if (passwordVisible) Icons.Filled.Visibility
|
||||||
|
else Icons.Filled.VisibilityOff
|
||||||
|
|
||||||
|
val description = if (passwordVisible) stringResource(R.string.content_desc_hide_password) else stringResource(R.string.content_desc_show_password)
|
||||||
|
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(imageVector = image, description)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(password) }, enabled = password.isNotBlank()) {
|
||||||
|
Text(stringResource(R.string.action_open))
|
||||||
|
}
|
||||||
|
}, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun OcrLanguageSelectionDialog(
|
||||||
|
currentLanguage: OcrLanguage,
|
||||||
|
isFirstRun: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onLanguageSelected: (OcrLanguage) -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_select_ocr_language)) }, text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.desc_select_ocr_language),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isFirstRun) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.desc_ocr_language_change_later),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OcrLanguage.entries.forEach { language ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = (language == currentLanguage),
|
||||||
|
onClick = { onLanguageSelected(language) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = (language == currentLanguage), onClick = null)
|
||||||
|
Text(
|
||||||
|
text = language.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } })
|
||||||
|
}
|
||||||
194
app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
Normal file
194
app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.print.PageRange
|
||||||
|
import android.print.PrintAttributes
|
||||||
|
import android.print.PrintDocumentAdapter
|
||||||
|
import android.print.PrintDocumentInfo
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
object PdfiumCoreProvider {
|
||||||
|
val core: PdfiumCoreKt by lazy {
|
||||||
|
PdfiumCoreKt(Dispatchers.Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DocumentCacheItem(
|
||||||
|
val doc: ReaderDocument,
|
||||||
|
val pfd: ParcelFileDescriptor,
|
||||||
|
val totalPages: Int,
|
||||||
|
val pageAspectRatios: List<Float>,
|
||||||
|
val flatTableOfContents: List<TocEntry>
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class DocumentCache(val maxSize: Int = 3) {
|
||||||
|
val cache = object : LruCache<String, DocumentCacheItem>(maxSize) {
|
||||||
|
override fun entryRemoved(
|
||||||
|
evicted: Boolean,
|
||||||
|
key: String,
|
||||||
|
oldValue: DocumentCacheItem,
|
||||||
|
newValue: DocumentCacheItem?
|
||||||
|
) {
|
||||||
|
if (evicted) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try { oldValue.doc.close() } catch (e: Exception) { Timber.e(e) }
|
||||||
|
try { oldValue.pfd.close() } catch (e: Exception) { Timber.e(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun put(key: String, item: DocumentCacheItem) { cache.put(key, item) }
|
||||||
|
fun get(key: String): DocumentCacheItem? = cache.get(key)
|
||||||
|
fun evictAll() { cache.evictAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PdfPrintDocumentAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val pdfUri: Uri,
|
||||||
|
private val fileName: String
|
||||||
|
) : PrintDocumentAdapter() {
|
||||||
|
|
||||||
|
override fun onLayout(
|
||||||
|
oldAttributes: PrintAttributes?,
|
||||||
|
newAttributes: PrintAttributes?,
|
||||||
|
cancellationSignal: CancellationSignal?,
|
||||||
|
callback: LayoutResultCallback?,
|
||||||
|
extras: Bundle?
|
||||||
|
) {
|
||||||
|
if (cancellationSignal?.isCanceled == true) {
|
||||||
|
callback?.onLayoutCancelled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = PrintDocumentInfo.Builder(fileName)
|
||||||
|
.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
callback?.onLayoutFinished(info, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWrite(
|
||||||
|
pages: Array<out PageRange>?,
|
||||||
|
destination: ParcelFileDescriptor?,
|
||||||
|
cancellationSignal: CancellationSignal?,
|
||||||
|
callback: WriteResultCallback?
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.openFileDescriptor(pdfUri, "r")?.use { pfd ->
|
||||||
|
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||||
|
FileOutputStream(destination?.fileDescriptor).use { output ->
|
||||||
|
val buf = ByteArray(8192)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (input.read(buf).also { bytesRead = it } > 0) {
|
||||||
|
if (cancellationSignal?.isCanceled == true) {
|
||||||
|
Timber.tag("PdfPrint").d("Print job cancelled during write")
|
||||||
|
callback?.onWriteCancelled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.write(buf, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timber.tag("PdfPrint").i("PDF successfully streamed to print spooler")
|
||||||
|
callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("PdfPrint").e(e, "Error writing PDF to print spooler")
|
||||||
|
callback?.onWriteFailed(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun generateShortId(): String {
|
||||||
|
return Random.nextInt(1000, 9999).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): String {
|
||||||
|
val base = originalName?.substringBeforeLast('.') ?: "Document"
|
||||||
|
val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_").take(50)
|
||||||
|
|
||||||
|
val suffix = if (isAnnotated) "_annotated" else ""
|
||||||
|
val shortId = generateShortId()
|
||||||
|
|
||||||
|
return "${safeBase}${suffix}_${shortId}.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getFastFileId(context: Context, uri: Uri): String {
|
||||||
|
var result = uri.toString()
|
||||||
|
try {
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
|
||||||
|
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||||
|
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||||
|
|
||||||
|
result = "${name}_${size}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to generate fast file ID")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bitmap? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
var page: ReaderPage? = null
|
||||||
|
try {
|
||||||
|
page = doc.openPage(pageIndex)
|
||||||
|
if (page == null) return@withContext null
|
||||||
|
|
||||||
|
val bitmapWidth = 1080
|
||||||
|
val aspectRatio =
|
||||||
|
page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat()
|
||||||
|
if (aspectRatio.isNaN() || aspectRatio <= 0) {
|
||||||
|
Timber.e("Invalid aspect ratio for page $pageIndex")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val bitmapHeight = (bitmapWidth / aspectRatio).toInt()
|
||||||
|
|
||||||
|
if (bitmapHeight <= 0) {
|
||||||
|
Timber.e("Invalid calculated bitmap height for page $pageIndex")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val bitmap = createBitmap(bitmapWidth, bitmapHeight)
|
||||||
|
page.renderPageBitmap(
|
||||||
|
bitmap = bitmap,
|
||||||
|
startX = 0,
|
||||||
|
startY = 0,
|
||||||
|
drawSizeX = bitmapWidth,
|
||||||
|
drawSizeY = bitmapHeight,
|
||||||
|
renderAnnot = true
|
||||||
|
)
|
||||||
|
bitmap
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error rendering page $pageIndex to bitmap for summarization")
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
page?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Error closing page in renderPageToBitmap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/src/main/java/com/aryan/reader/pdf/PdfModels.kt
Normal file
36
app/src/main/java/com/aryan/reader/pdf/PdfModels.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||||
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
|
|
||||||
|
internal enum class SaveMode {
|
||||||
|
ORIGINAL, ANNOTATED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SearchHighlightMode {
|
||||||
|
FOCUSED, ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface HistoryAction {
|
||||||
|
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
|
||||||
|
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DockLocation {
|
||||||
|
TOP, BOTTOM, FLOATING
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DisplayMode {
|
||||||
|
PAGINATION, VERTICAL_SCROLL
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Suppress("unused")
|
||||||
|
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,537 @@
|
||||||
|
// PdfNavigationDrawerContent.kt
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun PdfNavigationDrawerContent(
|
||||||
|
flatTableOfContents: List<TocEntry>,
|
||||||
|
bookmarks: Set<PdfBookmark>,
|
||||||
|
userHighlights: List<PdfUserHighlight>,
|
||||||
|
currentPage: Int,
|
||||||
|
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||||
|
onPageSelected: (Int) -> Unit,
|
||||||
|
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||||
|
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||||
|
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||||
|
onNoteRequested: (String?) -> Unit,
|
||||||
|
onCloseDrawer: () -> Unit
|
||||||
|
) {
|
||||||
|
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||||
|
val drawerScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
|
||||||
|
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||||
|
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||||
|
}, text = { Text("Chapters") })
|
||||||
|
Tab(
|
||||||
|
selected = drawerPagerState.currentPage == 1,
|
||||||
|
onClick = {
|
||||||
|
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
|
||||||
|
},
|
||||||
|
text = { Text("Bookmarks") },
|
||||||
|
modifier = Modifier.testTag("BookmarksTab")
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = drawerPagerState.currentPage == 2,
|
||||||
|
onClick = {
|
||||||
|
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
|
||||||
|
},
|
||||||
|
text = { Text("Highlights") },
|
||||||
|
modifier = Modifier.testTag("HighlightsTab")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
state = drawerPagerState,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
|
) { page ->
|
||||||
|
when (page) {
|
||||||
|
0 -> { // Chapters Page
|
||||||
|
if (flatTableOfContents.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Chapters are not available for this book.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val allParentIndices = remember(flatTableOfContents) {
|
||||||
|
flatTableOfContents.indices.filter { i ->
|
||||||
|
val next = flatTableOfContents.getOrNull(i + 1)
|
||||||
|
next != null && next.nestLevel > flatTableOfContents[i].nestLevel
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
|
||||||
|
mutableStateOf(allParentIndices)
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleItemInfo by remember(flatTableOfContents) {
|
||||||
|
derivedStateOf {
|
||||||
|
val result = mutableListOf<Pair<Int, TocEntry>>()
|
||||||
|
val visibilityStack = BooleanArray(20) { false }
|
||||||
|
visibilityStack[0] = true
|
||||||
|
|
||||||
|
for (i in flatTableOfContents.indices) {
|
||||||
|
val entry = flatTableOfContents[i]
|
||||||
|
val level = entry.nestLevel.coerceIn(0, 19)
|
||||||
|
|
||||||
|
if (visibilityStack[level]) {
|
||||||
|
result.add(i to entry)
|
||||||
|
val isExpanded = expandedEntryIndices.contains(i)
|
||||||
|
if (level + 1 < visibilityStack.size) {
|
||||||
|
visibilityStack[level + 1] = isExpanded
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (level + 1 < visibilityStack.size) {
|
||||||
|
visibilityStack[level + 1] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentTocEntry by remember(currentPage, flatTableOfContents) {
|
||||||
|
derivedStateOf {
|
||||||
|
flatTableOfContents.lastOrNull { it.pageIndex <= currentPage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onScrollToCurrent = {
|
||||||
|
drawerScope.launch {
|
||||||
|
val targetEntry = currentTocEntry ?: return@launch
|
||||||
|
val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
|
||||||
|
if (targetOriginalIndex != -1) {
|
||||||
|
var currentLevel = targetEntry.nestLevel
|
||||||
|
val newExpanded = expandedEntryIndices.toMutableSet()
|
||||||
|
|
||||||
|
for (i in targetOriginalIndex downTo 0) {
|
||||||
|
val entry = flatTableOfContents[i]
|
||||||
|
if (entry.nestLevel < currentLevel) {
|
||||||
|
newExpanded.add(i)
|
||||||
|
currentLevel = entry.nestLevel
|
||||||
|
}
|
||||||
|
if (currentLevel == 0) break
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedEntryIndices = newExpanded
|
||||||
|
|
||||||
|
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
||||||
|
|
||||||
|
if (visibleIdx != -1) {
|
||||||
|
var attempts = 0
|
||||||
|
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
|
||||||
|
delay(30)
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
listState.animateScrollToItem(visibleIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) {
|
||||||
|
Text("Expand All")
|
||||||
|
}
|
||||||
|
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
|
||||||
|
Text("Collapse All")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onScrollToCurrent) {
|
||||||
|
Text("Locate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(end = 12.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = visibleItemInfo,
|
||||||
|
key = { it.second.title + it.first }
|
||||||
|
) { item ->
|
||||||
|
val (originalIndex, entry) = item
|
||||||
|
|
||||||
|
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
|
||||||
|
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
|
||||||
|
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||||
|
val isCurrentChapter = entry == currentTocEntry
|
||||||
|
|
||||||
|
PdfTocTreeItem(
|
||||||
|
label = entry.title,
|
||||||
|
nestLevel = entry.nestLevel,
|
||||||
|
isExpanded = isExpanded,
|
||||||
|
hasChildren = hasChildren,
|
||||||
|
isCurrent = isCurrentChapter,
|
||||||
|
onToggleExpand = {
|
||||||
|
expandedEntryIndices = if (isExpanded) {
|
||||||
|
expandedEntryIndices - originalIndex
|
||||||
|
} else {
|
||||||
|
expandedEntryIndices + originalIndex
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onCloseDrawer()
|
||||||
|
onPageSelected(entry.pageIndex)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollbar(
|
||||||
|
listState = listState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> { // Bookmarks Page
|
||||||
|
if (bookmarks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"You haven't added any bookmarks yet.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var bookmarkMenuExpandedFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||||
|
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||||
|
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||||
|
|
||||||
|
val sortedBookmarks = remember(bookmarks) {
|
||||||
|
bookmarks.sortedBy { it.pageIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = sortedBookmarks, key = { index, bookmark ->
|
||||||
|
"bm_${index}_${bookmark.pageIndex}"
|
||||||
|
}) { _, bookmark ->
|
||||||
|
ListItem(headlineContent = {
|
||||||
|
Text(
|
||||||
|
bookmark.title,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}, supportingContent = {
|
||||||
|
Text(
|
||||||
|
"Page ${bookmark.pageIndex + 1} of ${bookmark.totalPages}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}, trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
bookmarkMenuExpandedFor = bookmark
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "More options for bookmark"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = bookmarkMenuExpandedFor == bookmark,
|
||||||
|
onDismissRequest = {
|
||||||
|
bookmarkMenuExpandedFor = null
|
||||||
|
}) {
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Text("Rename")
|
||||||
|
}, onClick = {
|
||||||
|
showRenameBookmarkDialog = bookmark
|
||||||
|
bookmarkMenuExpandedFor = null
|
||||||
|
})
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Text("Delete")
|
||||||
|
}, onClick = {
|
||||||
|
showDeleteConfirmDialogFor = bookmark
|
||||||
|
bookmarkMenuExpandedFor = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
onCloseDrawer()
|
||||||
|
onPageSelected(bookmark.pageIndex)
|
||||||
|
}
|
||||||
|
.testTag(
|
||||||
|
"BookmarkItem_${bookmark.pageIndex}"
|
||||||
|
))
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRenameBookmarkDialog?.let { bookmarkToRename ->
|
||||||
|
var newTitle by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(onDismissRequest = {
|
||||||
|
showRenameBookmarkDialog = null
|
||||||
|
}, title = { Text("Rename Bookmark") }, text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newTitle,
|
||||||
|
onValueChange = { newTitle = it },
|
||||||
|
label = { Text("New Title") },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = bookmarkToRename.title,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.6f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}, confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onRenameBookmark(bookmarkToRename, newTitle)
|
||||||
|
showRenameBookmarkDialog = null
|
||||||
|
}) { Text("Save") }
|
||||||
|
}, dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRenameBookmarkDialog = null
|
||||||
|
}) { Text("Cancel") }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
|
||||||
|
AlertDialog(onDismissRequest = {
|
||||||
|
showDeleteConfirmDialogFor = null
|
||||||
|
}, title = { Text("Delete Bookmark?") }, text = {
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to permanently delete this bookmark?"
|
||||||
|
)
|
||||||
|
}, confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDeleteBookmark(bookmarkToDelete)
|
||||||
|
showDeleteConfirmDialogFor = null
|
||||||
|
}) { Text("Delete") }
|
||||||
|
}, dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteConfirmDialogFor = null
|
||||||
|
}) { Text("Cancel") }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 -> { // Highlights Page
|
||||||
|
if (userHighlights.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"You haven't added any highlights yet.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfUserHighlight?>(null) }
|
||||||
|
var filterWithNotesOnly by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = !filterWithNotesOnly,
|
||||||
|
onClick = { filterWithNotesOnly = false },
|
||||||
|
label = { Text("All") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filterWithNotesOnly,
|
||||||
|
onClick = { filterWithNotesOnly = true },
|
||||||
|
label = { Text("With Notes") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredHighlights = if (filterWithNotesOnly) {
|
||||||
|
userHighlights.filter { !it.note.isNullOrBlank() }
|
||||||
|
} else {
|
||||||
|
userHighlights.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedHighlights = remember(filteredHighlights) {
|
||||||
|
filteredHighlights.sortedBy { it.pageIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = sortedHighlights,
|
||||||
|
key = { _, highlight -> highlight.id }
|
||||||
|
) { _, highlight ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = highlight.text.ifBlank { "Highlighted section" },
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.background(displayColor, CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Page ${highlight.pageIndex + 1}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!highlight.note.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = highlight.note,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
var highlightMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
IconButton(onClick = { highlightMenuExpanded = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Options")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = highlightMenuExpanded,
|
||||||
|
onDismissRequest = { highlightMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (highlight.note.isNullOrBlank()) "Add Note" else "Edit Note") },
|
||||||
|
onClick = {
|
||||||
|
onNoteRequested(highlight.id)
|
||||||
|
highlightMenuExpanded = false
|
||||||
|
onCloseDrawer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = {
|
||||||
|
showDeleteConfirmDialogFor = highlight
|
||||||
|
highlightMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onCloseDrawer()
|
||||||
|
onPageSelected(highlight.pageIndex)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteConfirmDialogFor?.let { highlightToDelete ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirmDialogFor = null },
|
||||||
|
title = { Text("Delete Highlight?") },
|
||||||
|
text = { Text("Are you sure you want to permanently delete this highlight?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDeleteHighlight(highlightToDelete)
|
||||||
|
showDeleteConfirmDialogFor = null
|
||||||
|
}
|
||||||
|
) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showDeleteConfirmDialogFor = null }
|
||||||
|
) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
Normal file
253
app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.R
|
||||||
|
|
||||||
|
private data class ScrollbarCalculations(
|
||||||
|
val thumbHeight: Float,
|
||||||
|
val thumbOffset: Float,
|
||||||
|
val contentHeight: Float,
|
||||||
|
val viewportHeight: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerticalScrollbar(
|
||||||
|
listState: LazyListState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isDragged by interactionSource.collectIsDraggedAsState()
|
||||||
|
|
||||||
|
val scrollbarState by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val totalItems = layoutInfo.totalItemsCount
|
||||||
|
val visibleItemsInfo = layoutInfo.visibleItemsInfo
|
||||||
|
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||||
|
|
||||||
|
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
|
||||||
|
return@derivedStateOf null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate total height
|
||||||
|
val averageItemHeight = visibleItemsInfo.sumOf { it.size } / visibleItemsInfo.size.toFloat()
|
||||||
|
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||||
|
val viewportRatio = viewportHeight / estimatedContentHeight
|
||||||
|
|
||||||
|
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||||
|
|
||||||
|
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
||||||
|
|
||||||
|
val firstItemIndex = listState.firstVisibleItemIndex
|
||||||
|
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||||
|
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
|
||||||
|
val maxScrollPixels = estimatedContentHeight - viewportHeight
|
||||||
|
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
|
||||||
|
val trackHeight = viewportHeight - thumbHeight
|
||||||
|
val thumbOffset = trackHeight * scrollProgress
|
||||||
|
|
||||||
|
ScrollbarCalculations(
|
||||||
|
thumbHeight = thumbHeight,
|
||||||
|
thumbOffset = thumbOffset,
|
||||||
|
contentHeight = estimatedContentHeight,
|
||||||
|
viewportHeight = viewportHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = targetAlpha,
|
||||||
|
animationSpec = tween(durationMillis = 200),
|
||||||
|
label = "ScrollbarAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (scrollbarState != null) {
|
||||||
|
val state = scrollbarState!!
|
||||||
|
val draggableState = rememberDraggableState { delta ->
|
||||||
|
val trackHeight = state.viewportHeight - state.thumbHeight
|
||||||
|
if (trackHeight > 0) {
|
||||||
|
val scrollRatio = delta / trackHeight
|
||||||
|
val totalScrollableDistance = state.contentHeight - state.viewportHeight
|
||||||
|
val scrollDelta = scrollRatio * totalScrollableDistance
|
||||||
|
listState.dispatchRawDelta(scrollDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.width(30.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.draggable(
|
||||||
|
state = draggableState,
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.graphicsLayer { translationY = state.thumbOffset }
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.width(6.dp)
|
||||||
|
.height(with(LocalDensity.current) { state.thumbHeight.toDp() })
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(
|
||||||
|
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
shape = RoundedCornerShape(100)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
indication = null, interactionSource = remember { MutableInteractionSource() }) {},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(
|
||||||
|
alpha = 0.9f
|
||||||
|
), shape = RoundedCornerShape(16.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.slider),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Page $currentPage of $totalPages",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ThumbnailWithIndicator(
|
||||||
|
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val borderColor = MaterialTheme.colorScheme.primary
|
||||||
|
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(45.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
border = BorderStroke(2.dp, borderColor)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = thumbnail.asImageBitmap(),
|
||||||
|
contentDescription = "Start page thumbnail",
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.offset(y = (-4).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.rotate(45f)
|
||||||
|
.background(borderColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BookmarkButton(
|
||||||
|
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.height(48.dp)
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick
|
||||||
|
), contentAlignment = Alignment.TopCenter
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = isBookmarked, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.bookmark),
|
||||||
|
contentDescription = "Bookmark",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ZoomPercentageIndicator(percentage: Int) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$percentage%",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -347,7 +347,9 @@ data class PageStaticData(
|
||||||
val targetWidth: Int,
|
val targetWidth: Int,
|
||||||
val targetHeight: Int,
|
val targetHeight: Int,
|
||||||
val colorFilter: StableHolder<ColorFilter?>,
|
val colorFilter: StableHolder<ColorFilter?>,
|
||||||
val isDarkMode: Boolean
|
val isDarkMode: Boolean,
|
||||||
|
val excludeImages: Boolean,
|
||||||
|
val imageRects: StableHolder<List<android.graphics.Rect>>
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|
@ -416,6 +418,7 @@ internal fun PdfPageComposable(
|
||||||
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||||
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||||
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||||
|
excludeImages: Boolean = false,
|
||||||
onDoubleTap: ((Offset) -> Unit)? = null,
|
onDoubleTap: ((Offset) -> Unit)? = null,
|
||||||
isEditMode: Boolean = false,
|
isEditMode: Boolean = false,
|
||||||
drawingState: PdfDrawingState? = null,
|
drawingState: PdfDrawingState? = null,
|
||||||
|
|
@ -451,7 +454,9 @@ internal fun PdfPageComposable(
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||||
onPaletteClick: (() -> Unit)? = null,
|
onPaletteClick: (() -> Unit)? = null,
|
||||||
lockedState: Triple<Float, Float, Float>? = null,
|
lockedState: Triple<Float, Float, Float>? = null,
|
||||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
|
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||||
|
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
|
||||||
|
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val pdfDocumentItem = pdfDocument.item
|
val pdfDocumentItem = pdfDocument.item
|
||||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
||||||
|
|
@ -852,11 +857,13 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
@Suppress("VariableNeverRead") var embeddedAnnotations by remember { mutableStateOf<List<EmbeddedAnnotation>>(emptyList()) }
|
||||||
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
var standardAnnotScreenRects by remember { mutableStateOf<List<Pair<EmbeddedAnnotation, Rect>>>(emptyList()) }
|
||||||
|
var imageScreenRects by remember { mutableStateOf<List<android.graphics.Rect>>(emptyList()) }
|
||||||
|
|
||||||
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
LaunchedEffect(pageIndex, pdfDocumentItem, actualBitmapWidthPx, actualBitmapHeightPx, virtualPage) {
|
||||||
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
if (!isPdfPage || actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0) {
|
||||||
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
|
if (pageLinks.isNotEmpty()) pageLinks = emptyList()
|
||||||
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
|
if (standardAnnotScreenRects.isNotEmpty()) standardAnnotScreenRects = emptyList()
|
||||||
|
if (imageScreenRects.isNotEmpty()) imageScreenRects = emptyList()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,6 +871,7 @@ internal fun PdfPageComposable(
|
||||||
val allLinks = mutableListOf<PageLink>()
|
val allLinks = mutableListOf<PageLink>()
|
||||||
var finalDisplayList = emptyList<EmbeddedAnnotation>()
|
var finalDisplayList = emptyList<EmbeddedAnnotation>()
|
||||||
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
|
var mappedAnnots = emptyList<Pair<EmbeddedAnnotation, Rect>>()
|
||||||
|
var mappedImageRects = emptyList<android.graphics.Rect>()
|
||||||
val annotLink = 2
|
val annotLink = 2
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -931,6 +939,39 @@ internal fun PdfPageComposable(
|
||||||
Timber.e(e, "Error fetching web links")
|
Timber.e(e, "Error fetching web links")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Extract Image Bounds ---
|
||||||
|
try {
|
||||||
|
val pagePtr = pageWrapper.getNativePointer()
|
||||||
|
if (pagePtr != 0L) {
|
||||||
|
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||||
|
val imgRects = mutableListOf<android.graphics.Rect>()
|
||||||
|
val outRect = FloatArray(4)
|
||||||
|
|
||||||
|
for (i in 0 until objCount) {
|
||||||
|
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
||||||
|
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
||||||
|
val pdfRectF = android.graphics.RectF(
|
||||||
|
min(outRect[0], outRect[2]),
|
||||||
|
max(outRect[1], outRect[3]),
|
||||||
|
max(outRect[0], outRect[2]),
|
||||||
|
min(outRect[1], outRect[3])
|
||||||
|
)
|
||||||
|
val deviceRect = pageWrapper.mapRectToDevice(
|
||||||
|
0, 0, actualBitmapWidthPx, actualBitmapHeightPx,
|
||||||
|
currentPageRotation, pdfRectF
|
||||||
|
)
|
||||||
|
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
|
||||||
|
imgRects.add(android.graphics.Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mappedImageRects = imgRects
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Extract Embedded Annotations
|
// 3. Extract Embedded Annotations
|
||||||
try {
|
try {
|
||||||
val pagePtr = pageWrapper.getNativePointer()
|
val pagePtr = pageWrapper.getNativePointer()
|
||||||
|
|
@ -1035,6 +1076,7 @@ internal fun PdfPageComposable(
|
||||||
pageLinks = allLinks
|
pageLinks = allLinks
|
||||||
embeddedAnnotations = finalDisplayList
|
embeddedAnnotations = finalDisplayList
|
||||||
standardAnnotScreenRects = mappedAnnots
|
standardAnnotScreenRects = mappedAnnots
|
||||||
|
imageScreenRects = mappedImageRects
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2627,7 +2669,38 @@ internal fun PdfPageComposable(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val startScale = scale
|
val startScale = scale
|
||||||
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
||||||
Timber.tag("PdfZoomDebug").i("DoubleTap Triggered: CurrentScale=$startScale, Target=$targetScale")
|
|
||||||
|
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
|
||||||
|
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||||
|
|
||||||
|
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
|
||||||
|
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
|
||||||
|
val tapXInBitmap = tapInContentCoords.x * ratioX
|
||||||
|
val tapYInBitmap = tapInContentCoords.y * ratioY
|
||||||
|
|
||||||
|
val panels = onDetectPanels(bitmapState!!)
|
||||||
|
|
||||||
|
val tappedPanel = panels.firstOrNull {
|
||||||
|
it.contains(tapXInBitmap, tapYInBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tappedPanel != null) {
|
||||||
|
Timber.d("Popup: Cropping panel $tappedPanel")
|
||||||
|
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
|
||||||
|
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
|
||||||
|
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
|
||||||
|
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
|
||||||
|
val width = right - left
|
||||||
|
val height = bottom - top
|
||||||
|
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
|
||||||
|
onShowPanelPopup(cropped)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val startOffset = offset
|
val startOffset = offset
|
||||||
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
|
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
|
||||||
Offset.Zero
|
Offset.Zero
|
||||||
|
|
@ -2658,10 +2731,10 @@ internal fun PdfPageComposable(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
val progress = value
|
val progress = value
|
||||||
scale = lerp(
|
scale = androidx.compose.ui.util.lerp(
|
||||||
startScale, targetScale, progress
|
startScale, targetScale, progress
|
||||||
)
|
)
|
||||||
offset = lerp(
|
offset = androidx.compose.ui.geometry.lerp(
|
||||||
startOffset, targetOffset, progress
|
startOffset, targetOffset, progress
|
||||||
)
|
)
|
||||||
onScaleChanged(scale)
|
onScaleChanged(scale)
|
||||||
|
|
@ -3555,6 +3628,7 @@ internal fun PdfPageComposable(
|
||||||
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
|
val stableBitmapState = remember(bitmapState) { StableHolder(bitmapState) }
|
||||||
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
||||||
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
||||||
|
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
||||||
|
|
||||||
val staticData = remember(
|
val staticData = remember(
|
||||||
stableBitmapState,
|
stableBitmapState,
|
||||||
|
|
@ -3567,7 +3641,9 @@ internal fun PdfPageComposable(
|
||||||
actualBitmapWidthPx,
|
actualBitmapWidthPx,
|
||||||
actualBitmapHeightPx,
|
actualBitmapHeightPx,
|
||||||
stableColorFilter,
|
stableColorFilter,
|
||||||
isDarkMode
|
isDarkMode,
|
||||||
|
excludeImages,
|
||||||
|
stableImageRects
|
||||||
) {
|
) {
|
||||||
Timber.tag("PdfDrawPerf").v(
|
Timber.tag("PdfDrawPerf").v(
|
||||||
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
||||||
|
|
@ -3583,7 +3659,9 @@ internal fun PdfPageComposable(
|
||||||
targetWidth = actualBitmapWidthPx,
|
targetWidth = actualBitmapWidthPx,
|
||||||
targetHeight = actualBitmapHeightPx,
|
targetHeight = actualBitmapHeightPx,
|
||||||
colorFilter = stableColorFilter,
|
colorFilter = stableColorFilter,
|
||||||
isDarkMode = isDarkMode
|
isDarkMode = isDarkMode,
|
||||||
|
excludeImages = excludeImages,
|
||||||
|
imageRects = stableImageRects
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3659,6 +3737,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scale = scale,
|
scale = scale,
|
||||||
|
uiScale = effectiveScale,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
startHandlePos = startHandleContentPosition.value,
|
startHandlePos = startHandleContentPosition.value,
|
||||||
endHandlePos = endHandleContentPosition.value,
|
endHandlePos = endHandleContentPosition.value,
|
||||||
|
|
@ -3936,11 +4015,11 @@ private fun PdfBitmapLayer(
|
||||||
targetWidth: Int,
|
targetWidth: Int,
|
||||||
targetHeight: Int,
|
targetHeight: Int,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
isDarkMode: Boolean = false
|
isDarkMode: Boolean = false,
|
||||||
|
excludeImages: Boolean = false,
|
||||||
|
imageRects: List<android.graphics.Rect> = emptyList()
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier
|
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer()) {
|
|
||||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||||
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
clipRect(left = 0f, top = 0f, right = targetWidth.toFloat(), bottom = targetHeight.toFloat()) {
|
||||||
if (bitmapState != null && !bitmapState.isRecycled) {
|
if (bitmapState != null && !bitmapState.isRecycled) {
|
||||||
|
|
@ -3959,6 +4038,32 @@ private fun PdfBitmapLayer(
|
||||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
||||||
|
imageRects.forEach { rect ->
|
||||||
|
val scaleX = bitmapState.width.toFloat() / dstW.toFloat()
|
||||||
|
val scaleY = bitmapState.height.toFloat() / dstH.toFloat()
|
||||||
|
|
||||||
|
val srcRectLeft = (rect.left * scaleX).roundToInt().coerceAtLeast(0)
|
||||||
|
val srcRectTop = (rect.top * scaleY).roundToInt().coerceAtLeast(0)
|
||||||
|
val srcRectRight = (rect.right * scaleX).roundToInt().coerceAtMost(bitmapState.width)
|
||||||
|
val srcRectBottom = (rect.bottom * scaleY).roundToInt().coerceAtMost(bitmapState.height)
|
||||||
|
|
||||||
|
val w = srcRectRight - srcRectLeft
|
||||||
|
val h = srcRectBottom - srcRectTop
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
drawImage(
|
||||||
|
image = bitmapState.asImageBitmap(),
|
||||||
|
srcOffset = IntOffset(srcRectLeft, srcRectTop),
|
||||||
|
srcSize = IntSize(w, h),
|
||||||
|
dstOffset = IntOffset(rect.left, rect.top),
|
||||||
|
dstSize = IntSize(rect.width(), rect.height()),
|
||||||
|
colorFilter = null,
|
||||||
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||||
if (needsTiling) {
|
if (needsTiling) {
|
||||||
tiles.forEach { tile ->
|
tiles.forEach { tile ->
|
||||||
|
|
@ -3968,12 +4073,52 @@ private fun PdfBitmapLayer(
|
||||||
srcOffset = IntOffset.Zero,
|
srcOffset = IntOffset.Zero,
|
||||||
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
|
srcSize = IntSize(tile.bitmap.width, tile.bitmap.height),
|
||||||
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
|
dstOffset = IntOffset(tile.renderRect.left, tile.renderRect.top),
|
||||||
dstSize = IntSize(
|
dstSize = IntSize(tile.renderRect.width(), tile.renderRect.height()),
|
||||||
tile.renderRect.width(), tile.renderRect.height()
|
|
||||||
),
|
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
||||||
|
imageRects.forEach { imgRect ->
|
||||||
|
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
|
||||||
|
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
|
||||||
|
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
|
||||||
|
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
|
||||||
|
|
||||||
|
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
|
||||||
|
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
|
||||||
|
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
|
||||||
|
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
|
||||||
|
|
||||||
|
val iw = intersectRight - intersectLeft
|
||||||
|
val ih = intersectBottom - intersectTop
|
||||||
|
|
||||||
|
if (iw > 0 && ih > 0) {
|
||||||
|
val scaleXBmp = tile.bitmap.width.toFloat() / tile.renderRect.width()
|
||||||
|
val scaleYBmp = tile.bitmap.height.toFloat() / tile.renderRect.height()
|
||||||
|
|
||||||
|
val srcLeft = ((intersectLeft - tile.renderRect.left) * scaleXBmp).roundToInt()
|
||||||
|
val srcTop = ((intersectTop - tile.renderRect.top) * scaleYBmp).roundToInt()
|
||||||
|
val srcRight = ((intersectRight - tile.renderRect.left) * scaleXBmp).roundToInt()
|
||||||
|
val srcBottom = ((intersectBottom - tile.renderRect.top) * scaleYBmp).roundToInt()
|
||||||
|
|
||||||
|
val srcW = srcRight - srcLeft
|
||||||
|
val srcH = srcBottom - srcTop
|
||||||
|
|
||||||
|
if (srcW > 0 && srcH > 0) {
|
||||||
|
drawImage(
|
||||||
|
image = tile.bitmap.asImageBitmap(),
|
||||||
|
srcOffset = IntOffset(srcLeft, srcTop),
|
||||||
|
srcSize = IntSize(srcW, srcH),
|
||||||
|
dstOffset = IntOffset(intersectLeft, intersectTop),
|
||||||
|
dstSize = IntSize(iw, ih),
|
||||||
|
colorFilter = null,
|
||||||
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4497,7 +4642,9 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
|
||||||
targetWidth = data.targetWidth,
|
targetWidth = data.targetWidth,
|
||||||
targetHeight = data.targetHeight,
|
targetHeight = data.targetHeight,
|
||||||
colorFilter = data.colorFilter.item,
|
colorFilter = data.colorFilter.item,
|
||||||
isDarkMode = data.isDarkMode
|
isDarkMode = data.isDarkMode,
|
||||||
|
excludeImages = data.excludeImages,
|
||||||
|
imageRects = data.imageRects.item
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4571,6 +4718,7 @@ private fun PdfPageRenderer(
|
||||||
drawingState: PdfDrawingState?,
|
drawingState: PdfDrawingState?,
|
||||||
onCanvasSizeChanged: (Float, Float) -> Unit,
|
onCanvasSizeChanged: (Float, Float) -> Unit,
|
||||||
scale: Float,
|
scale: Float,
|
||||||
|
uiScale: Float,
|
||||||
offset: Offset,
|
offset: Offset,
|
||||||
startHandlePos: Offset?,
|
startHandlePos: Offset?,
|
||||||
endHandlePos: Offset?,
|
endHandlePos: Offset?,
|
||||||
|
|
@ -4697,6 +4845,12 @@ private fun PdfPageRenderer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (textBoxes.isNotEmpty()) {
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer parent graphicsLayer applied | scale=$scale | offset=$offset | Centering: X=${staticData.centeringOffsetX}, Y=${staticData.centeringOffsetY}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
textBoxes.forEach { box ->
|
textBoxes.forEach { box ->
|
||||||
val isDraggingThisBox = (box.id == draggingBoxId)
|
val isDraggingThisBox = (box.id == draggingBoxId)
|
||||||
val boxAlpha = if (isDraggingThisBox) 0f else 1f
|
val boxAlpha = if (isDraggingThisBox) 0f else 1f
|
||||||
|
|
@ -4707,17 +4861,27 @@ private fun PdfPageRenderer(
|
||||||
isSelected = (box.id == selectedTextBoxId),
|
isSelected = (box.id == selectedTextBoxId),
|
||||||
isEditMode = isEditMode,
|
isEditMode = isEditMode,
|
||||||
isDarkMode = staticData.isDarkMode,
|
isDarkMode = staticData.isDarkMode,
|
||||||
|
scale = uiScale,
|
||||||
pageWidthPx = staticData.targetWidth.toFloat(),
|
pageWidthPx = staticData.targetWidth.toFloat(),
|
||||||
pageHeightPx = staticData.targetHeight.toFloat(),
|
pageHeightPx = staticData.targetHeight.toFloat(),
|
||||||
handlePosition = HandlePosition.AUTO,
|
handlePosition = HandlePosition.AUTO,
|
||||||
onBoundsChanged = { newBounds ->
|
onBoundsChanged = { newBounds ->
|
||||||
|
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onBoundsChanged [ID: ${box.id}] bounds=$newBounds draggingBoxId=$draggingBoxId")
|
||||||
|
if (draggingBoxId != box.id) {
|
||||||
onTextBoxChange(box.copy(relativeBounds = newBounds))
|
onTextBoxChange(box.copy(relativeBounds = newBounds))
|
||||||
|
} else {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onBoundsChanged IGNORED because box[ID: ${box.id}] is being dragged globally")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTextChanged = { newText ->
|
onTextChanged = { newText ->
|
||||||
onTextBoxChange(box.copy(text = newText))
|
onTextBoxChange(box.copy(text = newText))
|
||||||
},
|
},
|
||||||
onSelect = { onTextBoxSelect(box.id) },
|
onSelect = {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onSelect propagated[ID: ${box.id}]")
|
||||||
|
onTextBoxSelect(box.id)
|
||||||
|
},
|
||||||
onDragStart = { touchOffset ->
|
onDragStart = { touchOffset ->
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragStart[ID: ${box.id}] isVerticalScroll=$isVerticalScroll | offset=$touchOffset")
|
||||||
if (isVerticalScroll) {
|
if (isVerticalScroll) {
|
||||||
val topLeft = Offset(
|
val topLeft = Offset(
|
||||||
box.relativeBounds.left * staticData.targetWidth,
|
box.relativeBounds.left * staticData.targetWidth,
|
||||||
|
|
@ -4729,10 +4893,12 @@ private fun PdfPageRenderer(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDrag = { delta, currentBounds ->
|
onDrag = { delta, currentBounds ->
|
||||||
|
Timber.tag("PdfTextBoxDebug").v("PdfPageRenderer onDrag [ID: ${box.id}] delta=$delta currentBounds=$currentBounds scale=$scale")
|
||||||
if (isVerticalScroll) {
|
if (isVerticalScroll) {
|
||||||
onTextBoxDrag(delta)
|
onTextBoxDrag(delta)
|
||||||
} else {
|
} else {
|
||||||
onTextBoxDrag(delta)
|
val scaledDelta = delta * scale
|
||||||
|
onTextBoxDrag(scaledDelta)
|
||||||
|
|
||||||
val width = staticData.targetWidth
|
val width = staticData.targetWidth
|
||||||
val edgeThreshold = 60f
|
val edgeThreshold = 60f
|
||||||
|
|
@ -4746,9 +4912,11 @@ private fun PdfPageRenderer(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragEnd[ID: ${box.id}]")
|
||||||
onTextBoxDragEnd()
|
onTextBoxDragEnd()
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("PdfPageRenderer onDragCancel [ID: ${box.id}]")
|
||||||
onTextBoxDragEnd()
|
onTextBoxDragEnd()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
373
app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
Normal file
373
app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.ReaderTheme
|
||||||
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
|
|
||||||
|
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
||||||
|
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||||
|
internal const val TTS_MODE_KEY = "tts_mode"
|
||||||
|
internal const val DISPLAY_MODE_KEY = "pdf_display_mode"
|
||||||
|
internal const val PDF_DARK_MODE_KEY = "pdf_dark_mode"
|
||||||
|
internal const val OCR_LANGUAGE_KEY = "ocr_language_key"
|
||||||
|
internal const val OCR_LANGUAGE_SELECTED_KEY = "ocr_language_selected_key"
|
||||||
|
internal const val DOCK_LOCATION_KEY = "dock_location"
|
||||||
|
internal const val DOCK_OFFSET_X_KEY = "dock_offset_x"
|
||||||
|
internal const val DOCK_OFFSET_Y_KEY = "dock_offset_y"
|
||||||
|
internal const val PDF_AUTO_SCROLL_SPEED_KEY = "pdf_auto_scroll_speed"
|
||||||
|
internal const val PDF_AUTO_SCROLL_USE_SLIDER_KEY = "pdf_auto_scroll_use_slider"
|
||||||
|
internal const val PDF_AUTO_SCROLL_MIN_SPEED_KEY = "pdf_auto_scroll_min_speed"
|
||||||
|
internal const val PDF_AUTO_SCROLL_MAX_SPEED_KEY = "pdf_auto_scroll_max_speed"
|
||||||
|
internal const val STYLUS_ONLY_MODE_KEY = "stylus_only_mode"
|
||||||
|
|
||||||
|
private const val PDF_AUTO_SCROLL_IS_LOCAL_PREFIX = "pdf_as_local_"
|
||||||
|
private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
|
||||||
|
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
|
||||||
|
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
|
||||||
|
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
|
||||||
|
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
|
||||||
|
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
|
||||||
|
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
|
||||||
|
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
|
||||||
|
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
|
||||||
|
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
||||||
|
private const val PDF_THEME_KEY = "pdf_reader_theme"
|
||||||
|
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
||||||
|
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
||||||
|
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||||
|
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||||
|
|
||||||
|
enum class PdfReaderTool(val title: String, val category: String) {
|
||||||
|
DICTIONARY("External Apps", "Top Bar"),
|
||||||
|
THEME("Theme Settings", "Top Bar"),
|
||||||
|
LOCK_PANNING("Lock Panning", "Top Bar"),
|
||||||
|
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||||
|
FULL_SCREEN("Full Screen", "Top Bar"),
|
||||||
|
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||||
|
TOC("Sidebar", "Bottom Bar"),
|
||||||
|
SEARCH("Search", "Bottom Bar"),
|
||||||
|
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
|
||||||
|
AI_FEATURES("AI Features", "Bottom Bar"),
|
||||||
|
EDIT_MODE("Edit Mode", "Bottom Bar"),
|
||||||
|
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
||||||
|
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||||
|
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||||
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
|
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||||
|
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
|
||||||
|
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||||
|
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||||
|
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
||||||
|
SHARE("Share", "Overflow Menu"),
|
||||||
|
SAVE_COPY("Save Copy", "Overflow Menu"),
|
||||||
|
PRINT("Print", "Overflow Menu")
|
||||||
|
}
|
||||||
|
|
||||||
|
val PdfBuiltInThemes = listOf(
|
||||||
|
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||||
|
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||||
|
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||||
|
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||||
|
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||||
|
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||||
|
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return PdfHighlightColor.entries.associateWith {
|
||||||
|
val defaultArgb = it.color.toArgb()
|
||||||
|
val savedArgb = prefs.getInt("custom_highlight_${it.name}", defaultArgb)
|
||||||
|
Color(savedArgb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveCustomHighlightColors(context: Context, colors: Map<PdfHighlightColor, Color>) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
colors.forEach { (colorEnum, color) ->
|
||||||
|
putInt("custom_highlight_${colorEnum.name}", color.toArgb())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_KEEP_SCREEN_ON_KEY, isEnabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadKeepScreenOn(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_KEEP_SCREEN_ON_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfSystemUiMode(context: Context, mode: SystemUiMode) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putInt(PDF_SYSTEM_UI_MODE_KEY, mode.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfSystemUiMode(context: Context): SystemUiMode {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getInt(PDF_SYSTEM_UI_MODE_KEY, SystemUiMode.SYNC.id)
|
||||||
|
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.SYNC
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfThemeId(context: Context): String {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString(PDF_THEME_KEY, "no_theme") ?: "no_theme"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadUseOnlineDict(context: Context): Boolean {
|
||||||
|
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadExternalDictPackage(context: Context): String? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveExternalDictPackage(context: Context, packageName: String) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadExternalTranslatePackage(context: Context): String? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveExternalTranslatePackage(context: Context, packageName: String) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadExternalSearchPackage(context: Context): String? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveExternalSearchPackage(context: Context, packageName: String) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfMusicianMode(context: Context, isEnabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfMusicianMode(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_MUSICIAN_MODE_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfScrollLocked(context: Context, bookId: String, isLocked: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, isLocked) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfScrollLocked(context: Context, bookId: String): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollLocalMode(context: Context, bookId: String): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollLocalSettings(context: Context, bookId: String, speed: Float, min: Float, max: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, speed)
|
||||||
|
putFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, min)
|
||||||
|
putFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollLocalSettings(context: Context, bookId: String): Triple<Float, Float, Float>? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.contains(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId)) return null
|
||||||
|
|
||||||
|
val speed = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, 3.0f)
|
||||||
|
val min = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, 0.1f)
|
||||||
|
val max = prefs.getFloat(PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, 10.0f)
|
||||||
|
return Triple(speed, min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollMinSpeed(context: Context, speed: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, speed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveStylusOnlyMode(context: Context, isEnabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(STYLUS_ONLY_MODE_KEY, isEnabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadStylusOnlyMode(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(STYLUS_ONLY_MODE_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollMinSpeed(context: Context): Float {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollMaxSpeed(context: Context, speed: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, speed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollMaxSpeed(context: Context): Float {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, 10.0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollUseSlider(context: Context, useSlider: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, useSlider) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollUseSlider(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfAutoScrollSpeed(context: Context, speed: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putFloat(PDF_AUTO_SCROLL_SPEED_KEY, speed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfAutoScrollSpeed(context: Context): Float {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getFloat(PDF_AUTO_SCROLL_SPEED_KEY, 3.0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putFloat("pdf_locked_scale_$bookId", scale)
|
||||||
|
putFloat("pdf_locked_offset_x_$bookId", offsetX)
|
||||||
|
putFloat("pdf_locked_offset_y_$bookId", offsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfLockedState(context: Context, bookId: String): Triple<Float, Float, Float>? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.contains("pdf_locked_scale_$bookId")) return null
|
||||||
|
return Triple(
|
||||||
|
prefs.getFloat("pdf_locked_scale_$bookId", 1f),
|
||||||
|
prefs.getFloat("pdf_locked_offset_x_$bookId", 0f),
|
||||||
|
prefs.getFloat("pdf_locked_offset_y_$bookId", 0f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveOcrLanguage(context: Context, language: OcrLanguage) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putString(OCR_LANGUAGE_KEY, language.name)
|
||||||
|
putBoolean(OCR_LANGUAGE_SELECTED_KEY, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadOcrLanguage(context: Context): OcrLanguage {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val name = prefs.getString(OCR_LANGUAGE_KEY, OcrLanguage.LATIN.name)
|
||||||
|
return try {
|
||||||
|
OcrLanguage.valueOf(name ?: OcrLanguage.LATIN.name)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
OcrLanguage.LATIN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasUserSelectedOcrLanguage(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(OCR_LANGUAGE_SELECTED_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveDockState(context: Context, location: DockLocation, offset: Offset) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putString(DOCK_LOCATION_KEY, location.name)
|
||||||
|
putFloat(DOCK_OFFSET_X_KEY, offset.x)
|
||||||
|
putFloat(DOCK_OFFSET_Y_KEY, offset.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadDockState(context: Context): Pair<DockLocation, Offset> {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val locName = prefs.getString(DOCK_LOCATION_KEY, DockLocation.BOTTOM.name)
|
||||||
|
val location = try {
|
||||||
|
DockLocation.valueOf(locName ?: DockLocation.BOTTOM.name)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
DockLocation.BOTTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
val x = prefs.getFloat(DOCK_OFFSET_X_KEY, 0f)
|
||||||
|
val y = prefs.getFloat(DOCK_OFFSET_Y_KEY, 0f)
|
||||||
|
|
||||||
|
return location to Offset(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveDisplayMode(context: Context, mode: DisplayMode) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(DISPLAY_MODE_KEY, mode.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadDisplayMode(context: Context): DisplayMode {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val modeName = prefs.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name)
|
||||||
|
return try {
|
||||||
|
DisplayMode.valueOf(modeName ?: DisplayMode.VERTICAL_SCROLL.name)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
DisplayMode.VERTICAL_SCROLL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class TtsPageData(
|
||||||
|
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PDF_DARK_MODE_KEY, isDark) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfDarkMode(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
|
||||||
|
}
|
||||||
236
app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
Normal file
236
app/src/main/java/com/aryan/reader/pdf/PdfSearchUI.kt
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.itemContentType
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.SearchResult
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SearchNavigationPill(
|
||||||
|
text: String,
|
||||||
|
mode: SearchHighlightMode,
|
||||||
|
onToggleMode: () -> Unit,
|
||||||
|
onPrev: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onTextClick: () -> Unit,
|
||||||
|
isPrevEnabled: Boolean = true,
|
||||||
|
isNextEnabled: Boolean = true
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.shadow(6.dp, RoundedCornerShape(50))
|
||||||
|
.height(56.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
// Toggle Mode
|
||||||
|
IconButton(onClick = onToggleMode) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (mode == SearchHighlightMode.ALL) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = "Toggle Highlights",
|
||||||
|
tint = if (mode == SearchHighlightMode.ALL) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical Divider
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.height(24.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prev
|
||||||
|
IconButton(onClick = onPrev, enabled = isPrevEnabled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowUp,
|
||||||
|
contentDescription = "Previous",
|
||||||
|
tint = if (isPrevEnabled) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter/Text
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onTextClick
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next
|
||||||
|
IconButton(onClick = onNext, enabled = isNextEnabled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = "Next",
|
||||||
|
tint = if (isNextEnabled) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfSearchResultsPanel(
|
||||||
|
lazyResults: LazyPagingItems<SearchResult>,
|
||||||
|
totalPageCount: Int,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
if (lazyResults.itemCount == 0 && lazyResults.loadState.refresh !is LoadState.Loading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.msg_results_found_pages),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||||
|
items(count = lazyResults.itemCount, key = lazyResults.itemKey {
|
||||||
|
"${it.locationInSource}_${it.occurrenceIndexInLocation}"
|
||||||
|
}, contentType = lazyResults.itemContentType { "SearchResult" }) { index ->
|
||||||
|
val result = lazyResults[index]
|
||||||
|
if (result != null) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
result.locationTitle,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}, supportingContent = {
|
||||||
|
Text(
|
||||||
|
result.snippet, style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}, modifier = Modifier
|
||||||
|
.testTag(
|
||||||
|
"SearchResultItem_${result.locationInSource}"
|
||||||
|
)
|
||||||
|
.clickable { onResultClick(result) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lazyResults.loadState.refresh is LoadState.Loading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfSearchResultsList(
|
||||||
|
results: List<SearchResult>,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(stringResource(R.string.search_no_results_simple), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "${results.size} matches found",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||||
|
itemsIndexed(results) { _, result ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
result.locationTitle,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}, supportingContent = {
|
||||||
|
Text(
|
||||||
|
result.snippet, style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}, modifier = Modifier
|
||||||
|
.testTag(
|
||||||
|
"SearchResultItem_${result.locationInSource}"
|
||||||
|
)
|
||||||
|
.clickable { onResultClick(result) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
Normal file
153
app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||||
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfCustomizeToolsSheet(
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
onUpdate: (Set<String>) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
contentWindowInsets = { WindowInsets.navigationBars }
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.title_customize_toolbar),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.desc_customize_toolbar),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(tools) { tool ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable {
|
||||||
|
val newSet = hiddenTools.toMutableSet()
|
||||||
|
if (newSet.contains(tool.name)) newSet.remove(tool.name)
|
||||||
|
else newSet.add(tool.name)
|
||||||
|
onUpdate(newSet)
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = tool.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = !hiddenTools.contains(tool.name),
|
||||||
|
onCheckedChange = { isVisible ->
|
||||||
|
val newSet = hiddenTools.toMutableSet()
|
||||||
|
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
|
||||||
|
onUpdate(newSet)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfVisualOptionsSheet(
|
||||||
|
systemUiMode: SystemUiMode,
|
||||||
|
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentWindowInsets = { WindowInsets.navigationBars }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OptionSegmentedControl(
|
||||||
|
options = SystemUiMode.entries,
|
||||||
|
selectedOption = systemUiMode,
|
||||||
|
onOptionSelected = onSystemUiModeChange,
|
||||||
|
getLabel = { it.title }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,8 @@ package com.aryan.reader.pdf
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -52,7 +53,12 @@ import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
|
import androidx.compose.ui.input.pointer.changedToUp
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
@ -78,6 +84,49 @@ enum class HandlePosition {
|
||||||
TOP, BOTTOM, AUTO
|
TOP, BOTTOM, AUTO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
|
||||||
|
suspend fun PointerInputScope.detectEagerDragGestures(
|
||||||
|
onDragStart: (Offset) -> Unit,
|
||||||
|
onDragEnd: () -> Unit,
|
||||||
|
onDragCancel: () -> Unit,
|
||||||
|
onDrag: (PointerInputChange, Offset) -> Unit
|
||||||
|
) {
|
||||||
|
awaitEachGesture {
|
||||||
|
var dragStarted = false
|
||||||
|
try {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
down.consume() // Consume immediately
|
||||||
|
onDragStart(down.position)
|
||||||
|
dragStarted = true
|
||||||
|
val pointerId = down.id
|
||||||
|
var canceled = false
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||||
|
if (change == null) {
|
||||||
|
canceled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (change.changedToUp()) {
|
||||||
|
change.consume()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (change.positionChanged()) {
|
||||||
|
val dragAmount = change.position - change.previousPosition
|
||||||
|
change.consume()
|
||||||
|
onDrag(change, dragAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canceled) onDragCancel() else onDragEnd()
|
||||||
|
dragStarted = false
|
||||||
|
} finally {
|
||||||
|
if (dragStarted) {
|
||||||
|
onDragCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ResizableTextBox(
|
fun ResizableTextBox(
|
||||||
box: PdfTextBox,
|
box: PdfTextBox,
|
||||||
|
|
@ -86,6 +135,7 @@ fun ResizableTextBox(
|
||||||
isDarkMode: Boolean,
|
isDarkMode: Boolean,
|
||||||
pageWidthPx: Float,
|
pageWidthPx: Float,
|
||||||
pageHeightPx: Float,
|
pageHeightPx: Float,
|
||||||
|
scale: Float = 1f,
|
||||||
onBoundsChanged: (Rect) -> Unit,
|
onBoundsChanged: (Rect) -> Unit,
|
||||||
onTextChanged: (String) -> Unit,
|
onTextChanged: (String) -> Unit,
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
|
|
@ -101,8 +151,9 @@ fun ResizableTextBox(
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val handleSize = 10.dp
|
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
|
||||||
val handleTouchSize = 40.dp
|
val handleSize = (10f / scale).dp
|
||||||
|
val handleTouchSize = (40f / scale).dp
|
||||||
val handleSizePx = with(density) { handleSize.toPx() }
|
val handleSizePx = with(density) { handleSize.toPx() }
|
||||||
val halfHandlePx = handleSizePx / 2f
|
val halfHandlePx = handleSizePx / 2f
|
||||||
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
|
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
|
||||||
|
|
@ -126,7 +177,9 @@ fun ResizableTextBox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
Timber.tag("PdfTextBoxDebug").v("ResizableTextBox Recompose [ID: ${box.id}] | isSelected=$isSelected | scale=$scale | pagePx=${pageWidthPx}x${pageHeightPx} | bounds=${box.relativeBounds}")
|
||||||
|
}
|
||||||
var currentRectPx by remember {
|
var currentRectPx by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
Rect(
|
Rect(
|
||||||
|
|
@ -152,6 +205,8 @@ fun ResizableTextBox(
|
||||||
right = box.relativeBounds.right * pageWidthPx,
|
right = box.relativeBounds.right * pageWidthPx,
|
||||||
bottom = box.relativeBounds.bottom * pageHeightPx
|
bottom = box.relativeBounds.bottom * pageHeightPx
|
||||||
)
|
)
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("LaunchedEffect bounds recalculation [ID: ${box.id}] | currentRectPx=$newPx")
|
||||||
|
|
||||||
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
|
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
|
||||||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
|
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
|
||||||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
|
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
|
||||||
|
|
@ -162,20 +217,19 @@ fun ResizableTextBox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
|
val requiredBottomSpacePx = with(density) { 60.dp.toPx() } / scale
|
||||||
|
|
||||||
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
|
// Freeze handle position while dragging to prevent UI jumping
|
||||||
derivedStateOf {
|
var isHandleAtTop by remember { mutableStateOf(false) }
|
||||||
when (handlePosition) {
|
|
||||||
|
LaunchedEffect(currentRectPx, pageHeightPx, handlePosition, requiredBottomSpacePx, isDraggingOrResizing) {
|
||||||
|
if (!isDraggingOrResizing) {
|
||||||
|
isHandleAtTop = when (handlePosition) {
|
||||||
HandlePosition.TOP -> true
|
HandlePosition.TOP -> true
|
||||||
HandlePosition.BOTTOM -> false
|
HandlePosition.BOTTOM -> false
|
||||||
HandlePosition.AUTO -> {
|
HandlePosition.AUTO -> {
|
||||||
if (pageHeightPx <= 0f) {
|
if (pageHeightPx <= 0f) false
|
||||||
false
|
else (pageHeightPx - currentRectPx.bottom) < requiredBottomSpacePx
|
||||||
} else {
|
|
||||||
val spaceBelow = pageHeightPx - currentRectPx.bottom
|
|
||||||
spaceBelow < requiredBottomSpacePx
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,11 +238,9 @@ fun ResizableTextBox(
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.zIndex(if (isSelected) 10f else 0f)
|
.zIndex(if (isSelected) 10f else 0f)
|
||||||
.offset {
|
.graphicsLayer {
|
||||||
IntOffset(
|
translationX = currentRectPx.left - halfHandlePx
|
||||||
(currentRectPx.left - halfHandlePx).roundToInt(),
|
translationY = currentRectPx.top - halfHandlePx
|
||||||
(currentRectPx.top - halfHandlePx).roundToInt()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.size(
|
.size(
|
||||||
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
||||||
|
|
@ -201,10 +253,13 @@ fun ResizableTextBox(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(handleSize / 2)
|
.padding(handleSize / 2)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures { onSelect() }
|
detectTapGestures {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
|
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
|
|
@ -220,7 +275,7 @@ fun ResizableTextBox(
|
||||||
background = box.backgroundColor,
|
background = box.backgroundColor,
|
||||||
fontFamily = fontFamily,
|
fontFamily = fontFamily,
|
||||||
fontSize = with(LocalDensity.current) {
|
fontSize = with(LocalDensity.current) {
|
||||||
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
|
(box.fontSize * pageHeightPx).toSp()
|
||||||
},
|
},
|
||||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
|
@ -267,8 +322,11 @@ fun ResizableTextBox(
|
||||||
}
|
}
|
||||||
.size(handleTouchSize)
|
.size(handleTouchSize)
|
||||||
.pointerInput(onBoundsChanged) {
|
.pointerInput(onBoundsChanged) {
|
||||||
detectDragGestures(
|
detectEagerDragGestures(
|
||||||
onDragStart = { isDraggingOrResizing = true },
|
onDragStart = {
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||||
|
isDraggingOrResizing = true
|
||||||
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
isDraggingOrResizing = false
|
isDraggingOrResizing = false
|
||||||
val normalized = Rect(
|
val normalized = Rect(
|
||||||
|
|
@ -277,40 +335,42 @@ fun ResizableTextBox(
|
||||||
right = currentRectPx.right / pageWidthPx,
|
right = currentRectPx.right / pageWidthPx,
|
||||||
bottom = currentRectPx.bottom / pageHeightPx
|
bottom = currentRectPx.bottom / pageHeightPx
|
||||||
)
|
)
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||||
onBoundsChanged(normalized)
|
onBoundsChanged(normalized)
|
||||||
},
|
},
|
||||||
onDragCancel = { isDraggingOrResizing = false }
|
onDragCancel = { isDraggingOrResizing = false }
|
||||||
) { change, dragAmount ->
|
) { change, dragAmount ->
|
||||||
change.consume()
|
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||||
|
|
||||||
var l = currentRectPx.left
|
var l = currentRectPx.left
|
||||||
var t = currentRectPx.top
|
var t = currentRectPx.top
|
||||||
var r = currentRectPx.right
|
var r = currentRectPx.right
|
||||||
var b = currentRectPx.bottom
|
var b = currentRectPx.bottom
|
||||||
val dx = dragAmount.x
|
val dx = dragAmount.x
|
||||||
val dy = dragAmount.y
|
val dy = dragAmount.y
|
||||||
val minSize = 50f
|
val minSize = 50f / scale
|
||||||
|
|
||||||
when (handle) {
|
when (handle) {
|
||||||
ResizeHandle.TOP_LEFT -> {
|
ResizeHandle.TOP_LEFT -> {
|
||||||
l = (l + dx).coerceIn(0f, r - minSize)
|
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||||
t = (t + dy).coerceIn(0f, b - minSize)
|
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||||
}
|
}
|
||||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
|
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||||
ResizeHandle.TOP_RIGHT -> {
|
ResizeHandle.TOP_RIGHT -> {
|
||||||
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||||
t = (t + dy).coerceIn(0f, b - minSize)
|
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||||
}
|
}
|
||||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||||
ResizeHandle.BOTTOM_RIGHT -> {
|
ResizeHandle.BOTTOM_RIGHT -> {
|
||||||
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||||
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||||
}
|
}
|
||||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||||
ResizeHandle.BOTTOM_LEFT -> {
|
ResizeHandle.BOTTOM_LEFT -> {
|
||||||
l = (l + dx).coerceIn(0f, r - minSize)
|
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||||
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||||
}
|
}
|
||||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
|
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
currentRectPx = Rect(l, t, r, b)
|
currentRectPx = Rect(l, t, r, b)
|
||||||
|
|
@ -328,13 +388,15 @@ fun ResizableTextBox(
|
||||||
|
|
||||||
DragPill(
|
DragPill(
|
||||||
isDarkMode = isDarkMode,
|
isDarkMode = isDarkMode,
|
||||||
|
scale = scale,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
||||||
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
|
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
|
||||||
.zIndex(20f)
|
.zIndex(20f)
|
||||||
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
||||||
detectDragGestures(
|
detectEagerDragGestures(
|
||||||
onDragStart = { offset ->
|
onDragStart = { offset ->
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
|
||||||
isDraggingOrResizing = true
|
isDraggingOrResizing = true
|
||||||
onDragStart(offset)
|
onDragStart(offset)
|
||||||
},
|
},
|
||||||
|
|
@ -346,6 +408,7 @@ fun ResizableTextBox(
|
||||||
right = currentRectPx.right / pageWidthPx,
|
right = currentRectPx.right / pageWidthPx,
|
||||||
bottom = currentRectPx.bottom / pageHeightPx
|
bottom = currentRectPx.bottom / pageHeightPx
|
||||||
)
|
)
|
||||||
|
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
|
||||||
onBoundsChanged(normalized)
|
onBoundsChanged(normalized)
|
||||||
onDragEnd()
|
onDragEnd()
|
||||||
},
|
},
|
||||||
|
|
@ -354,13 +417,12 @@ fun ResizableTextBox(
|
||||||
onDragCancel()
|
onDragCancel()
|
||||||
}
|
}
|
||||||
) { change, dragAmount ->
|
) { change, dragAmount ->
|
||||||
change.consume()
|
|
||||||
val w = currentRectPx.width
|
val w = currentRectPx.width
|
||||||
val h = currentRectPx.height
|
val h = currentRectPx.height
|
||||||
val rawLeft = currentRectPx.left + dragAmount.x
|
val rawLeft = currentRectPx.left + dragAmount.x
|
||||||
val rawTop = currentRectPx.top + dragAmount.y
|
val rawTop = currentRectPx.top + dragAmount.y
|
||||||
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
|
val newLeft = rawLeft.coerceIn(0f, maxOf(0f, pageWidthPx - w))
|
||||||
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
|
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
|
||||||
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
||||||
currentRectPx = newRect
|
currentRectPx = newRect
|
||||||
onDrag(dragAmount, newRect)
|
onDrag(dragAmount, newRect)
|
||||||
|
|
@ -374,21 +436,22 @@ fun ResizableTextBox(
|
||||||
@Composable
|
@Composable
|
||||||
private fun DragPill(
|
private fun DragPill(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDarkMode: Boolean
|
isDarkMode: Boolean,
|
||||||
|
scale: Float = 1f
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(width = 48.dp, height = 24.dp),
|
.size(width = (48f / scale).dp, height = (24f / scale).dp),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = if (isDarkMode) Color.White else Color.Black,
|
color = if (isDarkMode) Color.White else Color.Black,
|
||||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||||
shadowElevation = 4.dp
|
shadowElevation = (4f / scale).dp
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.drag_handle),
|
painter = painterResource(id = R.drawable.drag_handle),
|
||||||
contentDescription = "Drag to move text box",
|
contentDescription = "Drag to move text box",
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size((20f / scale).dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
229
app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt
Normal file
229
app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legere.pdfiumandroid.api.Bookmark
|
||||||
|
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||||
|
import org.json.JSONArray
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
private const val MAX_FIXED_RECURSION = 128
|
||||||
|
|
||||||
|
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||||
|
|
||||||
|
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||||
|
*/
|
||||||
|
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||||
|
val tag = "PdfTocFix"
|
||||||
|
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||||
|
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||||
|
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||||
|
|
||||||
|
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||||
|
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||||
|
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||||
|
|
||||||
|
// 3. Get the native pointer (long) from PdfDocumentU
|
||||||
|
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||||
|
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||||
|
|
||||||
|
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||||
|
val nClass = nativeDocInstance.javaClass
|
||||||
|
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||||
|
|
||||||
|
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||||
|
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||||
|
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||||
|
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||||
|
|
||||||
|
val topLevel = mutableListOf<Bookmark>()
|
||||||
|
val visited = mutableSetOf<Long>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||||
|
*/
|
||||||
|
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||||
|
var currentPtr = startPtr
|
||||||
|
var itemIndex = 0
|
||||||
|
|
||||||
|
while (currentPtr != 0L) {
|
||||||
|
if (visited.contains(currentPtr)) break
|
||||||
|
visited.add(currentPtr)
|
||||||
|
|
||||||
|
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||||
|
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||||
|
|
||||||
|
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||||
|
|
||||||
|
val bookmark = Bookmark().apply {
|
||||||
|
this.mNativePtr = currentPtr
|
||||||
|
this.title = title
|
||||||
|
this.pageIdx = pageIdx
|
||||||
|
}
|
||||||
|
parentList.add(bookmark)
|
||||||
|
|
||||||
|
// Recursive dive into children
|
||||||
|
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||||
|
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||||
|
walk(bookmark.children, firstChild, level + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative move to next sibling
|
||||||
|
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||||
|
itemIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Start from the root (Pass 0L as primitive long)
|
||||||
|
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||||
|
if (firstRoot != 0L) {
|
||||||
|
walk(topLevel, firstRoot, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topLevel.isEmpty()) {
|
||||||
|
Timber.tag(tag).w("No items found, falling back to library.")
|
||||||
|
getTableOfContents()
|
||||||
|
} else {
|
||||||
|
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||||
|
topLevel
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||||
|
this.getTableOfContents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||||
|
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||||
|
val entries = mutableListOf<TocEntry>()
|
||||||
|
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||||
|
val title = bookmark.title ?: "Untitled Chapter"
|
||||||
|
val childCount = bookmark.children.size
|
||||||
|
|
||||||
|
Timber.tag("PdfTocDebug").d(
|
||||||
|
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.add(
|
||||||
|
TocEntry(
|
||||||
|
title = title,
|
||||||
|
pageIndex = bookmark.pageIdx.toInt(),
|
||||||
|
nestLevel = level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (childCount > 0) {
|
||||||
|
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||||
|
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||||
|
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||||
|
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val jsonArray = JSONArray(bookmarksJson)
|
||||||
|
(0 until jsonArray.length()).mapNotNull { i ->
|
||||||
|
try {
|
||||||
|
val json = jsonArray.getJSONObject(i)
|
||||||
|
PdfBookmark(
|
||||||
|
pageIndex = json.getInt("pageIndex"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
totalPages = json.getInt("totalPages")
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PdfTocTreeItem(
|
||||||
|
label: String,
|
||||||
|
nestLevel: Int,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
hasChildren: Boolean,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
onToggleExpand: () -> Unit,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||||
|
label = "TocItemBackground"
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (hasChildren) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
541
app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
Normal file
541
app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
// PdfToolbars.kt
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.FileType
|
||||||
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.SearchState
|
||||||
|
import com.aryan.reader.SearchTopBar
|
||||||
|
import com.aryan.reader.TooltipIconButton
|
||||||
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
|
import kotlin.collections.isNotEmpty
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun PdfTopBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showStandardBars: Boolean,
|
||||||
|
systemUiMode: SystemUiMode,
|
||||||
|
statusBarHeightDp: Dp,
|
||||||
|
searchState: SearchState,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onCloseSearch: () -> Unit,
|
||||||
|
isLoadingDocument: Boolean,
|
||||||
|
errorMessage: String?,
|
||||||
|
currentPageForDisplay: Int,
|
||||||
|
totalPages: Int,
|
||||||
|
pagerStatePageCount: Int,
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
isScrollLocked: Boolean,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
displayMode: DisplayMode,
|
||||||
|
isKeepScreenOn: Boolean,
|
||||||
|
isTtsSessionActive: Boolean,
|
||||||
|
isBookmarked: Boolean,
|
||||||
|
canDeletePage: Boolean,
|
||||||
|
isReflowingThisBook: Boolean,
|
||||||
|
hasReflowFile: Boolean,
|
||||||
|
isPdfDocumentLoaded: Boolean,
|
||||||
|
isTabsEnabled: Boolean,
|
||||||
|
openTabs: List<RecentFileItem>,
|
||||||
|
activeTabBookId: String?,
|
||||||
|
effectiveFileType: FileType,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onShowThemePanel: () -> Unit,
|
||||||
|
onToggleScrollLock: () -> Unit,
|
||||||
|
onShowDictionarySettings: () -> Unit,
|
||||||
|
onShowPenPlayground: () -> Unit,
|
||||||
|
onImportSvg: () -> Unit,
|
||||||
|
onShowCustomizeTools: () -> Unit,
|
||||||
|
onShowOcrLanguage: () -> Unit,
|
||||||
|
onShowVisualOptions: () -> Unit,
|
||||||
|
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||||
|
onToggleKeepScreenOn: () -> Unit,
|
||||||
|
onStartAutoScroll: () -> Unit,
|
||||||
|
onShowTtsSettings: () -> Unit,
|
||||||
|
onToggleBookmark: () -> Unit,
|
||||||
|
onInsertPage: () -> Unit,
|
||||||
|
onDeletePage: () -> Unit,
|
||||||
|
onReflowAction: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onSaveCopy: () -> Unit,
|
||||||
|
onPrint: () -> Unit,
|
||||||
|
onTabClick: (String) -> Unit,
|
||||||
|
onTabClose: (String) -> Unit,
|
||||||
|
onNewTabClick: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showStandardBars,
|
||||||
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||||
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> -fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
val isStatusBarVisible = when (systemUiMode) {
|
||||||
|
SystemUiMode.DEFAULT -> true
|
||||||
|
SystemUiMode.SYNC -> showStandardBars
|
||||||
|
SystemUiMode.HIDDEN -> false
|
||||||
|
}
|
||||||
|
val topBarPadding = if (isStatusBarVisible) statusBarHeightDp else 0.dp
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(top = topBarPadding)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (searchState.isSearchActive) {
|
||||||
|
SearchTopBar(
|
||||||
|
searchState = searchState,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
onCloseSearch = onCloseSearch
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_back),
|
||||||
|
description = stringResource(R.string.tooltip_back_desc),
|
||||||
|
onClick = onNavigateBack
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
val titleText = when {
|
||||||
|
isLoadingDocument -> stringResource(R.string.loading_pdf)
|
||||||
|
errorMessage != null -> stringResource(R.string.error_loading_pdf)
|
||||||
|
totalPages > 0 && pagerStatePageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages"
|
||||||
|
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
||||||
|
else -> "PDF Viewer"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = titleText,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_theme),
|
||||||
|
description = stringResource(R.string.tooltip_theme_desc),
|
||||||
|
onClick = onShowThemePanel
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
||||||
|
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
||||||
|
onClick = onToggleScrollLock
|
||||||
|
) {
|
||||||
|
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_dictionary),
|
||||||
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||||
|
onClick = onShowDictionarySettings
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary Settings", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
||||||
|
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
|
||||||
|
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box {
|
||||||
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_more_options),
|
||||||
|
description = stringResource(R.string.tooltip_more_options_desc),
|
||||||
|
onClick = { showMoreMenu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||||
|
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||||
|
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||||
|
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||||
|
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = { onChangeDisplayMode(DisplayMode.PAGINATION); showMoreMenu = false },
|
||||||
|
trailingIcon = { if (displayMode == DisplayMode.PAGINATION) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||||
|
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||||
|
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||||
|
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||||
|
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||||
|
enabled = !isTtsSessionActive,
|
||||||
|
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||||
|
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||||
|
onClick = { showMoreMenu = false; onInsertPage() }
|
||||||
|
)
|
||||||
|
if (canDeletePage) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||||
|
onClick = { showMoreMenu = false; onDeletePage() },
|
||||||
|
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_reflow_progress); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||||
|
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||||
|
onClick = { showMoreMenu = false; onReflowAction() },
|
||||||
|
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_share)) },
|
||||||
|
onClick = { showMoreMenu = false; onShare() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||||
|
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_print)) },
|
||||||
|
onClick = { showMoreMenu = false; onPrint() },
|
||||||
|
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTabsEnabled && openTabs.isNotEmpty() && effectiveFileType == FileType.PDF) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
items(openTabs, key = { it.bookId }) { tab ->
|
||||||
|
val isSelected = tab.bookId == activeTabBookId
|
||||||
|
val bgColor = if (isSelected) MaterialTheme.colorScheme.surface else Color.Transparent
|
||||||
|
val contentColor = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(if (isSelected) 44.dp else 36.dp)
|
||||||
|
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable { onTabClick(tab.bookId) }
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.widthIn(max = 140.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = contentColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = { onTabClose(tab.bookId) },
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close_tab), modifier = Modifier.size(16.dp), tint = contentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
IconButton(onClick = onNewTabClick, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp).size(36.dp)) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "New Tab", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReflowProgressOverlay(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showStandardBars: Boolean,
|
||||||
|
isReflowingThisBook: Boolean,
|
||||||
|
reflowProgressValue: Float
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showStandardBars && isReflowingThisBook,
|
||||||
|
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)),
|
||||||
|
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(animationSpec = tween(200)),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp),
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.generating_text_view),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${(reflowProgressValue * 100).toInt()}%",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { reflowProgressValue },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfBottomBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showStandardBars: Boolean,
|
||||||
|
searchStateActive: Boolean,
|
||||||
|
systemUiMode: SystemUiMode,
|
||||||
|
navBarHeightDp: Dp,
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
isTtsPlayingOrLoading: Boolean,
|
||||||
|
showAllTextHighlights: Boolean,
|
||||||
|
isHighlightingLoading: Boolean,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
isTtsSessionActive: Boolean,
|
||||||
|
ttsErrorMessage: String?,
|
||||||
|
onShowSlider: () -> Unit,
|
||||||
|
onShowToc: () -> Unit,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
onToggleHighlights: () -> Unit,
|
||||||
|
onShowAiHub: () -> Unit,
|
||||||
|
onToggleEditMode: () -> Unit,
|
||||||
|
onToggleTts: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showStandardBars && !searchStateActive,
|
||||||
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||||
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
val isNavBarVisible = systemUiMode != SystemUiMode.HIDDEN
|
||||||
|
val bottomBarPadding = if (isNavBarVisible) navBarHeightDp else 0.dp
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp) {
|
||||||
|
val bottomBarScrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_slider),
|
||||||
|
description = stringResource(R.string.tooltip_slider_desc),
|
||||||
|
onClick = onShowSlider,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_toc),
|
||||||
|
description = stringResource(R.string.tooltip_toc_desc),
|
||||||
|
onClick = onShowToc,
|
||||||
|
enabled = !isTtsPlayingOrLoading,
|
||||||
|
modifier = Modifier.testTag("TocButton")
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Menu, contentDescription = "Table of Contents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_search),
|
||||||
|
description = stringResource(R.string.tooltip_search_desc),
|
||||||
|
onClick = onSearchClick,
|
||||||
|
enabled = !isTtsPlayingOrLoading,
|
||||||
|
modifier = Modifier.testTag("SearchButton")
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
||||||
|
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
||||||
|
onClick = onToggleHighlights
|
||||||
|
) {
|
||||||
|
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = "Highlight all text", tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_ai),
|
||||||
|
description = stringResource(R.string.tooltip_ai_desc),
|
||||||
|
onClick = onShowAiHub
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
||||||
|
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
||||||
|
onClick = onToggleEditMode
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Toggle Editing Mode", tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
||||||
|
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
||||||
|
onClick = onToggleTts
|
||||||
|
) {
|
||||||
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS", tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ttsErrorMessage?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -184,11 +184,12 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfVerticalReader(
|
internal fun PdfVerticalReader(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
state: VerticalPdfReaderState,
|
state: VerticalPdfReaderState,
|
||||||
pdfDocument: StableHolder<ReaderDocument>,
|
pdfDocument: StableHolder<ReaderDocument>,
|
||||||
activeTheme: com.aryan.reader.ReaderTheme,
|
activeTheme: com.aryan.reader.ReaderTheme,
|
||||||
|
excludeImages: Boolean = false,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
virtualPages: List<VirtualPage> = emptyList(),
|
virtualPages: List<VirtualPage> = emptyList(),
|
||||||
pageAspectRatios: StableHolder<List<Float>>,
|
pageAspectRatios: StableHolder<List<Float>>,
|
||||||
headerHeight: Dp,
|
headerHeight: Dp,
|
||||||
|
|
@ -284,6 +285,11 @@ internal fun PdfVerticalReader(
|
||||||
val dividerHeightDp = 8.dp
|
val dividerHeightDp = 8.dp
|
||||||
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
|
||||||
|
|
||||||
|
var isFlinging by remember { mutableStateOf(false) }
|
||||||
|
var isFastFlinging by remember { mutableStateOf(false) }
|
||||||
|
var isInteracting by remember { mutableStateOf(false) }
|
||||||
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
val layoutState = remember(ratios, screenWidth, screenHeight, density) {
|
||||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||||
|
|
||||||
|
|
@ -373,8 +379,41 @@ internal fun PdfVerticalReader(
|
||||||
var isInitialLayout by remember { mutableStateOf(true) }
|
var isInitialLayout by remember { mutableStateOf(true) }
|
||||||
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
|
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
|
||||||
|
|
||||||
|
var hasRestoredLockedState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(isScrollLocked, lockedState, totalDocHeight, screenWidth, isInteracting) {
|
||||||
|
if (!hasRestoredLockedState && isScrollLocked && lockedState != null && totalDocHeight > 0f && screenWidth > 0f && !isInteracting) {
|
||||||
|
val (savedScale, savedPanX, savedPanY) = lockedState
|
||||||
|
|
||||||
|
Timber.tag("PdfLockDiagnostic").i("RESTORING: Scale=$savedScale, X=$savedPanX, Y=$savedPanY")
|
||||||
|
|
||||||
|
val zoomedDocWidth = screenWidth * savedScale
|
||||||
|
val minPanX = if (zoomedDocWidth < screenWidth) (screenWidth - zoomedDocWidth) / 2f else -(zoomedDocWidth - screenWidth)
|
||||||
|
val maxPanX = if (zoomedDocWidth < screenWidth) minPanX else 0f
|
||||||
|
|
||||||
|
val zoomedDocHeight = totalDocHeight * savedScale
|
||||||
|
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||||
|
val maxPanY = headerHeightPx
|
||||||
|
|
||||||
|
zoomAnimatable.stop()
|
||||||
|
panXAnimatable.stop()
|
||||||
|
panYAnimatable.stop()
|
||||||
|
|
||||||
|
panXAnimatable.updateBounds(minPanX, maxPanX)
|
||||||
|
panYAnimatable.updateBounds(minPanY, maxPanY)
|
||||||
|
|
||||||
|
zoomAnimatable.snapTo(savedScale)
|
||||||
|
panXAnimatable.snapTo(savedPanX)
|
||||||
|
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
|
||||||
|
|
||||||
|
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
|
||||||
|
|
||||||
|
hasRestoredLockedState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(layoutState.pages) {
|
LaunchedEffect(layoutState.pages) {
|
||||||
if (!isInitialLayout) {
|
if (!isInitialLayout && !isScrollLocked) {
|
||||||
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
val targetPageIdx = if (targetPageDuringResize.intValue != -1) {
|
||||||
targetPageDuringResize.intValue
|
targetPageDuringResize.intValue
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -410,21 +449,13 @@ internal fun PdfVerticalReader(
|
||||||
launch { panXAnimatable.snapTo(targetPanX) }
|
launch { panXAnimatable.snapTo(targetPanX) }
|
||||||
launch { panYAnimatable.snapTo(finalPanY) }
|
launch { panYAnimatable.snapTo(finalPanY) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
|
||||||
state.currentPage = targetPageIdx
|
|
||||||
if (isFit) onZoomChange(targetZoom)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isInitialLayout) {
|
||||||
delay(50)
|
delay(50)
|
||||||
isResizing = false
|
isResizing = false
|
||||||
targetPageDuringResize.intValue = -1
|
targetPageDuringResize.intValue = -1
|
||||||
} else if (isScrollLocked && lockedState != null) {
|
|
||||||
val (savedScale, savedPanX, _) = lockedState
|
|
||||||
coroutineScope {
|
|
||||||
launch { zoomAnimatable.snapTo(savedScale) }
|
|
||||||
launch { panXAnimatable.snapTo(savedPanX) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isInitialLayout = false
|
isInitialLayout = false
|
||||||
}
|
}
|
||||||
|
|
@ -461,11 +492,6 @@ internal fun PdfVerticalReader(
|
||||||
return clampValues(targetZoom, targetPanX, targetPanY)
|
return clampValues(targetZoom, targetPanX, targetPanY)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isFlinging by remember { mutableStateOf(false) }
|
|
||||||
var isFastFlinging by remember { mutableStateOf(false) }
|
|
||||||
var isInteracting by remember { mutableStateOf(false) }
|
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
|
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
|
||||||
) {
|
) {
|
||||||
|
|
@ -483,6 +509,9 @@ internal fun PdfVerticalReader(
|
||||||
panYAnimatable.snapTo(y)
|
panYAnimatable.snapTo(y)
|
||||||
}
|
}
|
||||||
if (x != currentPanX) {
|
if (x != currentPanX) {
|
||||||
|
if (isScrollLocked) {
|
||||||
|
Timber.tag("PdfLockDiagnostic").d("FORCED SNAP: X=$currentPanX to $x")
|
||||||
|
}
|
||||||
panXAnimatable.snapTo(x)
|
panXAnimatable.snapTo(x)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -585,8 +614,8 @@ internal fun PdfVerticalReader(
|
||||||
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
|
val dist = (draggingBoxOffset.y - topEdge).coerceAtMost(scrollZone)
|
||||||
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
||||||
scrollDelta = -15f * ratio
|
scrollDelta = -15f * ratio
|
||||||
} else if (draggingBoxOffset.y + draggingBoxSize.height > bottomEdge - scrollZone) {
|
} else if (draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value) > bottomEdge - scrollZone) {
|
||||||
val boxBottom = draggingBoxOffset.y + draggingBoxSize.height
|
val boxBottom = draggingBoxOffset.y + (draggingBoxSize.height * zoomAnimatable.value)
|
||||||
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
|
val dist = (bottomEdge - boxBottom).coerceAtMost(scrollZone)
|
||||||
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
val ratio = 1f - (dist / scrollZone).coerceIn(0f, 1f)
|
||||||
scrollDelta = 15f * ratio
|
scrollDelta = 15f * ratio
|
||||||
|
|
@ -744,6 +773,9 @@ internal fun PdfVerticalReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAnimating) {
|
if (!isAnimating) {
|
||||||
|
if (isScrollLocked) {
|
||||||
|
Timber.tag("PdfLockDiagnostic").v("CLAMP CHECK: X=${panXAnimatable.value} | Allowed Range=[$minPanX, $maxPanX]")
|
||||||
|
}
|
||||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
||||||
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
|
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1575,6 +1607,7 @@ internal fun PdfVerticalReader(
|
||||||
virtualPage = virtualPage,
|
virtualPage = virtualPage,
|
||||||
totalPages = totalPages,
|
totalPages = totalPages,
|
||||||
activeTheme = activeTheme,
|
activeTheme = activeTheme,
|
||||||
|
excludeImages = excludeImages,
|
||||||
externalScale = highResScale,
|
externalScale = highResScale,
|
||||||
onScaleChanged = {},
|
onScaleChanged = {},
|
||||||
showAllTextHighlights = showAllTextHighlights,
|
showAllTextHighlights = showAllTextHighlights,
|
||||||
|
|
@ -1638,17 +1671,20 @@ internal fun PdfVerticalReader(
|
||||||
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
|
val boxScreenY = pageScreenY + (localTopLeft.y * currentZoom)
|
||||||
|
|
||||||
draggingBoxSize = Size(
|
draggingBoxSize = Size(
|
||||||
box.relativeBounds.width * page.width * currentZoom,
|
box.relativeBounds.width * page.width,
|
||||||
box.relativeBounds.height * page.height * currentZoom
|
box.relativeBounds.height * page.height
|
||||||
)
|
)
|
||||||
draggingBoxPageHeight = page.height * currentZoom
|
draggingBoxPageHeight = page.height
|
||||||
|
|
||||||
draggingBoxOffset = Offset(boxScreenX, boxScreenY)
|
draggingBoxOffset = Offset(boxScreenX, boxScreenY)
|
||||||
draggingBoxTouchDelta = touchOffset * currentZoom
|
draggingBoxTouchDelta = touchOffset * currentZoom
|
||||||
draggingBoxId = box.id
|
draggingBoxId = box.id
|
||||||
},
|
},
|
||||||
onTextBoxDrag = { dragDelta ->
|
onTextBoxDrag = { dragDelta ->
|
||||||
draggingBoxOffset += dragDelta
|
val currentZoom = zoomAnimatable.value
|
||||||
|
val scaledDelta = dragDelta * currentZoom
|
||||||
|
Timber.tag("PdfTextBoxDebug").v("VerticalReader onTextBoxDrag dragDelta=$dragDelta zoom=$currentZoom scaledDelta=$scaledDelta")
|
||||||
|
draggingBoxOffset += scaledDelta
|
||||||
},
|
},
|
||||||
onTextBoxDragEnd = {
|
onTextBoxDragEnd = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -1681,9 +1717,9 @@ internal fun PdfVerticalReader(
|
||||||
val rawRelY =
|
val rawRelY =
|
||||||
(finalBoxY / currentZoom) / targetPage.height
|
(finalBoxY / currentZoom) / targetPage.height
|
||||||
val relW =
|
val relW =
|
||||||
(draggingBoxSize.width / currentZoom) / targetPage.width
|
draggingBoxSize.width / targetPage.width
|
||||||
val relH =
|
val relH =
|
||||||
(draggingBoxSize.height / currentZoom) / targetPage.height
|
draggingBoxSize.height / targetPage.height
|
||||||
|
|
||||||
val clampedW = relW.coerceAtMost(1f)
|
val clampedW = relW.coerceAtMost(1f)
|
||||||
val clampedH = relH.coerceAtMost(1f)
|
val clampedH = relH.coerceAtMost(1f)
|
||||||
|
|
@ -2051,7 +2087,8 @@ internal fun PdfVerticalReader(
|
||||||
val fontScaleRatio =
|
val fontScaleRatio =
|
||||||
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f
|
if (currentBoxHeight > 0) draggingBoxPageHeight / currentBoxHeight else 1f
|
||||||
|
|
||||||
val boxBottomY = draggingBoxOffset.y + draggingBoxSize.height
|
val currentZoom = zoomAnimatable.value
|
||||||
|
val boxBottomY = draggingBoxOffset.y + (draggingBoxSize.height * currentZoom)
|
||||||
val spaceBelow = screenHeight - boxBottomY
|
val spaceBelow = screenHeight - boxBottomY
|
||||||
val overlayHandlePos =
|
val overlayHandlePos =
|
||||||
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM
|
if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM
|
||||||
|
|
@ -2062,6 +2099,11 @@ internal fun PdfVerticalReader(
|
||||||
draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
|
draggingBoxOffset.x.roundToInt(), draggingBoxOffset.y.roundToInt()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = currentZoom
|
||||||
|
scaleY = currentZoom
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
}
|
||||||
.zIndex(100f)) {
|
.zIndex(100f)) {
|
||||||
ResizableTextBox(
|
ResizableTextBox(
|
||||||
box = draggedBox.copy(
|
box = draggedBox.copy(
|
||||||
|
|
@ -2073,6 +2115,7 @@ internal fun PdfVerticalReader(
|
||||||
isDarkMode = isDarkMode,
|
isDarkMode = isDarkMode,
|
||||||
pageWidthPx = draggingBoxSize.width,
|
pageWidthPx = draggingBoxSize.width,
|
||||||
pageHeightPx = draggingBoxSize.height,
|
pageHeightPx = draggingBoxSize.height,
|
||||||
|
scale = currentZoom,
|
||||||
handlePosition = overlayHandlePos,
|
handlePosition = overlayHandlePos,
|
||||||
onBoundsChanged = {},
|
onBoundsChanged = {},
|
||||||
onTextChanged = {},
|
onTextChanged = {},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -35,14 +35,11 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
|
@ -118,7 +115,6 @@ import com.aryan.reader.R
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private enum class ColorMenuMode {
|
private enum class ColorMenuMode {
|
||||||
|
|
@ -176,18 +172,6 @@ fun TextAnnotationDock(
|
||||||
val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
|
val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val imeBottom = WindowInsets.ime.getBottom(density)
|
|
||||||
val navBottom = WindowInsets.navigationBars.getBottom(density)
|
|
||||||
val spacerHeightPx = max(0, imeBottom - navBottom)
|
|
||||||
val spacerHeightDp = with(density) { spacerHeightPx.toDp() }
|
|
||||||
|
|
||||||
val effectiveSpacerHeight = if (spacerHeightDp > 0.dp) {
|
|
||||||
spacerHeightDp
|
|
||||||
} else {
|
|
||||||
bottomDockPadding
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(activePopup) {
|
LaunchedEffect(activePopup) {
|
||||||
if (activePopup == ActivePopup.NONE) {
|
if (activePopup == ActivePopup.NONE) {
|
||||||
activeMenuMode = ColorMenuMode.PALETTE
|
activeMenuMode = ColorMenuMode.PALETTE
|
||||||
|
|
@ -200,7 +184,7 @@ fun TextAnnotationDock(
|
||||||
|
|
||||||
val dockBarHeight = 48.dp
|
val dockBarHeight = 48.dp
|
||||||
val margin = 8.dp
|
val margin = 8.dp
|
||||||
val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin)
|
val finalOffsetY = -(bottomDockPadding + dockBarHeight + margin)
|
||||||
|
|
||||||
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY
|
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY
|
||||||
|
|
||||||
|
|
@ -724,7 +708,7 @@ fun TextAnnotationDock(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(effectiveSpacerHeight))
|
Spacer(modifier = Modifier.height(bottomDockPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.materialkolor.PaletteStyle
|
import com.materialkolor.PaletteStyle
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -118,36 +119,58 @@ fun AppTheme(
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
val colorScheme = when {
|
val baseColorScheme = remember(
|
||||||
|
darkTheme, dynamicColor, supportsDynamicColor, seedColor, contrastLevel
|
||||||
|
) {
|
||||||
|
when {
|
||||||
seedColor != null -> dynamicColorScheme(
|
seedColor != null -> dynamicColorScheme(
|
||||||
seedColor = seedColor,
|
seedColor = seedColor,
|
||||||
isDark = darkTheme,
|
isDark = darkTheme,
|
||||||
contrastLevel = contrastLevel,
|
contrastLevel = contrastLevel,
|
||||||
style = PaletteStyle.Fidelity
|
style = PaletteStyle.Fidelity
|
||||||
)
|
)
|
||||||
|
|
||||||
dynamicColor && supportsDynamicColor -> {
|
dynamicColor && supportsDynamicColor -> {
|
||||||
val context = LocalContext.current
|
if (darkTheme) dynamicDarkColorScheme(context)
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
darkTheme -> darkScheme
|
darkTheme -> darkScheme
|
||||||
else -> lightScheme
|
else -> lightScheme
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val finalColorScheme = colorScheme.copy(
|
val finalColorScheme = remember(baseColorScheme, textDimFactor) {
|
||||||
onPrimary = colorScheme.onPrimary.copy(alpha = textDimFactor),
|
if (textDimFactor >= 1.0f) {
|
||||||
onSecondary = colorScheme.onSecondary.copy(alpha = textDimFactor),
|
baseColorScheme
|
||||||
onTertiary = colorScheme.onTertiary.copy(alpha = textDimFactor),
|
} else {
|
||||||
onBackground = colorScheme.onBackground.copy(alpha = textDimFactor),
|
baseColorScheme.copy(
|
||||||
onSurface = colorScheme.onSurface.copy(alpha = textDimFactor),
|
primary = baseColorScheme.primary.copy(alpha = textDimFactor),
|
||||||
onSurfaceVariant = colorScheme.onSurfaceVariant.copy(alpha = textDimFactor),
|
secondary = baseColorScheme.secondary.copy(alpha = textDimFactor),
|
||||||
onError = colorScheme.onError.copy(alpha = textDimFactor),
|
tertiary = baseColorScheme.tertiary.copy(alpha = textDimFactor),
|
||||||
onPrimaryContainer = colorScheme.onPrimaryContainer.copy(alpha = textDimFactor),
|
error = baseColorScheme.error.copy(alpha = textDimFactor),
|
||||||
onSecondaryContainer = colorScheme.onSecondaryContainer.copy(alpha = textDimFactor),
|
primaryContainer = baseColorScheme.primaryContainer.copy(alpha = textDimFactor),
|
||||||
onTertiaryContainer = colorScheme.onTertiaryContainer.copy(alpha = textDimFactor),
|
secondaryContainer = baseColorScheme.secondaryContainer.copy(alpha = textDimFactor),
|
||||||
onErrorContainer = colorScheme.onErrorContainer.copy(alpha = textDimFactor)
|
tertiaryContainer = baseColorScheme.tertiaryContainer.copy(alpha = textDimFactor),
|
||||||
|
errorContainer = baseColorScheme.errorContainer.copy(alpha = textDimFactor),
|
||||||
|
outline = baseColorScheme.outline.copy(alpha = textDimFactor),
|
||||||
|
outlineVariant = baseColorScheme.outlineVariant.copy(alpha = textDimFactor),
|
||||||
|
inversePrimary = baseColorScheme.inversePrimary.copy(alpha = textDimFactor),
|
||||||
|
inverseOnSurface = baseColorScheme.inverseOnSurface.copy(alpha = textDimFactor),
|
||||||
|
onPrimary = baseColorScheme.onPrimary.copy(alpha = textDimFactor),
|
||||||
|
onSecondary = baseColorScheme.onSecondary.copy(alpha = textDimFactor),
|
||||||
|
onTertiary = baseColorScheme.onTertiary.copy(alpha = textDimFactor),
|
||||||
|
onBackground = baseColorScheme.onBackground.copy(alpha = textDimFactor),
|
||||||
|
onSurface = baseColorScheme.onSurface.copy(alpha = textDimFactor),
|
||||||
|
onSurfaceVariant = baseColorScheme.onSurfaceVariant.copy(alpha = textDimFactor),
|
||||||
|
onError = baseColorScheme.onError.copy(alpha = textDimFactor),
|
||||||
|
onPrimaryContainer = baseColorScheme.onPrimaryContainer.copy(alpha = textDimFactor),
|
||||||
|
onSecondaryContainer = baseColorScheme.onSecondaryContainer.copy(alpha = textDimFactor),
|
||||||
|
onTertiaryContainer = baseColorScheme.onTertiaryContainer.copy(alpha = textDimFactor),
|
||||||
|
onErrorContainer = baseColorScheme.onErrorContainer.copy(alpha = textDimFactor),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = finalColorScheme,
|
colorScheme = finalColorScheme,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<style name="Theme.Reader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||||
|
|
||||||
<style name="Theme.Reader" parent="android:Theme.Material.Light.NoActionBar" />
|
<!-- Modern Splash Screen Theme -->
|
||||||
|
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||||
|
<!-- You can specify a background color here if needed, otherwise it defaults to the system's window background -->
|
||||||
|
<!-- <item name="windowSplashScreenBackground">#121212</item> -->
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.Reader</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
7
app/src/main/res/xml/locales_config.xml
Normal file
7
app/src/main/res/xml/locales_config.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en"/>
|
||||||
|
<locale android:name="ar"/>
|
||||||
|
<locale android:name="de"/>
|
||||||
|
<locale android:name="tr"/>
|
||||||
|
</locale-config>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue