/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ // PdfViewerScreen.kt @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") package com.aryan.reader.pdf import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.geometry.lerp import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor import android.provider.OpenableColumns import android.util.Base64 import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.media3.common.util.UnstableApi import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig import com.aryan.reader.MainViewModel import com.aryan.reader.R import com.aryan.reader.SearchResult import com.aryan.reader.SearchTopBar import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationResult import com.aryan.reader.SyncUpdateInfo import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.fetchAiDefinition import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.data.AnnotationSettingsRepository import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.SmartSearchResult import com.aryan.reader.pdf.data.TextStyleConfig import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.rememberSearchState import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks import io.legere.pdfiumandroid.PdfDocument import io.legere.pdfiumandroid.PdfPasswordException import io.legere.pdfiumandroid.suspend.PdfDocumentKt import io.legere.pdfiumandroid.suspend.PdfPageKt import io.legere.pdfiumandroid.suspend.PdfTextPageKt import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File import java.net.HttpURLConnection import java.net.URL import kotlin.math.min import kotlin.math.roundToInt import kotlin.random.Random private const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" private const val SETTINGS_PREFS_NAME = "epub_reader_settings" private const val TTS_MODE_KEY = "tts_mode" private const val DISPLAY_MODE_KEY = "pdf_display_mode" private const val PDF_DARK_MODE_KEY = "pdf_dark_mode" private const val OCR_LANGUAGE_KEY = "ocr_language_key" private const val OCR_LANGUAGE_SELECTED_KEY = "ocr_language_selected_key" private const val DOCK_LOCATION_KEY = "dock_location" private const val DOCK_OFFSET_X_KEY = "dock_offset_x" private const val DOCK_OFFSET_Y_KEY = "dock_offset_y" private const val PDF_AUTO_SCROLL_SPEED_KEY = "pdf_auto_scroll_speed" private const val PDF_AUTO_SCROLL_LOCKED_KEY = "pdf_auto_scroll_locked" private const val PDF_AUTO_SCROLL_USE_SLIDER_KEY = "pdf_auto_scroll_use_slider" private const val PDF_AUTO_SCROLL_MIN_SPEED_KEY = "pdf_auto_scroll_min_speed" private const val PDF_AUTO_SCROLL_MAX_SPEED_KEY = "pdf_auto_scroll_max_speed" private fun savePdfAutoScrollMinSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, speed) } } private fun loadPdfAutoScrollMinSpeed(context: Context): Float { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getFloat(PDF_AUTO_SCROLL_MIN_SPEED_KEY, 0.1f) } private fun savePdfAutoScrollMaxSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, speed) } } private fun loadPdfAutoScrollMaxSpeed(context: Context): Float { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getFloat(PDF_AUTO_SCROLL_MAX_SPEED_KEY, 10.0f) } private fun savePdfAutoScrollLocked(context: Context, isLocked: Boolean) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putBoolean(PDF_AUTO_SCROLL_LOCKED_KEY, isLocked) } } private fun loadPdfAutoScrollLocked(context: Context): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(PDF_AUTO_SCROLL_LOCKED_KEY, false) } private fun savePdfAutoScrollUseSlider(context: Context, useSlider: Boolean) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, useSlider) } } private fun loadPdfAutoScrollUseSlider(context: Context): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(PDF_AUTO_SCROLL_USE_SLIDER_KEY, false) } private fun savePdfAutoScrollSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putFloat(PDF_AUTO_SCROLL_SPEED_KEY, speed) } } private fun loadPdfAutoScrollSpeed(context: Context): Float { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getFloat(PDF_AUTO_SCROLL_SPEED_KEY, 3.0f) } private fun generateShortId(): String { return Random.nextInt(1000, 9999).toString() } private fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): String { val base = originalName?.substringBeforeLast('.') ?: "Document" val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_").take(50) val suffix = if (isAnnotated) "_annotated" else "" val shortId = generateShortId() return "${safeBase}${suffix}_${shortId}.pdf" } private enum class SaveMode { ORIGINAL, ANNOTATED } enum class SearchHighlightMode { FOCUSED, ALL } private sealed interface HistoryAction { data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction data class Remove(val items: Map>) : HistoryAction } private enum class DockLocation { TOP, BOTTOM, FLOATING } private fun saveOcrLanguage(context: Context, language: OcrLanguage) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(OCR_LANGUAGE_KEY, language.name) putBoolean(OCR_LANGUAGE_SELECTED_KEY, true) } } private fun loadOcrLanguage(context: Context): OcrLanguage { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val name = prefs.getString(OCR_LANGUAGE_KEY, OcrLanguage.LATIN.name) return try { OcrLanguage.valueOf(name ?: OcrLanguage.LATIN.name) } catch (_: Exception) { OcrLanguage.LATIN } } private fun hasUserSelectedOcrLanguage(context: Context): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(OCR_LANGUAGE_SELECTED_KEY, false) } private fun saveDockState(context: Context, location: DockLocation, offset: Offset) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(DOCK_LOCATION_KEY, location.name) putFloat(DOCK_OFFSET_X_KEY, offset.x) putFloat(DOCK_OFFSET_Y_KEY, offset.y) } } private fun loadDockState(context: Context): Pair { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val locName = prefs.getString(DOCK_LOCATION_KEY, DockLocation.BOTTOM.name) val location = try { DockLocation.valueOf(locName ?: DockLocation.BOTTOM.name) } catch (_: Exception) { DockLocation.BOTTOM } val x = prefs.getFloat(DOCK_OFFSET_X_KEY, 0f) val y = prefs.getFloat(DOCK_OFFSET_Y_KEY, 0f) return location to Offset(x, y) } private enum class DisplayMode { PAGINATION, VERTICAL_SCROLL } private fun saveDisplayMode(context: Context, mode: DisplayMode) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(DISPLAY_MODE_KEY, mode.name) } } private fun loadDisplayMode(context: Context): DisplayMode { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val modeName = prefs.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) return try { DisplayMode.valueOf(modeName ?: DisplayMode.VERTICAL_SCROLL.name) } catch (_: IllegalArgumentException) { DisplayMode.VERTICAL_SCROLL } } internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) private fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set { if (bookmarksJson.isNullOrBlank()) return emptySet() return try { val jsonArray = JSONArray(bookmarksJson) (0 until jsonArray.length()).mapNotNull { i -> try { val json = jsonArray.getJSONObject(i) PdfBookmark( pageIndex = json.getInt("pageIndex"), title = json.getString("title"), totalPages = json.getInt("totalPages") ) } catch (e: Exception) { Timber.e(e, "Failed to parse bookmark from JSON object") null } }.toSet() } catch (e: Exception) { Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson") emptySet() } } private fun savePdfDarkMode(context: Context, isDark: Boolean) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putBoolean(PDF_DARK_MODE_KEY, isDark) } } private fun loadPdfDarkMode(context: Context): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(PDF_DARK_MODE_KEY, false) } @Suppress("unused") private enum class TtsMode { CLOUD, BASE } private data class TtsPageData( val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean ) private data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) private fun flattenToc(bookmarks: List, level: Int = 0): List { val entries = mutableListOf() for (bookmark in bookmarks) { entries.add( TocEntry( title = bookmark.title ?: "Untitled Chapter", pageIndex = bookmark.pageIdx.toInt(), nestLevel = level ) ) if (bookmark.children.isNotEmpty()) { entries.addAll(flattenToc(bookmark.children, level + 1)) } } return entries } @Suppress("unused") private fun saveTtsMode(context: Context, mode: TtsMode) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(TTS_MODE_KEY, mode.name) } } @Suppress("unused") private fun loadTtsMode(): TtsMode { // For this release, Cloud TTS is disabled. Force BASE mode. return TtsMode.BASE } private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? { return withContext(Dispatchers.IO) { var page: PdfPageKt? = null try { page = doc.openPage(pageIndex) val bitmapWidth = 1080 val aspectRatio = page.getPageWidthPoint().toFloat() / page.getPageHeightPoint().toFloat() if (aspectRatio.isNaN() || aspectRatio <= 0) { Timber.e("Invalid aspect ratio for page $pageIndex") return@withContext null } val bitmapHeight = (bitmapWidth / aspectRatio).toInt() if (bitmapHeight <= 0) { Timber.e("Invalid calculated bitmap height for page $pageIndex") return@withContext null } val bitmap = createBitmap(bitmapWidth, bitmapHeight) page.renderPageBitmap( bitmap = bitmap, startX = 0, startY = 0, drawSizeX = bitmapWidth, drawSizeY = bitmapHeight, renderAnnot = true ) bitmap } catch (e: Exception) { Timber.e(e, "Error rendering page $pageIndex to bitmap for summarization") null } finally { try { page?.close() } catch (e: Exception) { Timber.w(e, "Error closing page in renderPageToBitmap") } } } } private fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() try { context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" result = "${name}_${size}" } } } catch (e: Exception) { Timber.e(e, "Failed to generate fast file ID") } return result } @Suppress("KotlinConstantConditions") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt") @ExperimentalMaterial3Api @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) @Composable fun PdfViewerScreen( pdfUri: Uri, initialPage: Int?, initialBookmarksJson: String?, isProUser: Boolean, onNavigateBack: () -> Unit, onSavePosition: (page: Int, totalPages: Int) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, onNavigateToPro: () -> Unit, viewModel: MainViewModel ) { SideEffect { Timber.tag("PdfDrawPerf").v("ROOT: PdfViewerScreen Recomposing") } val context = LocalContext.current LaunchedEffect(Unit) { PdfFontCache.init(context.assets) } val activity = context as? Activity val coroutineScope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) } var pageAspectRatios by remember { mutableStateOf>(emptyList()) } var showBars by remember { mutableStateOf(true) } var isFullScreen by remember { mutableStateOf(false) } var documentPassword by remember { mutableStateOf(null) } var isScrollLocked by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) } LocalView.current var ocrLanguage by remember { mutableStateOf(loadOcrLanguage(context)) } var hasSelectedOcrLanguage by remember { mutableStateOf(hasUserSelectedOcrLanguage(context)) } var showOcrLanguageDialog by remember { mutableStateOf(false) } var showReindexDialog by remember { mutableStateOf(null) } var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) } val executeWithOcrCheck = remember(hasSelectedOcrLanguage) { { action: () -> Unit -> if (hasSelectedOcrLanguage) { action() } else { pendingActionAfterOcrSelection = action showOcrLanguageDialog = true } } } var searchHighlightMode by remember { mutableStateOf(SearchHighlightMode.ALL) } var isBackgroundIndexing by remember { mutableStateOf(false) } var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) } val uiState by viewModel.uiState.collectAsState() val originalFileName by remember(uiState.recentFiles, pdfUri) { derivedStateOf { uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName ?: pdfUri.lastPathSegment ?: "Document.pdf" } } var isDockDragging by remember { mutableStateOf(false) } var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) } var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } var autoScrollSpeed by remember { mutableFloatStateOf(loadPdfAutoScrollSpeed(context)) } var autoScrollMinSpeed by remember { mutableFloatStateOf(loadPdfAutoScrollMinSpeed(context)) } var autoScrollMaxSpeed by remember { mutableFloatStateOf(loadPdfAutoScrollMaxSpeed(context)) } var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } fun triggerAutoScrollTempPause(durationMs: Long) { if (!isAutoScrollModeActive || !isAutoScrollPlaying) return autoScrollResumeJob.value?.cancel() isAutoScrollTempPaused = true autoScrollResumeJob.value = coroutineScope.launch { delay(durationMs) if (isActive && isAutoScrollModeActive && isAutoScrollPlaying) { isAutoScrollTempPaused = false } } } val onAutoScrollInteraction = remember { { if (isAutoScrollPlaying) { triggerAutoScrollTempPause(1000L) } } } var paginationDraggingBoxId by remember { mutableStateOf(null) } val customFonts by viewModel.customFonts.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val onOcrStateChange: (Boolean) -> Unit = {} var showZoomIndicator by remember { mutableStateOf(false) } var bookmarks by remember(pdfUri) { mutableStateOf(loadPdfBookmarksFromJson(initialBookmarksJson)) } var showPenPlayground by remember { mutableStateOf(false) } var isEditMode by remember { mutableStateOf(false) } var isDockMinimized by remember { mutableStateOf(false) } val isDrawingActive by remember(isEditMode, isDockMinimized) { derivedStateOf { isEditMode && !isDockMinimized } } val (initialDockLocation, initialDockOffset) = remember(context) { loadDockState(context) } var dockLocation by remember { mutableStateOf(initialDockLocation) } var dockOffset by remember { mutableStateOf(initialDockOffset) } var snapPreviewLocation by remember { mutableStateOf(null) } var paginationDraggingOffset by remember { mutableStateOf(Offset.Zero) } var paginationDraggingSize by remember { mutableStateOf(Size.Zero) } var paginationDragPageHeight by remember { mutableFloatStateOf(0f) } var paginationOriginalRelSize by remember { mutableStateOf(Size.Zero) } LaunchedEffect(Unit) { if (dockLocation == DockLocation.FLOATING && dockOffset == Offset.Zero) { dockLocation = DockLocation.BOTTOM } } val view = LocalView.current val window = (view.context as? Activity)?.window LaunchedEffect(isFullScreen) { if (window != null) { val insetsController = WindowCompat.getInsetsController(window, view) if (isFullScreen) { showBars = false insetsController.hide(WindowInsetsCompat.Type.systemBars()) insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } else { showBars = true insetsController.show(WindowInsetsCompat.Type.systemBars()) } } } val dockHeight = 64.dp val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() } val verticalHeaderHeight by remember( dockLocation, snapPreviewLocation, isEditMode, isDockDragging ) { derivedStateOf { if (!isEditMode) { 0.dp } else { val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging val isPreviewingTop = snapPreviewLocation == DockLocation.TOP if (isStickyTop || isPreviewingTop) dockHeight else 0.dp } } } val verticalFooterHeight by remember( dockLocation, snapPreviewLocation, isEditMode, isDockDragging ) { derivedStateOf { if (!isEditMode) { 0.dp } else { val isStickyBottom = dockLocation == DockLocation.BOTTOM && !isDockDragging val isPreviewingBottom = snapPreviewLocation == DockLocation.BOTTOM if (isStickyBottom || isPreviewingBottom) dockHeight else 0.dp } } } LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) } LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) } LaunchedEffect(isPdfDarkMode) { savePdfDarkMode(context, isPdfDarkMode) } val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val toolSettings by annotationSettingsRepo.settings.collectAsState() var showToolSettings by remember { mutableStateOf(false) } val selectedTool = toolSettings.getActiveTool() val lastPenTool = toolSettings.getLastPenTool() val dockPenColor = toolSettings.getToolColor(lastPenTool) val dockHighlighterColor = toolSettings.getToolColor(InkType.HIGHLIGHTER) val activeToolColor = toolSettings.getToolColor(selectedTool) val activeToolThickness = toolSettings.getToolThickness(selectedTool) val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN) val markerColor = toolSettings.getToolColor(InkType.PEN) val pencilColor = toolSettings.getToolColor(InkType.PENCIL) val highlighterColor = toolSettings.getToolColor(InkType.HIGHLIGHTER) val highlighterRoundColor = toolSettings.getToolColor(InkType.HIGHLIGHTER_ROUND) val isCurrentToolHighlighter = selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND val penPalette = remember(toolSettings.penPaletteArgb) { toolSettings.getPenPalette() } val highlighterPalette = remember(toolSettings.highlighterPaletteArgb) { toolSettings.getHighlighterPalette() } val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } } val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } } val pdfTextRepository = remember(context) { PdfTextRepository(context) } val annotationRepository = remember(context) { PdfAnnotationRepository(context) } val textBoxRepository = remember(context) { PdfTextBoxRepository(context) } var currentBookId by remember { mutableStateOf(null) } var allAnnotations by remember { mutableStateOf>>(emptyMap()) } val undoStack = remember { mutableStateListOf() } val redoStack = remember { mutableStateListOf() } val erasedAnnotationsFromStroke = remember { mutableStateMapOf>() } var areAnnotationsLoaded by remember { mutableStateOf(false) } LaunchedEffect(allAnnotations) { if (areAnnotationsLoaded && currentBookId != null) { delay(1000) withContext(Dispatchers.IO) { Timber.d("Auto-saving annotations locally for book $currentBookId") annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) } } } val drawingState = remember { PdfDrawingState() } val allAnnotationsProvider = remember { { allAnnotations } } LaunchedEffect(Unit) { Timber.d("PdfViewerScreen init: initialBookmarksJson is '$initialBookmarksJson'") Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.") } LaunchedEffect(bookmarks) { val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) put("title", bookmark.title) put("totalPages", bookmark.totalPages) } } val bookmarksJson = JSONArray(objectList).toString() Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson") onBookmarksChanged(bookmarksJson) } var flatTableOfContents by remember { mutableStateOf>(emptyList()) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } var showAiDefinitionPopup by remember { mutableStateOf(false) } var selectedTextForAi by remember { mutableStateOf(null) } var aiDefinitionResult by remember { mutableStateOf(null) } var isAiDefinitionLoading by remember { mutableStateOf(false) } var isAutoPagingForTts by remember { mutableStateOf(false) } var showAllTextHighlights by remember { mutableStateOf(false) } var isHighlightingLoading by remember { mutableStateOf(false) } var ttsPageData by remember { mutableStateOf(null) } var ttsHighlightData by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } var isLoadingDocument by remember { mutableStateOf(true) } var pdfDocument by remember { mutableStateOf(null) } var pfdState by remember { mutableStateOf(null) } var totalPages by remember { mutableIntStateOf(0) } var currentPageScale by remember { mutableFloatStateOf(1f) } var initialScrollDone by remember { mutableStateOf(false) } var isDocumentReady by remember { mutableStateOf(false) } var selectionClearTrigger by remember { mutableLongStateOf(0L) } var virtualPages by remember { mutableStateOf>(emptyList()) } val displayPageRatios by remember(pageAspectRatios, virtualPages) { derivedStateOf { if (virtualPages.isEmpty()) { pageAspectRatios } else { virtualPages.map { vp -> when (vp) { is VirtualPage.PdfPage -> pageAspectRatios.getOrElse(vp.pdfIndex) { 1f } is VirtualPage.BlankPage -> { if (vp.height > 0) vp.width.toFloat() / vp.height.toFloat() else 1f } } } } } } val richTextRepository = remember(context) { PdfRichTextRepository(context) } val richTextController = remember(currentBookId) { if (currentBookId != null) RichTextController( richTextRepository, coroutineScope, currentBookId!! ) else null } LaunchedEffect(richTextController, toolSettings.textStyle) { richTextController?.let { controller -> val config = toolSettings.textStyle val style = SpanStyle( color = Color(config.colorArgb), background = Color(config.backgroundColorArgb), fontSize = config.fontSize.sp, fontWeight = if (config.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (config.isItalic) FontStyle.Italic else FontStyle.Normal, fontFamily = PdfFontCache.getFontFamily(config.fontPath), textDecoration = run { val decorations = mutableListOf() if (config.isUnderline) decorations.add(TextDecoration.Underline) if (config.isStrikeThrough) decorations.add(TextDecoration.LineThrough) if (decorations.isEmpty()) TextDecoration.None else TextDecoration.combine(decorations) }) if (controller.currentStyle != style || controller.currentFontPath != config.fontPath) { controller.updateCurrentStyle(style, config.fontPath, config.fontName) } } } val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) } val verticalReaderState = rememberVerticalPdfReaderState() val totalDisplayPages by remember(virtualPages, totalPages) { derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages } } val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages }) LaunchedEffect(currentBookId) { if (currentBookId != null) richTextRepository.load(currentBookId!!) } LaunchedEffect(richTextController, keyboardController) { richTextController?.setKeyboardController(keyboardController) } LaunchedEffect(richTextController?.cursorPageIndex, isEditMode) { val controller = richTextController ?: return@LaunchedEffect val targetPage = controller.cursorPageIndex if (isEditMode && targetPage >= 0 && targetPage < totalDisplayPages) { if (displayMode == DisplayMode.PAGINATION) { if (pagerState.currentPage != targetPage) { Timber.tag("CursorNav").d("Cursor moved to Page $targetPage. Auto-paging.") pagerState.animateScrollToPage(targetPage) } } } } val currentPage by remember { derivedStateOf { when (displayMode) { DisplayMode.PAGINATION -> pagerState.currentPage DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage } } } Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)") val textBoxes = remember { mutableStateListOf() } var selectedTextBoxId by remember { mutableStateOf(null) } val onInsertPage: () -> Unit = { coroutineScope.launch { val targetIndex = currentPage + 1 Timber.tag("RichTextMigration").i("INSERT: User requested blank page at index $targetIndex") val (refWidth, refHeight) = withContext(Dispatchers.IO) { if (virtualPages.isNotEmpty()) { val refIndex = (currentPage).coerceIn(0, virtualPages.size - 1) when (val vp = virtualPages[refIndex]) { is VirtualPage.PdfPage -> { var w = 595 var h = 842 try { pdfDocument?.openPage(vp.pdfIndex)?.use { page -> val preRotationWidth = page.getPageWidthPoint() val preRotationHeight = page.getPageHeightPoint() val rotation = page.getPageRotation() if (rotation == 90 || rotation == 270) { w = preRotationHeight h = preRotationWidth } else { w = preRotationWidth h = preRotationHeight } } } catch (e: Exception) { Timber.e(e, "Could not get page dimensions for page ${vp.pdfIndex}. Using defaults.") } Pair(w, h) } is VirtualPage.BlankPage -> Pair(vp.width, vp.height) } } else { Pair(595, 842) } } if (currentBookId != null) { val shiftedBoxes = textBoxes.map { box -> if (box.pageIndex >= targetIndex) { box.copy(pageIndex = box.pageIndex + 1) } else { box } } if (shiftedBoxes != textBoxes) { textBoxes.clear() textBoxes.addAll(shiftedBoxes) } val tempNewPage = VirtualPage.BlankPage(generateShortId(), refWidth, refHeight, wasManuallyAdded = true) val optimisticPages = virtualPages.toMutableList() optimisticPages.add(targetIndex, tempNewPage) virtualPages = optimisticPages if (displayMode == DisplayMode.PAGINATION) { pagerState.animateScrollToPage(targetIndex) } else { verticalReaderState.scrollToPage(targetIndex) } Timber.tag("RichTextMigration").d("INSERT: Triggering RichTextController.insertPageBreakAt($targetIndex, count=2)") richTextController?.insertPageBreakAt(targetIndex, count = 2) val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) put("title", bookmark.title) put("totalPages", bookmark.totalPages) } } val currentJson = JSONArray(objectList).toString() val result = viewModel.addPage( bookId = currentBookId!!, currentLayout = virtualPages - tempNewPage, insertIndex = targetIndex, currentAnnotations = allAnnotations, currentBookmarksJson = currentJson, referenceWidth = refWidth, referenceHeight = refHeight, wasManuallyAdded = true ) Timber.tag("RichTextMigration").i("INSERT: Layout update complete. New virtualPages size: ${result.layout.size}") virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) snackbarHostState.showSnackbar("Page added at ${targetIndex + 1}") } } } val onDeletePage: () -> Unit = { coroutineScope.launch { if (currentBookId != null && currentPage in virtualPages.indices) { Timber.tag("RichTextMigration").i("DELETE: User requested deletion of page at index $currentPage") // Update text boxes: remove those on current page, shift those after val boxesToKeep = textBoxes.filter { it.pageIndex != currentPage } val shiftedBoxes = boxesToKeep.map { box -> if (box.pageIndex > currentPage) { box.copy(pageIndex = box.pageIndex - 1) } else { box } } textBoxes.clear() textBoxes.addAll(shiftedBoxes) val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) put("title", bookmark.title) put("totalPages", bookmark.totalPages) } } val currentJson = JSONArray(objectList).toString() val cleanedAnnotations = allAnnotations.filterKeys { it != currentPage } allAnnotations = cleanedAnnotations Timber.tag("RichTextMigration").d("DELETE: Wiping text and structural breaks for page $currentPage") richTextController?.deleteTextOnPage(currentPage) val result = viewModel.removePage( currentBookId!!, virtualPages, currentPage, cleanedAnnotations, currentJson ) Timber.tag("RichTextMigration").i("DELETE: Layout update complete. New virtualPages size: ${result.layout.size}") virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) snackbarHostState.showSnackbar("Page deleted") val newMax = (virtualPages.size - 1).coerceAtLeast(0) if (currentPage > newMax) { if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage(newMax) } else { verticalReaderState.scrollToPage(newMax) } } } } } val onInsertTextBox = { val currentP = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage Timber.tag("PdfTextBoxDebug").d("Viewer: onInsertTextBox triggered. Target Page: $currentP, DisplayMode: $displayMode") val defaultWidth = 0.4f val defaultHeight = 0.1f val startX = 0.3f val startY = 0.45f val newStyle = toolSettings.textStyle val fontSizeNorm = 0.02f val newBox = PdfTextBox( id = generateShortId(), pageIndex = currentP, relativeBounds = Rect(startX, startY, startX + defaultWidth, startY + defaultHeight), text = "", color = Color(newStyle.colorArgb), backgroundColor = Color(newStyle.backgroundColorArgb), fontSize = fontSizeNorm, isBold = newStyle.isBold, isItalic = newStyle.isItalic, isUnderline = newStyle.isUnderline, isStrikeThrough = newStyle.isStrikeThrough ) textBoxes.add(newBox) Timber.tag("PdfTextBoxDebug").i("Viewer: Added TextBox [ID: ${newBox.id}] to list. Total boxes now: ${textBoxes.size}") selectedTextBoxId = newBox.id richTextController?.clearSelection() showBars = false } val onSingleTapStable = remember { { if (selectedTextBoxId != null) { val box = textBoxes.find { it.id == selectedTextBoxId } if (box != null && box.text.trim().isEmpty()) { textBoxes.remove(box) Timber.tag("PdfTextBoxDebug").i("Removed empty text box: ${box.id}") } selectedTextBoxId = null } else { if (!isFullScreen) { showBars = !showBars Timber.d("Vertical Reader Clicked. showBars now: $showBars") } } } } val highestRequiredTextPageIndex by remember(richTextController?.pageLayouts) { derivedStateOf { val maxIdx = richTextController?.pageLayouts?.maxOfOrNull { it.pageIndex } ?: -1 Timber.tag("CursorDebug").v("Calc highestRequiredTextPageIndex: $maxIdx") maxIdx } } val hasTextOnPage = remember(richTextController?.pageLayouts) { { pageIndex: Int -> richTextController?.pageLayouts?.any { it.pageIndex == pageIndex && it.visibleText.isNotBlank() } == true } } LaunchedEffect(highestRequiredTextPageIndex, virtualPages.size, allAnnotations) { if (richTextController == null || !isDocumentReady) return@LaunchedEffect delay(500) @Suppress("UnusedVariable", "Unused") val lastPageIndex = virtualPages.size - 1 val requiredPages = highestRequiredTextPageIndex + 1 // Expansion Logic if (requiredPages > virtualPages.size) { Timber.tag("RichTextFlow").i("Text overflow detected. Required pages: $requiredPages, current: ${virtualPages.size}. Adding page.") val lastPage = virtualPages.lastOrNull() val (refWidth, refHeight) = when(lastPage) { is VirtualPage.PdfPage -> { var w = 595; var h = 842 pdfDocument?.openPage(lastPage.pdfIndex)?.use { page -> w = page.getPageWidthPoint() h = page.getPageHeightPoint() } Pair(w, h) } is VirtualPage.BlankPage -> Pair(lastPage.width, lastPage.height) null -> Pair(595, 842) } val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) put("title", bookmark.title) put("totalPages", bookmark.totalPages) } } val currentJson = JSONArray(objectList).toString() val result = viewModel.addPage( bookId = currentBookId!!, currentLayout = virtualPages, insertIndex = virtualPages.size, currentAnnotations = allAnnotations, currentBookmarksJson = currentJson, referenceWidth = refWidth, referenceHeight = refHeight, wasManuallyAdded = false // Auto-added page ) virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) } // Contraction Logic else { var lastPage = virtualPages.lastOrNull() var currentLastIndex = virtualPages.size - 1 var pageRemoved = false while ( lastPage is VirtualPage.BlankPage && !lastPage.wasManuallyAdded && currentLastIndex > highestRequiredTextPageIndex && !hasTextOnPage(currentLastIndex) && allAnnotations[currentLastIndex].isNullOrEmpty() && textBoxes.none { it.pageIndex == currentLastIndex } // Check for text boxes ) { Timber.tag("RichTextFlow").i("Auto-pruning empty page at index $currentLastIndex.") pageRemoved = true val objectList = bookmarks.map { JSONObject().apply { put("pageIndex", it.pageIndex) put("title", it.title) put("totalPages", it.totalPages) } } val currentJson = JSONArray(objectList).toString() val result = viewModel.removePage( currentBookId!!, virtualPages, currentLastIndex, allAnnotations, currentJson ) virtualPages = result.layout allAnnotations = result.annotations bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson) onBookmarksChanged(result.bookmarksJson) currentLastIndex-- lastPage = virtualPages.getOrNull(currentLastIndex) } if (pageRemoved) { snackbarHostState.showSnackbar("Extra page removed") } } } LaunchedEffect(isDocumentReady) { if (isDocumentReady && !initialScrollDone) { val pageCount = pagerState.pageCount if (pageCount > 0) { val targetPage = initialPage?.coerceIn(0, pageCount - 1) ?: 0 Timber.d("Initial Setup: Document is ready. Target page: $targetPage.") coroutineScope.launch { if (pagerState.currentPage != targetPage) { when (displayMode) { DisplayMode.PAGINATION -> { Timber.d("Initial Setup: Animating scroll to page $targetPage.") pagerState.scrollToPage(targetPage) } DisplayMode.VERTICAL_SCROLL -> { Timber.d("Initial Setup: Snapping scroll to item $targetPage.") verticalReaderState.snapToPage(targetPage) pagerState.scrollToPage(targetPage) } } } initialScrollDone = true Timber.d("Initial Setup: Scroll complete. Page saving is now enabled.") } } else { Timber.w( "Initial Setup: Document is ready, but pager pageCount is still 0. This may happen on rapid open/close. Scroll will be skipped." ) } } } LaunchedEffect(isDocumentReady, currentBookId) { if (isDocumentReady && currentBookId != null && totalPages > 0) { val layout = viewModel.loadPageLayout(currentBookId!!, totalPages) virtualPages = layout if (initialPage != null && initialPage >= totalPages && initialPage < layout.size) { Timber.d("Restoring position to added page: $initialPage") if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage(initialPage) } else { verticalReaderState.scrollToPage(initialPage) } } } } LaunchedEffect(pagerState.currentPage) { Timber.d("Pager state changed: pagerState.currentPage is now ${pagerState.currentPage}") } LaunchedEffect(currentPage, totalPages) { if (totalPages > 0 && initialScrollDone) { delay(500L) Timber.d( "Debounced save: Calling onSavePosition(page=$currentPage, totalPages=$totalPages)" ) onSavePosition(currentPage, totalPages) } } LaunchedEffect(displayMode) { coroutineScope.launch { if (displayMode == DisplayMode.VERTICAL_SCROLL) { val pageToScroll = pagerState.currentPage verticalReaderState.scrollToPage(pageToScroll) } else { val pageToScroll = verticalReaderState.currentPage pagerState.scrollToPage(pageToScroll) } } } val isBookmarked by remember( bookmarks, pagerState.currentPage, verticalReaderState.currentPage, displayMode ) { derivedStateOf { val currentPage = if (displayMode == DisplayMode.PAGINATION) { pagerState.currentPage } else { verticalReaderState.currentPage } bookmarks.any { it.pageIndex == currentPage } } } LaunchedEffect(currentPageScale) { if (currentPageScale != 1f) { showZoomIndicator = true delay(1500) showZoomIndicator = false } else { showZoomIndicator = false } } val onToggleBookmark: (Int) -> Unit = { pageIndex -> coroutineScope.launch { Timber.d("onToggleBookmark triggered for page index: $pageIndex") if (bookmarks.any { it.pageIndex == pageIndex }) { Timber.d("Bookmark exists. Removing...") bookmarks = bookmarks.filterNot { it.pageIndex == pageIndex }.toSet() } else { Timber.d("Creating new bookmark. Attempting text extraction...") var extractedText = "" if (pdfDocument != null) { try { withContext(Dispatchers.IO) { pdfDocument!!.openPage(pageIndex).use { page -> page.openTextPage().use { textPage -> val count = textPage.textPageCountChars() if (count > 0) { // Attempt to get text val rawText = textPage.textPageGetText(0, min(count, 200)) extractedText = rawText ?: "" } else { Timber.w( "Pdfium: Character count is 0. Page might be image-only." ) } } } } } catch (e: Exception) { Timber.e(e, "Bookmark: Direct text extraction failed") } } else { Timber.e("pdfDocument is null. Cannot extract text.") } if (extractedText.isBlank() && currentBookId != null && pdfDocument != null) { Timber.d("Extracted text is blank. Attempting repository/OCR fallback...") try { extractedText = pdfTextRepository.getOrExtractText( currentBookId!!, pdfDocument!!, pageIndex ) Timber.d("Repository: Extracted text length: ${extractedText.length}") } catch (e: Exception) { Timber.w(e, "Bookmark: Repository extraction failed") } } val cleanText = extractedText.replace("\\s+".toRegex(), " ").trim() val words = cleanText.split(" ").filter { it.isNotBlank() } val contentTitle = if (words.isNotEmpty()) { words.take(6).joinToString(" ") + "..." } else { Timber.d("No words found. Falling back to 'Page X' title.") "Page ${pageIndex + 1}" } val chapterTitle = flatTableOfContents.lastOrNull { it.pageIndex <= pageIndex }?.title val finalTitle = if (!chapterTitle.isNullOrBlank()) { "$contentTitle\n$chapterTitle" } else { contentTitle } Timber.d("Final Bookmark Title: '$finalTitle'") bookmarks = bookmarks + PdfBookmark( pageIndex = pageIndex, title = finalTitle, totalPages = totalPages ) } } } val onBookmarkClick: () -> Unit = { val currentPage = if (displayMode == DisplayMode.PAGINATION) { pagerState.currentPage } else { verticalReaderState.currentPage } onToggleBookmark(currentPage) } LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) } LaunchedEffect(currentBookId) { if (currentBookId != null) { val loaded = annotationRepository.loadAnnotations(currentBookId!!) allAnnotations = loaded areAnnotationsLoaded = true val loadedBoxes = textBoxRepository.loadTextBoxes(currentBookId!!) textBoxes.clear() textBoxes.addAll(loadedBoxes) } } LaunchedEffect(textBoxes.toList()) { if (currentBookId != null) { delay(1000) withContext(Dispatchers.IO) { Timber.d("Auto-saving text boxes locally for book $currentBookId") textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList()) } } } var pendingSaveMode by remember { mutableStateOf(null) } val saveLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/pdf") ) { uri -> if (uri != null && pendingSaveMode != null) { when (pendingSaveMode) { SaveMode.ANNOTATED -> { if (currentBookId != null) { coroutineScope.launch { val currentRichTextLayouts = richTextController?.pageLayouts Timber.tag("PdfExportDebug").i("*** EXPORT TRIGGERED FROM VIEWER ***") if (currentRichTextLayouts != null) { Timber.tag("PdfExportDebug").i( "Passing ${currentRichTextLayouts.size} rich text pages." ) } viewModel.savePdfWithAnnotations( sourceUri = pdfUri, destUri = uri, annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, textBoxes = textBoxes.toList(), bookId = currentBookId!! ) } } else { Timber.tag("PdfExportDebug").e("Cannot export: currentBookId is null") } } SaveMode.ORIGINAL -> { viewModel.saveOriginalPdf(pdfUri, uri) } else -> {} } } pendingSaveMode = null } var showShareDialog by remember { mutableStateOf(false) } var showSaveDialog by remember { mutableStateOf(false) } var isShareLoading by remember { mutableStateOf(false) } var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) } var showSummarizationPopup by remember { mutableStateOf(false) } var summarizationResult by remember { mutableStateOf(null) } var isSummarizationLoading by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) } var sliderStartPage by remember { mutableIntStateOf(0) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } var startPageThumbnail by remember { mutableStateOf(null) } val speakerPlayer = remember(context, coroutineScope) { SpeakerSamplePlayer(context, coroutineScope) } var clickedLinkUrl by remember { mutableStateOf(null) } val uriHandler = LocalUriHandler.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() ttsState.currentText var showRenameBookmarkDialog by remember { mutableStateOf(null) } var isOcrModelDownloading by remember { mutableStateOf(false) } LaunchedEffect(isOcrModelDownloading) { if (isOcrModelDownloading) { delay(10_000) isOcrModelDownloading = false } } val saveStateAndExit = { val activePage = richTextController?.activePageIndex ?: -1 Timber.tag("RichTextFlow").i("System Exit: isEditMode=$isEditMode, richActivePage=$activePage") if (isLoadingDocument) { onNavigateBack() } else { ttsController.stop() coroutineScope.launch { withContext(NonCancellable) { if (richTextController != null) { Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...") richTextController.saveImmediate() } if (totalDisplayPages > 0) { onSavePosition(currentPage, totalDisplayPages) if (currentBookId != null) { annotationRepository.saveAnnotations(currentBookId!!, allAnnotations) textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList()) } val objectList = bookmarks.map { bookmark -> JSONObject().apply { put("pageIndex", bookmark.pageIndex) put("title", bookmark.title) put("totalPages", bookmark.totalPages) } } val bookmarksJson = JSONArray(objectList).toString() onBookmarksChanged(bookmarksJson) } } Timber.tag("AnnotationSync").d("Save complete. Navigating back.") onNavigateBack() } } } val onZoomChangeStable = remember { { scale: Float -> currentPageScale = scale } } val onHighlightLoadingStable = remember { { isLoading: Boolean -> isHighlightingLoading = isLoading } } val onShowDictionaryUpsellDialogStable = remember { { showDictionaryUpsellDialog = true } } val onWordSelectedForAiDefinitionStable = remember(isProUser, executeWithOcrCheck) { { text: String -> executeWithOcrCheck { selectedTextForAi = text showAiDefinitionPopup = true coroutineScope.launch { isAiDefinitionLoading = true aiDefinitionResult = null fetchAiDefinition(text = text, onUpdate = { chunk -> val currentDefinition = aiDefinitionResult?.definition ?: "" aiDefinitionResult = AiDefinitionResult( definition = currentDefinition + chunk ) }, onError = { error -> aiDefinitionResult = AiDefinitionResult(error = error) }, onFinish = { isAiDefinitionLoading = false }) } } } } val onLinkClickedStable = remember { { url: String -> clickedLinkUrl = url } } val onInternalLinkNavStable = remember(displayMode) { { targetPage: Int -> coroutineScope.launch { if (targetPage in 0 until totalPages) { if (displayMode == DisplayMode.PAGINATION) { pagerState.animateScrollToPage(targetPage) } else { verticalReaderState.scrollToPage(targetPage) } } } Unit } } val onBookmarkClickStable = remember(bookmarks, pdfDocument, currentBookId, flatTableOfContents, totalPages) { { pageIndex: Int -> onToggleBookmark(pageIndex) } } val onOcrStateChangeStable = remember { { isScanning: Boolean -> onOcrStateChange(isScanning) } } val onGetOcrSearchRectsStable = remember(pdfTextRepository, pdfDocument) { val callback: suspend (Int, String) -> List = { page, query -> if (pdfDocument != null) { val hasNative = pdfTextRepository.hasNativeText(pdfDocument!!, page) if (!hasNative) { pdfTextRepository.getOcrSearchRects( document = pdfDocument!!, pageIndex = page, query = query, onModelDownloading = { isOcrModelDownloading = true }) } else { emptyList() } } else { emptyList() } } callback } suspend fun summarizeCurrentPage( onUpdate: (SummarizationResult) -> Unit, onFinish: () -> Unit ) { val currentPageIndex = currentPage val virtualPage = if (virtualPages.isNotEmpty() && currentPageIndex in virtualPages.indices) { virtualPages[currentPageIndex] } else { null } if (virtualPage is VirtualPage.BlankPage) { onUpdate(SummarizationResult(error = "Cannot summarize a blank page.")) onFinish() return } val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: currentPageIndex val doc = pdfDocument ?: run { onUpdate(SummarizationResult(error = "Document not loaded.")) onFinish() return } Timber.d( "Starting summarization for PDF page: $pdfPageIndex (Display Page: $currentPageIndex)" ) withContext(Dispatchers.IO) { var pageBitmap: Bitmap? = null var connection: HttpURLConnection? = null try { pageBitmap = renderPageToBitmap(doc, pdfPageIndex) if (pageBitmap == null) { throw Exception("Could not render page to bitmap.") } val outputStream = ByteArrayOutputStream() pageBitmap.compress(Bitmap.CompressFormat.JPEG, 85, outputStream) val imageBytes = outputStream.toByteArray() val base64Image = Base64.encodeToString(imageBytes, Base64.NO_WRAP) pageBitmap.recycle() pageBitmap = null val url = URL(summarizationUrl) connection = url.openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 15000 connection.readTimeout = 180000 connection.doOutput = true connection.doInput = true val jsonPayload = JSONObject().apply { put("content_type", "image") put("data", base64Image) } connection.outputStream.use { os -> os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) } val responseCode = connection.responseCode Timber.d("Summarization API response code: $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { val fullText = StringBuilder() var lastResult: SummarizationResult? = null connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> var line: String? while (reader.readLine().also { line = it } != null) { try { val jsonResponse = JSONObject(line!!) jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { fullText.append(it) lastResult = SummarizationResult(summary = fullText.toString()) @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") onUpdate(lastResult!!) } jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let { lastResult = SummarizationResult(error = it) onUpdate(lastResult) } } catch (e: Exception) { Timber.w(e, "Could not parse stream line: $line") } } } if (fullText.isEmpty() && lastResult?.error == null) { onUpdate( SummarizationResult( error = "Failed to parse summary from server response." ) ) } } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } Timber.e("Summarization API error: $responseCode. Body: $errorBody") val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not fetch summary." } onUpdate( SummarizationResult( error = "Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}" ) ) } } catch (e: Exception) { Timber.e(e, "Exception during PDF page summarization: ${e.message}") onUpdate(SummarizationResult(error = "An error occurred: ${e.localizedMessage}")) } finally { pageBitmap?.recycle() connection?.disconnect() onFinish() } } } fun isAnnotationHit( annotation: PdfAnnotation, hitPoint: PdfPoint, threshold: Float = 0.05f ): Boolean { return annotation.points.any { p -> val dx = p.x - hitPoint.x val dy = p.y - hitPoint.y (dx * dx + dy * dy) < (threshold * threshold) } } fun startTts(pageToReadOverride: Int? = null) { Timber.d("TTS button clicked: Starting TTS for current page") if (pdfDocument == null || totalPages == 0) { return } coroutineScope.launch { val pageToRead = pageToReadOverride ?: currentPage var rawPageText: String? = null var tempPage: PdfPageKt? = null var tempTextPage: PdfTextPageKt? = null var ocrAttempted = false try { withContext(Dispatchers.IO) { Timber.d("TTS: Opening page $pageToRead for Pdfium text extraction.") tempPage = pdfDocument!!.openPage(pageToRead) tempTextPage = tempPage.openTextPage() val charCount = tempTextPage.textPageCountChars() if (charCount > 0) { rawPageText = tempTextPage.textPageGetText(0, charCount)?.trim() if (rawPageText.isNullOrBlank()) { Timber.d( "TTS: Pdfium extracted text but it's blank (charCount: $charCount)." ) } else { Timber.d( "TTS: Text extracted via Pdfium (length: ${rawPageText.length})." ) } } else { Timber.d( "TTS: No characters found by Pdfium (charCount is 0) for page $pageToRead." ) } } } catch (e: Exception) { Timber.e(e, "TTS: Error extracting text via Pdfium for page $pageToRead") } finally { withContext(Dispatchers.IO) { tempTextPage?.close() } } if (rawPageText.isNullOrBlank()) { Timber.i( "TTS: Pdfium text is blank or extraction failed. Attempting OCR for page $pageToRead." ) ocrAttempted = true ocrUsedForCurrentPageTts = true var ocrBitmap: Bitmap? = null try { if (tempPage == null) { withContext(Dispatchers.IO) { tempPage?.close() Timber.d("TTS/OCR: Re-opening page $pageToRead for bitmap rendering.") tempPage = pdfDocument!!.openPage(pageToRead) } } val pageForOcr = tempPage ?: throw IllegalStateException( "TTS/OCR: PDF page couldn't be opened for OCR." ) val ocrBitmapWidth = 1080 val ocrBitmapHeight: Int withContext(Dispatchers.IO) { val originalWidthPoints = pageForOcr.getPageWidthPoint() val originalHeightPoints = pageForOcr.getPageHeightPoint() if (originalWidthPoints <= 0 || originalHeightPoints <= 0) { throw IllegalStateException( "TTS/OCR: Invalid page dimensions (points) from Pdfium for page $pageToRead." ) } val aspectRatio = originalWidthPoints.toFloat() / originalHeightPoints.toFloat() ocrBitmapHeight = (ocrBitmapWidth / aspectRatio).toInt() if (ocrBitmapHeight <= 0) { throw IllegalStateException( "TTS/OCR: Calculated invalid bitmap dimensions for OCR ($ocrBitmapWidth x $ocrBitmapHeight) for page $pageToRead." ) } Timber.d( "TTS/OCR: Rendering page $pageToRead to bitmap of size ${ocrBitmapWidth}x$ocrBitmapHeight." ) ocrBitmap = createBitmap(ocrBitmapWidth, ocrBitmapHeight) pageForOcr.renderPageBitmap( bitmap = ocrBitmap, startX = 0, startY = 0, drawSizeX = ocrBitmapWidth, drawSizeY = ocrBitmapHeight, renderAnnot = false ) } Timber.d("TTS/OCR: Bitmap rendered for page $pageToRead. Attempting OCR.") rawPageText = OcrHelper.extractTextFromBitmap(ocrBitmap!!) { isOcrModelDownloading = true }?.text if (!rawPageText.isNullOrBlank()) { Timber.i( "TTS: Text extracted via OCR for page $pageToRead (length: ${rawPageText?.length})." ) } else { Timber.w( "TTS: OCR process completed for page $pageToRead but returned no text or blank text." ) } } catch (e: Exception) { Timber.e(e, "TTS: Error during OCR process for page $pageToRead") } finally { ocrBitmap?.recycle() withContext(Dispatchers.IO) { tempPage?.close() Timber.d("TTS/OCR: Closed page $pageToRead after OCR attempt.") } } } else { ocrUsedForCurrentPageTts = false withContext(Dispatchers.IO) { tempPage?.close() Timber.d( "TTS: Closed page $pageToRead after successful Pdfium text extraction." ) } } if (rawPageText != null && rawPageText!!.isNotBlank()) { val processedText = preprocessTextForTts(rawPageText!!) ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts) val chunks = splitTextIntoChunks(processedText.cleanText) val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } ?: pdfUri.lastPathSegment ?: "PDF Document" val pageTitle = "Page ${pageToRead + 1}" val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } ttsController.start( chunks = ttsChunks, bookTitle = bookTitle, chapterTitle = pageTitle, coverImageUri = null, ttsMode = TtsMode.BASE.name, playbackSource = "READER" ) if (isAutoPagingForTts) { delay(500) isAutoPagingForTts = false } } else { val finalError = when { ocrAttempted -> "OCR found no text on this page." else -> "Page seems empty or text not extractable." } ttsController.stop() isAutoPagingForTts = false Timber.w("TTS start failed: $finalError") } } } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { _ -> startTts() }) var showPermissionRationaleDialog by remember { mutableStateOf(false) } DisposableEffect(Unit) { onDispose { Timber.d("Disposing sample MediaPlayer.") speakerPlayer.release() PdfBitmapPool.clear() PdfThumbnailCache.clear() } } LaunchedEffect(ttsState.sessionFinished) { if (ttsState.sessionFinished && ttsState.playbackSource == "READER") { val lastPlayedPage = ttsPageData?.pageIndex ?: (currentPage - 1) val nextPage = lastPlayedPage + 1 if (nextPage < totalPages) { when (displayMode) { DisplayMode.PAGINATION -> { Timber.d("TTS auto-paging to next page: ${nextPage + 1}") isAutoPagingForTts = true coroutineScope.launch { pagerState.animateScrollToPage(nextPage) } } DisplayMode.VERTICAL_SCROLL -> { Timber.d("TTS auto-starting on next page (no scroll): ${nextPage + 1}") startTts(pageToReadOverride = nextPage) } } } else { Timber.d("TTS finished on the last page.") } } } LaunchedEffect(isPageSliderVisible) { if (isPageSliderVisible) { val doc = pdfDocument if (doc != null && totalPages > 0) { Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage") startPageThumbnail = renderPageToBitmap(doc, sliderStartPage) Timber.d( "Thumbnail rendering complete. Is bitmap null: ${startPageThumbnail == null}" ) } } else { Timber.d("Slider hidden. Clearing thumbnail.") startPageThumbnail?.recycle() startPageThumbnail = null } } LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) { val currentText = ttsState.currentText val currentTtsData = ttsPageData val chunkIndex = ttsState.startOffsetInSource if (currentText == null || currentTtsData == null) { ttsHighlightData = null return@LaunchedEffect } if (currentTtsData.fromOcr) { ttsHighlightData = TtsHighlightData.Ocr(currentText) } else { var cleanStartIndex = -1 if (chunkIndex >= 0) { val chunks = splitTextIntoChunks(currentTtsData.processedText.cleanText) if (chunkIndex < chunks.size) { var runningIndex = 0 for (i in 0 until chunkIndex) { val prevChunk = chunks[i] val foundAt = currentTtsData.processedText.cleanText.indexOf( prevChunk, runningIndex ) if (foundAt != -1) { runningIndex = foundAt + prevChunk.length } } cleanStartIndex = currentTtsData.processedText.cleanText.indexOf( currentText, runningIndex ) } } if (cleanStartIndex == -1) { cleanStartIndex = currentTtsData.processedText.cleanText.indexOf(currentText) } if (cleanStartIndex != -1) { val cleanEndIndex = cleanStartIndex + currentText.length if (cleanEndIndex <= currentTtsData.processedText.indexMap.size) { val originalStartIndex = currentTtsData.processedText.indexMap[cleanStartIndex] val originalEndIndex = currentTtsData.processedText.indexMap[cleanEndIndex - 1] val originalLength = originalEndIndex - originalStartIndex + 1 ttsHighlightData = TtsHighlightData.Pdfium(originalStartIndex, originalLength) } else { ttsHighlightData = null } } else { ttsHighlightData = null } } } LaunchedEffect(pdfUri, pdfiumCore, documentPassword) { Timber.d("LaunchedEffect: Loading PDF document for URI: $pdfUri") isLoadingDocument = true isDocumentReady = false errorMessage = null if (showPasswordDialog) isPasswordError = false ttsController.stop() ocrUsedForCurrentPageTts = false flatTableOfContents = emptyList() val fastId = getFastFileId(context, pdfUri) currentBookId = fastId val oldDoc = pdfDocument val oldPfd = pfdState pdfDocument = null pfdState = null totalPages = 0 if (oldDoc != null || oldPfd != null) { withContext(Dispatchers.IO) { oldDoc?.let { try { it.close() } catch (e: Exception) { Timber.e(e) } } oldPfd?.let { try { it.close() } catch (e: Exception) { Timber.e(e) } } } } var currentPfdOpened: ParcelFileDescriptor? = null try { withContext(Dispatchers.IO) { Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri") currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r") if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") val doc = pdfiumCore.newDocument(currentPfdOpened, documentPassword) if (!isActive) { doc.close() currentPfdOpened.close() return@withContext } pdfDocument = doc pfdState = currentPfdOpened val pagesCount = doc.getPageCount() totalPages = pagesCount if (pagesCount > 0) { val cachedRatios = pdfTextRepository.getPageRatios(currentBookId!!) val ratios = if (cachedRatios != null && cachedRatios.size == pagesCount) { Timber.i("Loaded ${cachedRatios.size} page ratios from cache.") cachedRatios } else { val computedRatios = ArrayList(pagesCount) doc.openPage(0).use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = if (height > 0) width.toFloat() / height.toFloat() else 1.0f repeat(pagesCount) { computedRatios.add(ratio) } } launch(Dispatchers.IO) { val refinedRatios = ArrayList(computedRatios) var hasChanges = false for (i in 0 until pagesCount) { if (!isActive) break try { doc.openPage(i).use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = if (height > 0) width.toFloat() / height.toFloat() else 1.0f if (refinedRatios[i] != ratio) { refinedRatios[i] = ratio hasChanges = true } } } catch (e: Exception) { Timber.w(e, "Failed to calculate ratio for page $i") } } if (hasChanges && isActive) { withContext(Dispatchers.Main) { pageAspectRatios = ArrayList(refinedRatios) } // Save to cache pdfTextRepository.savePageRatios( currentBookId!!, refinedRatios ) } } computedRatios } pageAspectRatios = ratios isDocumentReady = true isLoadingDocument = false withContext(Dispatchers.Main) { showPasswordDialog = false isPasswordError = false } launch(Dispatchers.IO) { val refinedRatios = ArrayList(pageAspectRatios) var hasChanges = false for (i in 1 until pagesCount) { if (!isActive) break try { doc.openPage(i).use { page -> val width = page.getPageWidthPoint() val height = page.getPageHeightPoint() val ratio = if (height > 0) width.toFloat() / height.toFloat() else 1.0f if (refinedRatios[i] != ratio) { refinedRatios[i] = ratio hasChanges = true } } if (hasChanges && (i % 500 == 0 || i == pagesCount - 1)) { withContext(Dispatchers.Main) { pageAspectRatios = ArrayList(refinedRatios) } hasChanges = false } } catch (e: Exception) { Timber.w(e, "Failed to calculate ratio for page $i") } } } launch(Dispatchers.IO) { try { val tableOfContents = doc.getTableOfContents() val flattened = flattenToc(tableOfContents) withContext(Dispatchers.Main) { flatTableOfContents = flattened } } catch (e: Exception) { Timber.w(e, "Failed to load TOC") } } } else { isDocumentReady = true isLoadingDocument = false } Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.") } } catch (e: Exception) { if (e is PdfPasswordException || e.cause is PdfPasswordException) { Timber.w("PDF is password protected or password incorrect.") withContext(Dispatchers.Main) { if (documentPassword != null) { isPasswordError = true showPasswordDialog = true } else { showPasswordDialog = true } isLoadingDocument = false } } else { Timber.e(e, "Error loading PDF document") errorMessage = "Error loading PDF: ${e.localizedMessage}" isLoadingDocument = false } if (pdfDocument == null) { currentPfdOpened?.let { try { it.close() } catch (_: Exception) { } } pfdState = null } } } LaunchedEffect(pagerState.isScrollInProgress) { if (pagerState.isScrollInProgress && showBars) { showBars = false Timber.d("Pager scroll detected, hiding bars.") } } LaunchedEffect(pagerState.isScrollInProgress) { if (pagerState.isScrollInProgress) { if (showBars) { showBars = false Timber.d("Pager scroll detected, hiding bars.") } if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) { ttsController.stop() } } } var previousPage by remember(displayMode) { mutableIntStateOf(-1) } LaunchedEffect(currentPage) { if (previousPage != -1 && previousPage != currentPage) { if (isAutoPagingForTts) { startTts() } else if (displayMode == DisplayMode.PAGINATION && (ttsState.isPlaying || ttsState.isLoading)) { Timber.d("Page changed manually while TTS active, stopping.") ttsController.stop() } } previousPage = currentPage } LaunchedEffect(pagerState.currentPage) { currentPageScale = 1f ocrUsedForCurrentPageTts = false } DisposableEffect(Unit) { onDispose { Timber.d("DisposableEffect: Screen disposing. Closing PDF document and PFD.") ttsController.stop() PdfBitmapPool.clear() PdfThumbnailCache.clear() val docToClose = pdfDocument val pfdToClose = pfdState pdfDocument = null pfdState = null if (docToClose != null || pfdToClose != null) { coroutineScope.launch(Dispatchers.IO) { docToClose?.let { Timber.d("Closing PDF document in onDispose.") try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing document in onDispose") } } pfdToClose?.let { Timber.d("Closing ParcelFileDescriptor in onDispose: $it") try { it.close() } catch (e: Exception) { Timber.e(e, "Error closing ParcelFileDescriptor in onDispose") } } } } } } var searchHighlightTarget by remember { mutableStateOf(null) } var isOcrScanning by remember { mutableStateOf(false) } LaunchedEffect(pdfUri, currentBookId, totalPages) { if (currentBookId == null || totalPages == 0) return@LaunchedEffect if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect withContext(Dispatchers.IO) { val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!) if (storedLang == null) { pdfTextRepository.setBookLanguage(currentBookId!!, ocrLanguage.name) } isBackgroundIndexing = true var bgPfd: ParcelFileDescriptor? = null var bgDoc: PdfDocumentKt? = null try { val existingPages = pdfTextRepository.getIndexedPages(currentBookId!!) val initialIndexedCount = existingPages.size if (existingPages.size >= totalPages) { Timber.d("Indexer: All pages already indexed.") isBackgroundIndexing = false backgroundIndexingProgress = 1f return@withContext } Timber.d( "Indexer: Starting background indexing for ${totalPages - existingPages.size} pages." ) bgPfd = context.contentResolver.openFileDescriptor(pdfUri, "r") if (bgPfd != null) { bgDoc = pdfiumCore.newDocument(bgPfd, documentPassword) val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) } val totalToDo = pagesToIndex.size var completed = 0 for (pageIndex in pagesToIndex) { if (!isActive) break try { pdfTextRepository.indexPage( bookId = currentBookId!!, document = bgDoc, pageIndex = pageIndex, onOcrModelDownloading = { isOcrModelDownloading = true }) } catch (e: Exception) { Timber.e(e, "Indexer: Failed on page $pageIndex") } completed++ if (completed % 5 == 0 || completed == totalToDo) { val totalIndexedSoFar = initialIndexedCount + completed backgroundIndexingProgress = totalIndexedSoFar.toFloat() / totalPages.toFloat() } } } } catch (e: Exception) { Timber.e(e, "Indexer: Fatal error") } finally { try { bgDoc?.close() bgPfd?.close() } catch (e: Exception) { Timber.e(e, "Indexer: Cleanup failed") } isBackgroundIndexing = false } } } var activeQuery by remember { mutableStateOf("") } val dummySearcher: suspend (String) -> List = { emptyList() } val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher) var smartSearchResult by remember { mutableStateOf(null) } var currentPdfSearchResult by remember { mutableStateOf(null) } val density = LocalDensity.current val navBarHeight = WindowInsets.systemBars.getBottom(density) val imeHeight = WindowInsets.ime.getBottom(density) val bottomScrollLimitPx = remember(isEditMode, imeHeight, navBarHeight, dockLocation, isDockMinimized) { if (isEditMode) { if (imeHeight > 0) { imeHeight.toFloat() } else { if (dockLocation == DockLocation.BOTTOM && !isDockMinimized) { with(density) { 100.dp.toPx() } } else { with(density) { 16.dp.toPx() } + navBarHeight } } } else { if (showBars) { with(density) { 56.dp.toPx() } + navBarHeight } else { navBarHeight.toFloat() } } } val statusBarHeight = WindowInsets.statusBars.getTop(density) val topBarHeightPx = with(density) { 56.dp.toPx() } val topScrollLimitPx = remember(showBars, statusBarHeight) { if (showBars) { statusBarHeight + topBarHeightPx } else { 0f } } LaunchedEffect(searchState.searchQuery, currentBookId) { val query = searchState.searchQuery if (query.isBlank() || currentBookId == null) { smartSearchResult = null currentPdfSearchResult = null return@LaunchedEffect } pdfTextRepository.searchBookSmart(currentBookId!!, query).conflate().collect { result -> smartSearchResult = result } } fun parseSnippet(rawSnippet: String): AnnotatedString { return buildAnnotatedString { val boldStyle = SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue) val regex = "(.*?)".toRegex() val matches = regex.findAll(rawSnippet) var lastAppendPosition = 0 for (match in matches) { append(rawSnippet.substring(lastAppendPosition, match.range.first)) val content = match.groupValues[1] pushStyle(boldStyle) append(content) pop() lastAppendPosition = match.range.last + 1 } if (lastAppendPosition < rawSnippet.length) { append(rawSnippet.substring(lastAppendPosition)) } } } LaunchedEffect(activeQuery, currentBookId) { val query = activeQuery if (query.isBlank() || currentBookId == null) { searchState.searchResults = emptyList() searchState.isSearchInProgress = false return@LaunchedEffect } searchState.isSearchInProgress = true delay(300) pdfTextRepository.searchBookFlow(currentBookId!!, query).conflate().collect { matches -> val results = mutableListOf() val regexPattern = try { Regex("(?i)\\b${Regex.escape(query)}") } catch (_: Exception) { Regex("(?i)${Regex.escape(query)}") } matches.forEach { match -> val regexMatches = regexPattern.findAll(match.content) var hasFoundMatch = false regexMatches.forEachIndexed { occurrenceIndex, _ -> hasFoundMatch = true results.add( SearchResult( locationInSource = match.pageIndex, locationTitle = "Page ${match.pageIndex + 1}", snippet = parseSnippet(match.snippet), query = query, occurrenceIndexInLocation = occurrenceIndex, chunkIndex = match.pageIndex ) ) } if (!hasFoundMatch) { results.add( SearchResult( locationInSource = match.pageIndex, locationTitle = "Page ${match.pageIndex + 1}", snippet = parseSnippet(match.snippet), query = query, occurrenceIndexInLocation = 0, chunkIndex = match.pageIndex ) ) } } searchState.searchResults = results searchState.isSearchInProgress = false delay(250) } } val isTtsSessionActive = (ttsState.currentText != null || ttsState.isLoading) && ttsState.playbackSource == "READER" val onInternalLinkNav: (Int) -> Unit = { targetPage -> coroutineScope.launch { if (targetPage in 0 until totalPages) { if (displayMode == DisplayMode.PAGINATION) { pagerState.animateScrollToPage(targetPage) } else { verticalReaderState.scrollToPage(targetPage) } } } } val paginationDraggingOriginPage = remember(paginationDraggingBoxId, textBoxes) { if (paginationDraggingBoxId == null) null else textBoxes.find { it.id == paginationDraggingBoxId }?.pageIndex } val dynamicBeyondViewportPageCount = remember( paginationDraggingOriginPage, pagerState.currentPage ) { if (paginationDraggingOriginPage != null) { val distance = kotlin.math.abs(pagerState.currentPage - paginationDraggingOriginPage) (distance + 1).coerceAtLeast(1) } else { 1 } } fun navigateToPdfSearchResult(result: SearchResult) { currentPdfSearchResult = result searchHighlightTarget = result coroutineScope.launch { if (displayMode == DisplayMode.PAGINATION) { if (pagerState.currentPage != result.locationInSource) { pagerState.scrollToPage(result.locationInSource) } } else { verticalReaderState.scrollToPage(result.locationInSource) } } } LaunchedEffect(searchState.isSearchActive) { if (searchState.isSearchActive) { delay(100) focusRequester.requestFocus() } else { searchHighlightTarget = null } } BackHandler(enabled = true) { when { showPasswordDialog -> { onNavigateBack() } isFullScreen -> { isFullScreen = false } showReindexDialog != null -> showReindexDialog = null drawerState.isOpen -> { coroutineScope.launch { drawerState.close() } } isEditMode -> { richTextController?.clearSelection() isEditMode = false showBars = true } showSummarizationPopup -> showSummarizationPopup = false showPermissionRationaleDialog -> showPermissionRationaleDialog = false showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false showAiDefinitionPopup -> showAiDefinitionPopup = false showDictionaryUpsellDialog -> showDictionaryUpsellDialog = false isPageSliderVisible -> { isPageSliderVisible = false showBars = true } searchState.isSearchActive -> { searchState.isSearchActive = false searchState.onQueryChange("") } else -> { saveStateAndExit() } } } ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { val drawerPagerState = rememberPagerState(pageCount = { 2 }) 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") ) } 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 currentTocEntry by remember( pagerState.currentPage, flatTableOfContents ) { derivedStateOf { flatTableOfContents.lastOrNull { it.pageIndex <= pagerState.currentPage } } } LazyColumn(modifier = Modifier.fillMaxHeight()) { itemsIndexed( items = flatTableOfContents, key = { index, entry -> "toc_${index}_${entry.pageIndex}_${entry.title.hashCode()}" }) { _, entry -> val isCurrentChapter = entry == currentTocEntry ListItem( headlineContent = { Text( entry.title, fontWeight = if (isCurrentChapter) FontWeight.Bold else FontWeight.Normal, modifier = Modifier.padding( start = (16 * entry.nestLevel).dp ) ) }, colors = if (isCurrentChapter) { ListItemDefaults.colors( containerColor = MaterialTheme.colorScheme.primaryContainer, headlineColor = MaterialTheme.colorScheme.onPrimaryContainer ) } else { ListItemDefaults.colors() }, modifier = Modifier.clickable { coroutineScope.launch { drawerState.close() if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage( entry.pageIndex ) } else { verticalReaderState.scrollToPage( entry.pageIndex ) } } }) HorizontalDivider() } } } } 1 -> { // Bookmarks Page if (bookmarks.isEmpty()) { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.Center ) { Text( "You haven't added any bookmarks yet.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) } } else { var bookmarkMenuExpandedFor by remember { mutableStateOf(null) } var showDeleteConfirmDialogFor by remember { mutableStateOf(null) } val sortedBookmarks = remember(bookmarks) { bookmarks.sortedBy { it.pageIndex } } LazyColumn(modifier = Modifier.fillMaxSize()) { itemsIndexed( items = sortedBookmarks, key = { index, bookmark -> "bm_${index}_${bookmark.pageIndex}" }) { _, bookmark -> ListItem(headlineContent = { Text( bookmark.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, supportingContent = { Text( "Page ${bookmark.pageIndex + 1} of ${bookmark.totalPages}", style = MaterialTheme.typography.bodySmall ) }, trailingContent = { Box { IconButton( onClick = { bookmarkMenuExpandedFor = bookmark }) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = "More options for bookmark" ) } DropdownMenu( expanded = bookmarkMenuExpandedFor == bookmark, onDismissRequest = { bookmarkMenuExpandedFor = null }) { DropdownMenuItem(text = { Text("Rename") }, onClick = { showRenameBookmarkDialog = bookmark bookmarkMenuExpandedFor = null }) DropdownMenuItem(text = { Text("Delete") }, onClick = { showDeleteConfirmDialogFor = bookmark bookmarkMenuExpandedFor = null }) } } }, modifier = Modifier .clickable { coroutineScope.launch { drawerState.close() if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage( bookmark.pageIndex ) } else { verticalReaderState.scrollToPage( bookmark.pageIndex ) } } } .testTag( "BookmarkItem_${bookmark.pageIndex}" )) HorizontalDivider() } } showRenameBookmarkDialog?.let { bookmarkToRename -> var newTitle by remember { mutableStateOf("") } AlertDialog(onDismissRequest = { showRenameBookmarkDialog = null }, title = { Text("Rename Bookmark") }, text = { OutlinedTextField( value = newTitle, onValueChange = { newTitle = it }, label = { Text("New Title") }, placeholder = { Text( text = bookmarkToRename.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.6f ) ) }, singleLine = true, modifier = Modifier.fillMaxWidth() ) }, confirmButton = { TextButton( onClick = { if (newTitle.isNotBlank()) { val updatedBookmark = bookmarkToRename.copy( title = newTitle ) bookmarks = (bookmarks - bookmarkToRename) + updatedBookmark } showRenameBookmarkDialog = null }) { Text("Save") } }, dismissButton = { TextButton( onClick = { showRenameBookmarkDialog = null }) { Text("Cancel") } }) } showDeleteConfirmDialogFor?.let { bookmarkToDelete -> AlertDialog(onDismissRequest = { showDeleteConfirmDialogFor = null }, title = { Text("Delete Bookmark?") }, text = { Text( "Are you sure you want to permanently delete this bookmark?" ) }, confirmButton = { TextButton( onClick = { bookmarks = bookmarks - bookmarkToDelete showDeleteConfirmDialogFor = null }) { Text("Delete") } }, dismissButton = { TextButton( onClick = { showDeleteConfirmDialogFor = null }) { Text("Cancel") } }) } } } } } } } }) { Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> BoxWithConstraints(modifier = Modifier .fillMaxSize() .padding(paddingValues)) { IntSize(constraints.maxWidth, constraints.maxHeight) val boxConstraints = constraints val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat() val boxMaxHeightFloat = boxConstraints.maxHeight.toFloat() if (richTextController != null && isEditMode && selectedTool == InkType.TEXT) { BasicTextField( value = richTextController.editingValue, onValueChange = { newValue -> richTextController.onValueChanged(newValue) }, textStyle = TextStyle( color = richTextController.currentStyle.color, fontSize = richTextController.currentStyle.fontSize, fontWeight = richTextController.currentStyle.fontWeight, fontStyle = richTextController.currentStyle.fontStyle, textDecoration = richTextController.currentStyle.textDecoration ), modifier = Modifier .size(1.dp) .alpha(0f) .clearAndSetSemantics { } .focusRequester(richTextController.focusRequester) .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && event.key == Key.Backspace) { val handled = richTextController.handleBackspaceAtStart() if (handled) Timber.tag("RichTextFlow").d("KeyEvent: Backspace consumed by controller") handled } else { false } } .align(Alignment.TopStart), ) } // --- Main Content Area --- Box( modifier = Modifier .fillMaxSize() .padding( top = verticalHeaderHeight, bottom = verticalFooterHeight ) ) { when { isLoadingDocument -> { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } errorMessage != null -> { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.Center ) { Text( text = errorMessage ?: "Failed to load PDF.", color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp) ) } } pdfDocument != null && totalPages > 0 -> { val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) } when (displayMode) { DisplayMode.PAGINATION -> { Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), key = { it }, beyondViewportPageCount = dynamicBeyondViewportPageCount, userScrollEnabled= currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null ) { pageIndex -> val isVisiblePage = remember(pagerState.currentPage, pageIndex) { kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1 } val isPageBookmarked by remember(bookmarks, pageIndex) { derivedStateOf { bookmarks.any { it.pageIndex == pageIndex } } } var ocrHighlightRects by remember { mutableStateOf>(emptyList()) } LaunchedEffect(searchHighlightTarget, pageIndex) { val target = searchHighlightTarget ocrHighlightRects = emptyList() if (target != null && target.locationInSource == pageIndex) { Timber.d( "LaunchedEffect triggered for Page $pageIndex. Checking Native..." ) val hasNative = pdfTextRepository.hasNativeText( pdfDocument!!, pageIndex ) Timber.d( "Page $pageIndex Has Native Text: $hasNative" ) if (!hasNative) { Timber.d( "Fetching OCR rects for query: '${target.query}'" ) val rects = pdfTextRepository.getOcrSearchRects( document = pdfDocument!!, pageIndex = pageIndex, query = target.query, onModelDownloading = { isOcrModelDownloading = true }) Timber.d( "Received ${rects.size} rects from Repository." ) ocrHighlightRects = rects } else { Timber.d( "Native text present. Skipping OCR highlighting." ) } } } val pageAnnotationsProvider = remember(pageIndex, allAnnotationsProvider) { { allAnnotationsProvider()[pageIndex] ?: emptyList() } } val stableOcrRects = remember(ocrHighlightRects) { StableHolder(ocrHighlightRects) } val currentSelectedTool by rememberUpdatedState(selectedTool) @Suppress("ControlFlowWithEmptyBody") val onDrawPagination = remember(pageIndex) { { point: PdfPoint -> if (currentSelectedTool == InkType.TEXT) { } else if (currentSelectedTool == InkType.ERASER) { val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { isAnnotationHit(it, point) } if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( pageIndex ) { mutableListOf() } batch.addAll(toRemove) val newList = existing - toRemove.toSet() allAnnotations = allAnnotations + (pageIndex to newList) } } else { val pointWithTime = point.copy( timestamp = System.currentTimeMillis() ) drawingState.onDraw(pointWithTime) } } } val currentStrokeColorState by rememberUpdatedState( currentStrokeColor ) val currentStrokeWidthState by rememberUpdatedState( currentStrokeWidth ) @Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination = remember(pageIndex) { { point: PdfPoint -> if (showToolSettings) { showToolSettings = false } else { if (currentSelectedTool == InkType.TEXT) { } else if (currentSelectedTool == InkType.ERASER) { erasedAnnotationsFromStroke.clear() val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { isAnnotationHit( it, point ) } if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( pageIndex ) { mutableListOf() } batch.addAll(toRemove) val newList = existing - toRemove.toSet() allAnnotations = allAnnotations + (pageIndex to newList) } } else { val pointWithTime = point.copy( timestamp = System.currentTimeMillis() ) drawingState.onDrawStart( pageIndex, pointWithTime, currentSelectedTool, currentStrokeColorState, currentStrokeWidthState ) } } } } val virtualPage = if (virtualPages.isNotEmpty()) virtualPages.getOrNull( pageIndex ) else VirtualPage.PdfPage(pageIndex) PdfPageComposable( pdfDocument = stablePdfDocument, pageIndex = pageIndex, virtualPage = virtualPage, totalPages = totalDisplayPages, isDarkMode = isPdfDarkMode, isScrollLocked = isScrollLocked, onScaleChanged = { newScale -> if (pagerState.currentPage == pageIndex) { currentPageScale = newScale } }, ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData else null, searchQuery = searchState.searchQuery, searchHighlightMode = searchHighlightMode, searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null, ocrHoverHighlights = stableOcrRects, modifier = Modifier.fillMaxSize(), showAllTextHighlights = showAllTextHighlights, onHighlightLoading = { /* no-op for paginated mode */ }, onSingleTap = onSingleTapStable, isProUser = isProUser, onShowDictionaryUpsellDialog = { showDictionaryUpsellDialog = true }, onWordSelectedForAiDefinition = { text -> selectedTextForAi = text showAiDefinitionPopup = true coroutineScope.launch { isAiDefinitionLoading = true aiDefinitionResult = null fetchAiDefinition( text = text, onUpdate = { chunk -> val currentDefinition = aiDefinitionResult?.definition ?: "" aiDefinitionResult = AiDefinitionResult( definition = currentDefinition + chunk ) }, onError = { error -> aiDefinitionResult = AiDefinitionResult( error = error ) }, onFinish = { isAiDefinitionLoading = false }) } }, onOcrStateChange = onOcrStateChange, onLinkClicked = { url -> clickedLinkUrl = url }, onInternalLinkClicked = onInternalLinkNav, isBookmarked = isPageBookmarked, onBookmarkClick = { onToggleBookmark(pageIndex) }, isZoomEnabled = true, clearSelectionTrigger = selectionClearTrigger, pageAnnotations = pageAnnotationsProvider, drawingState = drawingState, onDrawStart = onDrawStartPagination, onDraw = onDrawPagination, selectedTool = selectedTool, onDrawEnd = { val finalAnnotation = drawingState.onDrawEnd() if (finalAnnotation != null) { val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() allAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation ) ) redoStack.clear() } if (selectedTool == InkType.ERASER && erasedAnnotationsFromStroke.isNotEmpty()) { val removalMap = erasedAnnotationsFromStroke.mapValues { it.value.toList() } undoStack.add( HistoryAction.Remove(removalMap) ) redoStack.clear() erasedAnnotationsFromStroke.clear() } }, onOcrModelDownloading = { isOcrModelDownloading = true }, onTwoFingerSwipe = { direction -> coroutineScope.launch { val targetPage = pagerState.currentPage + direction if (targetPage in 0 until totalDisplayPages) { pagerState.animateScrollToPage( targetPage ) } } }, richTextController = richTextController, isEditMode = isDrawingActive, textBoxes = textBoxes.filter { it.pageIndex == pageIndex }, selectedTextBoxId = selectedTextBoxId, onTextBoxChange = { updatedBox -> val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } if (idx != -1) textBoxes[idx] = updatedBox }, onTextBoxSelect = { id -> selectedTextBoxId = id richTextController?.clearSelection() }, draggingBoxId = paginationDraggingBoxId, onTextBoxDragStart = { box, _, _ -> val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f } val containerWidthPx = boxConstraints.maxWidth val containerHeightPx = boxConstraints.maxHeight var renderedWidthInt = containerWidthPx var renderedHeightInt = (renderedWidthInt / pageAspectRatio).toInt() if (renderedHeightInt > containerHeightPx) { renderedHeightInt = containerHeightPx renderedWidthInt = (renderedHeightInt * pageAspectRatio).toInt() } val renderedWidth = renderedWidthInt.toFloat() val renderedHeight = renderedHeightInt.toFloat() val offsetX = (containerWidthPx - renderedWidth) / 2f val offsetY = (containerHeightPx - renderedHeight) / 2f paginationDraggingBoxId = box.id paginationOriginalRelSize = Size(box.relativeBounds.width, box.relativeBounds.height) paginationDraggingSize = Size( box.relativeBounds.width * renderedWidth, box.relativeBounds.height * renderedHeight ) paginationDraggingOffset = Offset( offsetX + (box.relativeBounds.left * renderedWidth), offsetY + (box.relativeBounds.top * renderedHeight) ) paginationDragPageHeight = renderedHeight }, onTextBoxDrag = { dragDelta -> paginationDraggingOffset += dragDelta val edgeThreshold = 60f val screenWidth = boxConstraints.maxWidth.toFloat() val isMovingLeft = dragDelta.x < 0 val isMovingRight = dragDelta.x > 0 if (paginationDraggingOffset.x < edgeThreshold && isMovingLeft) { coroutineScope.launch { if (pagerState.currentPage > 0 && !pagerState.isScrollInProgress) { pagerState.animateScrollToPage(pagerState.currentPage - 1) } } } else if (paginationDraggingOffset.x + paginationDraggingSize.width > screenWidth - edgeThreshold && isMovingRight) { coroutineScope.launch { if (pagerState.currentPage < totalDisplayPages - 1 && !pagerState.isScrollInProgress) { pagerState.animateScrollToPage(pagerState.currentPage + 1) } } } }, onTextBoxDragEnd = { val boxId = paginationDraggingBoxId if (boxId != null) { coroutineScope.launch { val targetPage = pagerState.currentPage val targetVirtualPage = virtualPages.getOrNull(targetPage) val pageAspectRatio = if (targetVirtualPage is VirtualPage.BlankPage) { if (targetVirtualPage.height > 0) targetVirtualPage.width.toFloat() / targetVirtualPage.height.toFloat() else 1f } else { displayPageRatios.getOrElse(targetPage) { 1f } } val containerWidthPx = boxConstraints.maxWidth val containerHeightPx = boxConstraints.maxHeight var renderedWidthInt = containerWidthPx var renderedHeightInt = (renderedWidthInt / pageAspectRatio).toInt() if (renderedHeightInt > containerHeightPx) { renderedHeightInt = containerHeightPx renderedWidthInt = (renderedHeightInt * pageAspectRatio).toInt() } val renderedWidth = renderedWidthInt.toFloat() val renderedHeight = renderedHeightInt.toFloat() val offsetX = (containerWidthPx - renderedWidth) / 2f val offsetY = (containerHeightPx - renderedHeight) / 2f val paddingPx = with(density) { 14.dp.toPx() } val padRelX = if (renderedWidth > 0) paddingPx / renderedWidth else 0f val padRelY = if (renderedHeight > 0) paddingPx / renderedHeight else 0f val relW = paginationOriginalRelSize.width val relH = paginationOriginalRelSize.height val rawRelX = (paginationDraggingOffset.x - offsetX) / renderedWidth val rawRelY = (paginationDraggingOffset.y - offsetY) / renderedHeight val maxRelX = (1f - relW - padRelX).coerceAtLeast(padRelX) val maxRelY = (1f - relH - padRelY).coerceAtLeast(padRelY) val finalRelX = rawRelX.coerceIn(padRelX, maxRelX) val finalRelY = rawRelY.coerceIn(padRelY, maxRelY) val targetOffset = Offset( offsetX + (finalRelX * renderedWidth), offsetY + (finalRelY * renderedHeight) ) val startOffset = paginationDraggingOffset Animatable(0f).animateTo(1f) { paginationDraggingOffset = lerp(startOffset, targetOffset, value) } val idx = textBoxes.indexOfFirst { it.id == boxId } if (idx != -1) { val oldBox = textBoxes[idx] val fontScale = if (paginationDragPageHeight > 0 && renderedHeight > 0) paginationDragPageHeight / renderedHeight else 1f textBoxes[idx] = oldBox.copy( pageIndex = targetPage, relativeBounds = Rect(finalRelX, finalRelY, finalRelX + relW, finalRelY + relH), fontSize = oldBox.fontSize * fontScale ) selectedTextBoxId = boxId } paginationDraggingBoxId = null } } else { paginationDraggingBoxId = null } }, onDragPageTurn = { /* Handled in onTextBoxDrag */ }, isVisible = isVisiblePage, ) } if (paginationDraggingBoxId != null) { val draggedBox = textBoxes.find { it.id == paginationDraggingBoxId } if (draggedBox != null) { val fontScaleRatio = if (paginationDraggingSize.height > 0) paginationDragPageHeight / paginationDraggingSize.height else 1f val screenHeight = boxConstraints.maxHeight.toFloat() val boxBottomY = paginationDraggingOffset.y + paginationDraggingSize.height val spaceBelow = screenHeight - boxBottomY val overlayHandlePos = if (spaceBelow < with(density) { 60.dp.toPx() }) HandlePosition.TOP else HandlePosition.BOTTOM Box( modifier = Modifier .offset { IntOffset( paginationDraggingOffset.x.roundToInt(), paginationDraggingOffset.y.roundToInt() ) } ) { ResizableTextBox( box = draggedBox.copy( relativeBounds = Rect(0f, 0f, 1f, 1f), fontSize = draggedBox.fontSize * fontScaleRatio ), isSelected = true, isEditMode = false, // Purely visual isDarkMode = isPdfDarkMode, pageWidthPx = paginationDraggingSize.width, pageHeightPx = paginationDraggingSize.height, handlePosition = overlayHandlePos, onBoundsChanged = {}, onTextChanged = {}, onSelect = {}, onDragStart = {}, onDrag = { _, _ -> }, onDragEnd = {} ) } } } } } DisplayMode.VERTICAL_SCROLL -> { val headerHeight = 0.dp val footerHeight = 0.dp val currentSelectedTool by rememberUpdatedState(selectedTool) val currentStrokeColorState by rememberUpdatedState( currentStrokeColor ) val currentStrokeWidthState by rememberUpdatedState( currentStrokeWidth ) @Suppress("ControlFlowWithEmptyBody") val onDrawStartStable = remember { { pageIndex: Int, point: PdfPoint -> if (showToolSettings) { showToolSettings = false } else { if (currentSelectedTool == InkType.TEXT) { } else if (currentSelectedTool == InkType.ERASER) { erasedAnnotationsFromStroke.clear() val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { isAnnotationHit(it, point) } if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( pageIndex ) { mutableListOf() } batch.addAll(toRemove) val newList = existing - toRemove.toSet() allAnnotations = allAnnotations + (pageIndex to newList) } } else { val pointWithTime = point.copy( timestamp = System.currentTimeMillis() ) drawingState.onDrawStart( pageIndex, pointWithTime, currentSelectedTool, currentStrokeColorState, currentStrokeWidthState ) } } } } val onDrawStable = remember { { pageIndex: Int, point: PdfPoint -> if (currentSelectedTool == InkType.ERASER) { val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { isAnnotationHit(it, point) } if (toRemove.isNotEmpty()) { val batch = erasedAnnotationsFromStroke.getOrPut( pageIndex ) { mutableListOf() } batch.addAll(toRemove) val newList = existing - toRemove.toSet() allAnnotations = allAnnotations + (pageIndex to newList) } } else { val pointWithTime = point.copy( timestamp = System.currentTimeMillis() ) drawingState.onDraw(pointWithTime) } } } Box(modifier = Modifier .fillMaxSize() .clip(RectangleShape)) { val docHolder = remember(pdfDocument) { StableHolder(pdfDocument!!) } val bookmarksHolder = remember(bookmarks) { StableHolder(bookmarks) } val ratiosHolder = remember(displayPageRatios) { StableHolder(displayPageRatios) } PdfVerticalReader( state = verticalReaderState, pdfDocument = docHolder, isDarkMode = isPdfDarkMode, isScrollLocked = isScrollLocked, totalPages = totalDisplayPages, pageAspectRatios = ratiosHolder, virtualPages = virtualPages, headerHeight = headerHeight, footerHeight = footerHeight, onPageClick = onSingleTapStable, modifier = Modifier.testTag(VERTICAL_SCROLL_TAG), onZoomChange = onZoomChangeStable, showAllTextHighlights = showAllTextHighlights, onHighlightLoading = onHighlightLoadingStable, searchQuery = searchState.searchQuery, searchHighlightMode = searchHighlightMode, searchResultToHighlight = searchHighlightTarget, isProUser = isProUser, onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialogStable, onWordSelectedForAiDefinition = onWordSelectedForAiDefinitionStable, ttsHighlightData = ttsHighlightData, ttsReadingPage = ttsPageData?.pageIndex, onLinkClicked = onLinkClickedStable, onInternalLinkClicked = onInternalLinkNavStable, bookmarks = bookmarksHolder, onBookmarkClick = onBookmarkClickStable, onOcrStateChange = onOcrStateChangeStable, onGetOcrSearchRects = onGetOcrSearchRectsStable, allAnnotations = allAnnotationsProvider, drawingState = drawingState, onDrawStart = onDrawStartStable, onDraw = onDrawStable, onDrawEnd = { val finalAnnotation = drawingState.onDrawEnd() if (finalAnnotation != null) { val pageIdx = finalAnnotation.pageIndex val existing = allAnnotations[pageIdx] ?: emptyList() allAnnotations = allAnnotations + (pageIdx to (existing + finalAnnotation)) undoStack.add( HistoryAction.Add( pageIdx, finalAnnotation ) ) redoStack.clear() } if (selectedTool == InkType.ERASER && erasedAnnotationsFromStroke.isNotEmpty()) { val removalMap = erasedAnnotationsFromStroke.mapValues { it.value.toList() } undoStack.add( HistoryAction.Remove(removalMap) ) redoStack.clear() erasedAnnotationsFromStroke.clear() } }, onOcrModelDownloading = { isOcrModelDownloading = true }, selectedTool = selectedTool, richTextController = richTextController, isEditMode = isDrawingActive, textBoxes = textBoxes, selectedTextBoxId = selectedTextBoxId, onTextBoxChange = { updatedBox -> val idx = textBoxes.indexOfFirst { it.id == updatedBox.id } if (idx != -1) textBoxes[idx] = updatedBox }, onTextBoxSelect = { id -> selectedTextBoxId = id richTextController?.clearSelection() }, bottomContentPaddingPx = bottomScrollLimitPx, topContentPaddingPx = topScrollLimitPx, onTextBoxMoved = { boxId, newPageIndex, newBounds -> val idx = textBoxes.indexOfFirst { it.id == boxId } if (idx != -1) { val oldBox = textBoxes[idx] textBoxes[idx] = oldBox.copy(pageIndex = newPageIndex, relativeBounds = newBounds) } }, isAutoScrollPlaying = isAutoScrollPlaying, isAutoScrollTempPaused = isAutoScrollTempPaused, autoScrollSpeed = autoScrollSpeed * 0.5f, onInteractionListener = onAutoScrollInteraction ) } } } } totalPages == 0 && !isLoadingDocument -> { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.Center ) { Text( "PDF is empty or could not be displayed.", modifier = Modifier.padding(16.dp) ) } } } } // OCR language download indicator AnimatedVisibility( visible = isOcrModelDownloading, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .padding( top = if (showBars) 56.dp else 0.dp ) // Push down if top bar is visible .padding(8.dp) ) { Surface( color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(8.dp), shadowElevation = 4.dp ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onTertiaryContainer ) Spacer(modifier = Modifier.width(12.dp)) Text( text = "Downloading ${ocrLanguage.displayName.substringBefore("(")} language pack...", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onTertiaryContainer ) } } } // --- Slider UI Overlay --- AnimatedVisibility( visible = isPageSliderVisible, enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut()) { Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { isPageSliderVisible = false showBars = true }) if (isFastScrubbing) { PageScrubbingAnimation( currentPage = sliderCurrentPage.roundToInt() + 1, totalPages = totalPages ) } // Top back button IconButton( onClick = { isPageSliderVisible = false showBars = true }, modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Exit slider navigation" ) } // Bottom controls Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }) {}, // Consume clicks ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { BoxWithConstraints( modifier = Modifier.weight(1f), contentAlignment = Alignment.Center ) { Slider( value = sliderCurrentPage, onValueChange = { newValue -> sliderCurrentPage = newValue isFastScrubbing = true scrubDebounceJob.value?.cancel() scrubDebounceJob.value = coroutineScope.launch { delay(200) if (isActive) { if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage( newValue.roundToInt() ) } else { verticalReaderState.scrollToPage( newValue.roundToInt() ) } isFastScrubbing = false } } }, valueRange = 0f..(totalPages - 1).toFloat() .coerceAtLeast(0f), steps = if (totalPages > 2) totalPages - 2 else 0, modifier = Modifier.fillMaxWidth(), thumb = { Surface( modifier = Modifier.size(20.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primary, tonalElevation = 0.dp, shadowElevation = 0.dp ) {} }, track = { sliderState -> val trackHeight = 2.dp val trackShape = RoundedCornerShape(trackHeight) val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start val fraction = if (range == 0f) 0f else { ((sliderState.value - sliderState.valueRange.start) / range).coerceIn( 0f, 1f ) } Box( modifier = Modifier .fillMaxWidth() .height(trackHeight) .background( color = MaterialTheme.colorScheme.surfaceVariant, shape = trackShape ) ) { Box( modifier = Modifier .fillMaxWidth(fraction) .fillMaxHeight() .background( color = MaterialTheme.colorScheme.primary, shape = trackShape ) ) } }) val startPageOffsetFraction = if (totalPages > 1) { sliderStartPage.toFloat() / (totalPages - 1) } else { 0f } val thumbWidth = 20.dp val trackWidth = maxWidth - thumbWidth val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2) val indicatorSize = 8.dp val indicatorOffset = startPagePixelPosition - (indicatorSize / 2) Surface( modifier = Modifier .align(Alignment.CenterStart) .offset(x = indicatorOffset) .size(indicatorSize), shape = CircleShape, color = MaterialTheme.colorScheme.primary ) {} Timber.d("maxWidth: $maxWidth, trackWidth: $trackWidth") Timber.d( "startPage: $sliderStartPage, totalPages: $totalPages, fraction: $startPageOffsetFraction" ) Timber.d( "Calculated X Offset (before centering): $startPagePixelPosition" ) startPageThumbnail?.let { thumbnail -> ThumbnailWithIndicator( thumbnail = thumbnail, modifier = Modifier .graphicsLayer { clip = false } .align(Alignment.TopStart) .offset( x = startPagePixelPosition - (45.dp / 2), y = (-72).dp ), onClick = { sliderCurrentPage = sliderStartPage.toFloat() coroutineScope.launch { if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage(sliderStartPage) } else { verticalReaderState.scrollToPage( sliderStartPage ) } } }) } } // Page number text Text( text = "${sliderCurrentPage.roundToInt() + 1} / $totalPages", style = MaterialTheme.typography.bodyLarge, color = if (displayMode == DisplayMode.VERTICAL_SCROLL) Color.Black else MaterialTheme.colorScheme.onSurface, fontSize = 18.sp ) } } } } val showStandardBars = showBars && !isEditMode // Custom Top Bar AnimatedVisibility( visible = showStandardBars, enter = slideInVertically { fullHeight -> -fullHeight } + fadeIn(), exit = slideOutVertically { fullHeight -> -fullHeight } + fadeOut(), modifier = Modifier.align(Alignment.TopCenter)) { Surface( modifier = Modifier .fillMaxWidth() .height(56.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp ) { Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically ) { if (searchState.isSearchActive) { SearchTopBar( searchState = searchState, focusRequester = focusRequester, onCloseSearch = { searchState.isSearchActive = false searchState.onQueryChange("") keyboardController?.hide() focusManager.clearFocus() }) } else { IconButton(onClick = { saveStateAndExit() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) { pagerState.currentPage } else { verticalReaderState.currentPage } val titleText = when { isLoadingDocument -> "Loading PDF..." errorMessage != null -> "Error loading PDF" totalPages > 0 && pagerState.pageCount > 0 -> "Page ${currentPageForDisplay + 1} of $totalPages" totalPages > 0 && pagerState.pageCount == 0 -> "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") ) IconButton(onClick = { isPdfDarkMode = !isPdfDarkMode }) { Icon( painter = painterResource(id = R.drawable.dark_mode), contentDescription = if (isPdfDarkMode) "Disable Dark Mode" else "Enable Dark Mode", tint = if (isPdfDarkMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = { isScrollLocked = !isScrollLocked }) { Icon( imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = { isFullScreen = true }) { Icon( imageVector = Icons.Default.Fullscreen, contentDescription = "Enter Full Screen", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } if (BuildConfig.DEBUG) { IconButton(onClick = { showPenPlayground = true }) { Icon( imageVector = Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary ) } IconButton(onClick = { val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage coroutineScope.launch(Dispatchers.IO) { val svgAnnotations = SvgToAnnotationConverter.importSvgFromAssets( context = context, fileName = "demo_art.svg", pageIndex = page ) withContext(Dispatchers.Main) { if (svgAnnotations.isNotEmpty()) { val existing = allAnnotations[page] ?: emptyList() allAnnotations = allAnnotations + (page to (existing + svgAnnotations)) svgAnnotations.forEach { annot -> undoStack.add(HistoryAction.Add(page, annot)) } redoStack.clear() snackbarHostState.showSnackbar("Imported ${svgAnnotations.size} SVG strokes!") } else { snackbarHostState.showSnackbar("Failed to import SVG or empty.") } } } }) { Icon( imageVector = Icons.Default.Brush, contentDescription = "Import SVG", tint = Color(0xFFE91E63) ) } } Box { var showMoreMenu by remember { mutableStateOf(false) } IconButton(onClick = { showMoreMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = "More Options" ) } // Main "More" menu DropdownMenu( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) { if (BuildConfig.IS_PRO) { DropdownMenuItem( text = { Text("OCR Language") }, onClick = { showMoreMenu = false hasSelectedOcrLanguage = true showOcrLanguageDialog = true } ) HorizontalDivider() } DropdownMenuItem( text = { Text("Reading Mode: Vertical scroll") }, enabled = !isTtsSessionActive, onClick = { displayMode = DisplayMode.VERTICAL_SCROLL showMoreMenu = false }, trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) { Icon( imageVector = Icons.Filled.Check, contentDescription = "Selected" ) } }) HorizontalDivider() DropdownMenuItem( text = { Text("Reading Mode: Paginated") }, enabled = !isTtsSessionActive, onClick = { displayMode = DisplayMode.PAGINATION showMoreMenu = false }, trailingIcon = { if (displayMode == DisplayMode.PAGINATION) { Icon( imageVector = Icons.Filled.Check, contentDescription = "Selected" ) } }) HorizontalDivider() DropdownMenuItem( text = { Text("Auto Scroll") }, enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, onClick = { showMoreMenu = false isAutoScrollModeActive = true isAutoScrollPlaying = true showBars = true } ) HorizontalDivider() DropdownMenuItem(text = { Text( if (isBookmarked) "Remove bookmark" else "Bookmark this page" ) }, onClick = { showMoreMenu = false onBookmarkClick() }) HorizontalDivider() DropdownMenuItem( text = { Text("Insert Blank Page") }, onClick = { showMoreMenu = false onInsertPage() }) val canDelete = virtualPages.getOrNull(currentPage) is VirtualPage.BlankPage if (canDelete) { DropdownMenuItem( text = { Text("Delete Page") }, onClick = { showMoreMenu = false onDeletePage() }, colors = MenuDefaults.itemColors( textColor = MaterialTheme.colorScheme.error ) ) } HorizontalDivider() DropdownMenuItem(text = { Text("Share") }, onClick = { showMoreMenu = false showShareDialog = true }, leadingIcon = { Icon( Icons.Default.Share, contentDescription = null ) }) DropdownMenuItem( text = { Text("Save copy to device") }, onClick = { showMoreMenu = false showSaveDialog = true }, leadingIcon = { Icon( Icons.Default.Save, contentDescription = null ) }) } } } } } } // Search Results Panel AnimatedVisibility( visible = searchState.isSearchActive && searchState.showSearchResultsPanel, ) { Column( modifier = Modifier .fillMaxSize() .padding(top = 56.dp) .background(MaterialTheme.colorScheme.surface) ) { if (isBackgroundIndexing) { Surface( color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.fillMaxWidth() ) { Row( modifier = Modifier.padding( horizontal = 16.dp, vertical = 8.dp ), verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( modifier = Modifier.size(12.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSecondaryContainer ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "Indexing pages... ${(backgroundIndexingProgress * 100).toInt()}% done. Search results will update automatically.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) } } } if (!(searchState.isSearchInProgress && isOcrScanning)) { // NEW PANEL LOGIC val resultState = smartSearchResult if (resultState is SmartSearchResult.Exact) { PdfSearchResultsList( results = resultState.matches, onResultClick = { result -> navigateToPdfSearchResult(result) searchState.showSearchResultsPanel = false keyboardController?.hide() }, modifier = Modifier.fillMaxSize() ) } else if (resultState is SmartSearchResult.Paged) { val lazyPagingItems = resultState.pagingData.collectAsLazyPagingItems() PdfSearchResultsPanel( lazyResults = lazyPagingItems, totalPageCount = resultState.totalPageCount, onResultClick = { result -> navigateToPdfSearchResult(result) searchState.showSearchResultsPanel = false keyboardController?.hide() }, modifier = Modifier.fillMaxSize() ) } } } } // Search Navigation Controls AnimatedVisibility( visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && smartSearchResult != null, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 24.dp)) { val currentResult = currentPdfSearchResult val searchData = smartSearchResult val (displayText, isPrevEnabled, isNextEnabled) = remember( currentResult, searchData ) { when (searchData) { is SmartSearchResult.Exact -> { val index = if (currentResult != null) searchData.matches.indexOf( currentResult ) else -1 val text = if (index >= 0) "Result ${index + 1} / ${searchData.matches.size}" else "${searchData.matches.size} Results" Triple(text, index > 0, index < searchData.matches.size - 1) } is SmartSearchResult.Paged -> { val page = currentResult?.locationInSource val text = if (page != null) "Page ${page + 1}" else "${searchData.totalPageCount}+ Pages" Triple(text, true, true) } else -> Triple("", false, false) } } SearchNavigationPill( text = displayText, mode = searchHighlightMode, onToggleMode = { searchHighlightMode = if (searchHighlightMode == SearchHighlightMode.ALL) SearchHighlightMode.FOCUSED else SearchHighlightMode.ALL }, onPrev = { coroutineScope.launch { when (searchData) { is SmartSearchResult.Exact -> { val index = if (currentResult != null) searchData.matches.indexOf( currentResult ) else -1 if (index > 0) { navigateToPdfSearchResult( searchData.matches[index - 1] ) } } is SmartSearchResult.Paged -> { val prev = pdfTextRepository.getPrevResult( currentBookId!!, searchState.searchQuery, currentPdfSearchResult ) if (prev != null) navigateToPdfSearchResult(prev) } else -> {} } } }, onNext = { coroutineScope.launch { when (searchData) { is SmartSearchResult.Exact -> { val index = if (currentResult != null) searchData.matches.indexOf( currentResult ) else -1 if (index >= 0 && index < searchData.matches.size - 1) { navigateToPdfSearchResult( searchData.matches[index + 1] ) } else if (index == -1 && searchData.matches.isNotEmpty()) { navigateToPdfSearchResult(searchData.matches[0]) } } is SmartSearchResult.Paged -> { val next = pdfTextRepository.getNextResult( currentBookId!!, searchState.searchQuery, currentPdfSearchResult ) if (next != null) navigateToPdfSearchResult(next) } else -> {} } } }, onTextClick = { searchState.showSearchResultsPanel = true }, isPrevEnabled = isPrevEnabled, isNextEnabled = isNextEnabled ) } // Bottom Bar AnimatedVisibility( visible = showStandardBars && !searchState.isSearchActive, enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), modifier = Modifier.align(Alignment.BottomCenter)) { Surface( modifier = Modifier .fillMaxWidth() .height(56.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp ) { Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceAround ) { // Slider Navigation Trigger IconButton( onClick = { val currentPage = if (displayMode == DisplayMode.PAGINATION) { pagerState.currentPage } else { verticalReaderState.currentPage } sliderStartPage = currentPage sliderCurrentPage = currentPage.toFloat() isPageSliderVisible = true showBars = false }, enabled = !(ttsState.isPlaying || ttsState.isLoading) ) { Icon( painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider" ) } IconButton( onClick = { coroutineScope.launch { drawerState.open() } }, enabled = !(ttsState.isPlaying || ttsState.isLoading), modifier = Modifier.testTag("TocButton") ) { Icon( imageVector = Icons.Default.Menu, contentDescription = "Table of Contents" ) } // Search Button IconButton( onClick = { executeWithOcrCheck { searchState.isSearchActive = true showBars = true } }, enabled = !(ttsState.isPlaying || ttsState.isLoading), modifier = Modifier.testTag("SearchButton") ) { Icon( imageVector = Icons.Default.Search, contentDescription = "Search" ) } IconButton( onClick = { val newState = !showAllTextHighlights if (newState) { if (isHighlightingLoading) return@IconButton showAllTextHighlights = true isHighlightingLoading = true } else { showAllTextHighlights = false isHighlightingLoading = false } }) { if (isHighlightingLoading) { CircularProgressIndicator(Modifier.size(24.dp)) } else { Icon( painter = painterResource(id = R.drawable.highlight_text), contentDescription = "Highlight all text", tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } } // AI feat Box { var showAiFeaturesMenu by remember { mutableStateOf(false) } IconButton(onClick = { showAiFeaturesMenu = true }) { Icon( painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features" ) } DropdownMenu( expanded = showAiFeaturesMenu, onDismissRequest = { showAiFeaturesMenu = false }) { DropdownMenuItem( text = { Text("Summarize Page (Page ${currentPage + 1})") }, onClick = { showAiFeaturesMenu = false if (isProUser) { showSummarizationPopup = true coroutineScope.launch { isSummarizationLoading = true summarizationResult = null summarizeCurrentPage(onUpdate = { result -> summarizationResult = result }, onFinish = { isSummarizationLoading = false }) } } else { showSummarizationUpsellDialog = true } }, enabled = !isSummarizationLoading && pdfDocument != null ) } } // Edit Button IconButton( onClick = { val newEditMode = !isEditMode val currentActivePage = richTextController?.activePageIndex ?: -1 Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") if (!newEditMode && richTextController != null) { coroutineScope.launch { richTextController.saveImmediate() withContext(Dispatchers.Main) { keyboardController?.hide() } } } isEditMode = newEditMode if (!newEditMode) showBars = true } ) { Icon( imageVector = Icons.Default.Edit, contentDescription = "Toggle Editing Mode", tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } // TTS IconButton( onClick = { if (isTtsSessionActive) { Timber.d("TTS button clicked: Stopping TTS") ttsController.stop() } else { executeWithOcrCheck { when { ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED -> { startTts() } activity?.shouldShowRequestPermissionRationale( Manifest.permission.POST_NOTIFICATIONS ) == true -> { showPermissionRationaleDialog = true } else -> { permissionLauncher.launch( Manifest.permission.POST_NOTIFICATIONS ) } } } } }) { Icon( painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource( id = R.drawable.text_to_speech ), contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS" ) } // TTS Pause/Resume Button if (isTtsSessionActive) { IconButton( onClick = { if (ttsState.isPlaying) { ttsController.pause() } else { ttsController.resume() } }, enabled = !ttsState.isLoading ) { Icon( painter = painterResource( id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play ), contentDescription = if (ttsState.isPlaying) "Pause TTS" else "Resume TTS" ) } } else { Spacer(Modifier.size(48.dp)) } // Error Message Area ttsState.errorMessage?.let { Text( it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier .weight(1f) .padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis ) } } } } // Full Screen Exit Button AnimatedVisibility( visible = isFullScreen, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) .padding(24.dp) ) { val isBackgroundDark = displayMode == DisplayMode.PAGINATION || isPdfDarkMode val fabContainerColor = if (isBackgroundDark) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.25f) val fabContentColor = if (isBackgroundDark) Color.White else Color.Black Surface( onClick = { isFullScreen = false }, color = fabContainerColor, contentColor = fabContentColor, shape = CircleShape, tonalElevation = 0.dp, shadowElevation = 0.dp ) { Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { Icon( imageVector = Icons.Default.FullscreenExit, contentDescription = "Exit Full Screen", modifier = Modifier.size(26.dp) ) } } } if (isEditMode) { val density = LocalDensity.current val popupPlacementConfig = remember(dockLocation, dockOffset, boxMaxHeightFloat, dockHeightPx) { val margin = 16.dp with(density) { boxMaxHeightFloat.toDp() } val dockTopY = when (dockLocation) { DockLocation.TOP -> 0f DockLocation.BOTTOM -> boxMaxHeightFloat - dockHeightPx DockLocation.FLOATING -> dockOffset.y } val dockBottomY = dockTopY + dockHeightPx val dockCenterY = dockTopY + (dockHeightPx / 2f) val isDockInBottomHalf = dockCenterY > (boxMaxHeightFloat / 2f) if (isDockInBottomHalf) { val distFromBottom = boxMaxHeightFloat - dockTopY val paddingBottom = with(density) { distFromBottom.toDp() } + margin Triple(Alignment.BottomCenter, 0.dp, paddingBottom) } else { val paddingTop = with(density) { dockBottomY.toDp() } + margin Triple(Alignment.TopCenter, paddingTop, 0.dp) } } val (popupAlign, popupTopPad, popupBottomPad) = popupPlacementConfig Box(modifier = Modifier.fillMaxSize()) { AnimatedVisibility( visible = showToolSettings, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(popupAlign) .padding(top = popupTopPad, bottom = popupBottomPad) .testTag("ToolSettingsPopup") ) { val currentPalette = if (isCurrentToolHighlighter) highlighterPalette else penPalette ToolSettingsPopup( selectedTool = selectedTool, activeToolThickness = activeToolThickness, fountainPenColor = fountainPenColor, markerColor = markerColor, pencilColor = pencilColor, highlighterColor = highlighterColor, highlighterRoundColor = highlighterRoundColor, activePalette = currentPalette, onToolTypeChanged = { newType -> annotationSettingsRepo.updateSelectedTool(newType) }, onColorChanged = { color -> annotationSettingsRepo.updateToolColor(selectedTool, color) }, onThicknessChanged = { thickness -> annotationSettingsRepo.updateToolThickness( selectedTool, thickness ) }, onPaletteChange = { newPalette -> if (isCurrentToolHighlighter) { annotationSettingsRepo.updateHighlighterPalette( newPalette ) } else { annotationSettingsRepo.updatePenPalette(newPalette) } }) } snapPreviewLocation?.let { location -> Box( modifier = Modifier .fillMaxWidth() .height(dockHeight) .align( if (location == DockLocation.TOP) Alignment.TopCenter else Alignment.BottomCenter ) .background(Color.Black) ) } Box( modifier = Modifier .fillMaxSize() .then( when { isDockDragging -> Modifier // Positioned manually // via offset during // drag dockLocation == DockLocation.TOP -> Modifier // Aligned via Box // Scope dockLocation == DockLocation.BOTTOM -> Modifier // Aligned via Box // Scope else -> Modifier // Positioned manually // via offset } ) ) { // Calculate drag offset to apply if floating/dragging val dragModifier = if (isDockDragging || dockLocation == DockLocation.FLOATING) { Modifier.offset { IntOffset( dockOffset.x.roundToInt(), dockOffset.y.roundToInt() ) } } else { Modifier // Sticky positions use alignment below } // Calculate Alignment for Sticky states val alignModifier = when { isDockDragging || dockLocation == DockLocation.FLOATING -> Modifier dockLocation == DockLocation.TOP -> Modifier.align(Alignment.TopCenter) dockLocation == DockLocation.BOTTOM -> Modifier.align(Alignment.BottomCenter) else -> Modifier } val widthModifier = if ((dockLocation == DockLocation.TOP || dockLocation == DockLocation.BOTTOM) && !isDockDragging) { Modifier.fillMaxWidth() } else { Modifier.padding( horizontal = 16.dp ) // Original padding for floating capsule } val paddingModifier = if ((dockLocation == DockLocation.TOP || dockLocation == DockLocation.BOTTOM) && !isDockDragging) { Modifier } else { Modifier.padding(vertical = 16.dp) } val isSticky = (dockLocation == DockLocation.TOP || dockLocation == DockLocation.BOTTOM) && !isDockDragging Box( modifier = Modifier .then(alignModifier) .then(dragModifier) .pointerInput(dockLocation, isDockMinimized) { val onDragStart: (Offset) -> Unit = { isDockDragging = true val startX = (boxMaxWidthFloat / 2) - (size.width / 2) if (dockLocation == DockLocation.BOTTOM) { dockOffset = Offset( startX, boxMaxHeightFloat - dockHeightPx - 50f ) } else if (dockLocation == DockLocation.TOP) { dockOffset = Offset(startX, 50f) } } val onDrag: ( PointerInputChange, Offset ) -> Unit = { change, dragAmount -> change.consume() dockOffset += dragAmount val topSnapThreshold = 150f val bottomSnapThreshold = boxMaxHeightFloat - 250f snapPreviewLocation = when { dockOffset.y < topSnapThreshold -> DockLocation.TOP dockOffset.y > bottomSnapThreshold -> DockLocation.BOTTOM else -> null } } val onDragEnd: () -> Unit = { isDockDragging = false if (snapPreviewLocation != null) { dockLocation = snapPreviewLocation!! snapPreviewLocation = null } else { dockLocation = DockLocation.FLOATING val safeX = dockOffset.x.coerceIn( 0f, boxMaxWidthFloat - 100f ) val safeY = dockOffset.y.coerceIn( 0f, boxMaxHeightFloat - dockHeightPx ) dockOffset = Offset(safeX, safeY) } saveDockState( context, dockLocation, dockOffset ) } val onDragCancel: () -> Unit = { isDockDragging = false snapPreviewLocation = null } if (dockLocation == DockLocation.FLOATING) { detectDragGestures( onDragStart = onDragStart, onDrag = onDrag, onDragEnd = onDragEnd, onDragCancel = onDragCancel ) } else { detectDragGesturesAfterLongPress( onDragStart = onDragStart, onDrag = onDrag, onDragEnd = onDragEnd, onDragCancel = onDragCancel ) } }) { AnnotationDock( selectedTool = selectedTool, activePenColor = dockPenColor, activeHighlighterColor = dockHighlighterColor, lastPenTool = lastPenTool, onToolClick = { clickedTool -> if (clickedTool == InkType.ERASER) { annotationSettingsRepo.updateSelectedTool( clickedTool ) showToolSettings = false } else if (selectedTool == clickedTool) { if (clickedTool == InkType.PEN || clickedTool == InkType.FOUNTAIN_PEN || clickedTool == InkType.PENCIL || clickedTool == InkType.HIGHLIGHTER || clickedTool == InkType.HIGHLIGHTER_ROUND) { showToolSettings = !showToolSettings } } else { if (showToolSettings) { coroutineScope.launch { showToolSettings = false delay(250) annotationSettingsRepo.updateSelectedTool( clickedTool ) showToolSettings = true } } else { annotationSettingsRepo.updateSelectedTool( clickedTool ) } } }, onUndo = { if (undoStack.isNotEmpty()) { val action = undoStack.removeAt(undoStack.lastIndex) when (action) { is HistoryAction.Add -> { val pageIndex = action.pageIndex val annotation = action.annotation val pageAnnotations = allAnnotations[pageIndex] ?: emptyList() val newForPage = pageAnnotations - annotation allAnnotations = allAnnotations + (pageIndex to newForPage) redoStack.add(action) } is HistoryAction.Remove -> { var currentAllAnnotations = allAnnotations action.items.forEach { (pageIndex, annotations) -> val pageList = currentAllAnnotations[pageIndex] ?: emptyList() currentAllAnnotations = currentAllAnnotations + (pageIndex to (pageList + annotations)) } allAnnotations = currentAllAnnotations redoStack.add(action) } } } }, onRedo = { if (redoStack.isNotEmpty()) { val action = redoStack.removeAt(redoStack.lastIndex) when (action) { is HistoryAction.Add -> { val pageIndex = action.pageIndex val annotation = action.annotation val pageAnnotations = allAnnotations[pageIndex] ?: emptyList() val newForPage = pageAnnotations + annotation allAnnotations = allAnnotations + (pageIndex to newForPage) undoStack.add(action) } is HistoryAction.Remove -> { var currentAllAnnotations = allAnnotations action.items.forEach { (pageIndex, annotations) -> val pageList = currentAllAnnotations[pageIndex] ?: emptyList() val newForPage = pageList - annotations.toSet() currentAllAnnotations = currentAllAnnotations + (pageIndex to newForPage) } allAnnotations = currentAllAnnotations undoStack.add(action) } } } }, onClose = { richTextController?.clearSelection() isEditMode = false isDockMinimized = false showBars = true }, canUndo = undoStack.isNotEmpty(), canRedo = redoStack.isNotEmpty(), isSticky = isSticky, modifier = Modifier .then(widthModifier) .then(paddingModifier), isMinimized = isDockMinimized, onToggleMinimize = { isDockMinimized = !isDockMinimized }) } } } } val ttsReadingPage = ttsPageData?.pageIndex val isTtsPageVisible by remember( ttsReadingPage, displayMode, verticalReaderState.firstVisiblePage, verticalReaderState.lastVisiblePage ) { derivedStateOf { if (displayMode != DisplayMode.VERTICAL_SCROLL || ttsReadingPage == null) { true } else { ttsReadingPage in verticalReaderState.firstVisiblePage..verticalReaderState.lastVisiblePage } } } val showScrollToTtsFab by remember( displayMode, isTtsSessionActive, isTtsPageVisible ) { derivedStateOf { displayMode == DisplayMode.VERTICAL_SCROLL && isTtsSessionActive && !isTtsPageVisible } } AnimatedVisibility( visible = showScrollToTtsFab, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = if (showBars) 56.dp + 16.dp else 16.dp), enter = fadeIn(), exit = fadeOut() ) { val isTtsPageBelow = (ttsReadingPage ?: 0) > verticalReaderState.currentPage FloatingActionButton( onClick = { ttsReadingPage?.let { coroutineScope.launch { verticalReaderState.scrollToPage(it) } } }, shape = CircleShape, containerColor = Color.Black.copy(alpha = 0.7f), contentColor = Color.White, elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp) ) { Icon( imageVector = if (isTtsPageBelow) Icons.Default.ArrowDownward else Icons.Default.ArrowUpward, contentDescription = "Scroll to reading page" ) } } AnimatedVisibility( visible = showZoomIndicator, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 88.dp, end = 16.dp), enter = fadeIn(), exit = fadeOut() ) { val percentage = (currentPageScale * 100).roundToInt() ZoomPercentageIndicator(percentage = percentage) } val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 var isTextAnnotationPopupVisible by remember { mutableStateOf(false) } val showTextDock = isEditMode && selectedTool == InkType.TEXT && (isImeVisible || isTextAnnotationPopupVisible || selectedTextBoxId != null) if (showTextDock && richTextController != null) { val bottomPadding = if (dockLocation == DockLocation.BOTTOM && !isDockMinimized) { 80.dp } else { 16.dp } val currentDensity = LocalDensity.current val effectiveStyle by remember(selectedTextBoxId, textBoxes, richTextController.currentStyle, displayPageRatios, boxMaxWidthFloat) { derivedStateOf { if (selectedTextBoxId != null) { val box = textBoxes.find { it.id == selectedTextBoxId } if (box != null) { val pageRatio = displayPageRatios.getOrElse(box.pageIndex) { 1f } val estimatedPageHeightPx = if (pageRatio > 0) boxMaxWidthFloat / pageRatio else boxMaxWidthFloat val fontSizePx = box.fontSize * estimatedPageHeightPx val fontSizeSp = with(currentDensity) { fontSizePx.toSp() } SpanStyle( color = box.color, background = box.backgroundColor, fontSize = fontSizeSp, fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal, textDecoration = run { val decs = mutableListOf() if (box.isUnderline) decs.add(TextDecoration.Underline) if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough) if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs) } ) } else richTextController.currentStyle } else { richTextController.currentStyle } } } Box(modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth()) { TextAnnotationDock( currentStyle = effectiveStyle, textColorPalette = penPalette, onTextColorPaletteChange = { newPalette -> annotationSettingsRepo.updatePenPalette(newPalette) }, backgroundColorPalette = highlighterPalette, onBackgroundColorPaletteChange = { newPalette -> annotationSettingsRepo.updateHighlighterPalette(newPalette) }, onUpdateStyle = { newStyle -> if (selectedTextBoxId != null) { val idx = textBoxes.indexOfFirst { it.id == selectedTextBoxId } if (idx != -1) { val old = textBoxes[idx] val pageRatio = displayPageRatios.getOrElse(old.pageIndex) { 1f } val estimatedPageHeightPx = if (pageRatio > 0) boxMaxWidthFloat / pageRatio else boxMaxWidthFloat val newFontSizePx = with(currentDensity) { newStyle.fontSize.toPx() } val newFontSizeNorm = if (estimatedPageHeightPx > 0) newFontSizePx / estimatedPageHeightPx else old.fontSize textBoxes[idx] = old.copy( color = newStyle.color, backgroundColor = newStyle.background, fontSize = newFontSizeNorm, isBold = newStyle.fontWeight == FontWeight.Bold, isItalic = newStyle.fontStyle == FontStyle.Italic, isUnderline = newStyle.textDecoration?.contains(TextDecoration.Underline) == true, isStrikeThrough = newStyle.textDecoration?.contains(TextDecoration.LineThrough) == true ) } } else { richTextController.updateCurrentStyle(newStyle) val newConfig = TextStyleConfig( colorArgb = newStyle.color.toArgb(), backgroundColorArgb = newStyle.background.toArgb(), fontSize = newStyle.fontSize.value, isBold = newStyle.fontWeight == FontWeight.Bold, isItalic = newStyle.fontStyle == FontStyle.Italic, isUnderline = newStyle.textDecoration?.contains(TextDecoration.Underline) == true, isStrikeThrough = newStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, fontPath = toolSettings.textStyle.fontPath, fontName = toolSettings.textStyle.fontName ) annotationSettingsRepo.updateTextStyle(newConfig) } }, onApplyToSelection = {}, onClose = { keyboardController?.hide() }, onPopupStateChange = { isVisible -> isTextAnnotationPopupVisible = isVisible richTextController.showCursorOverride = !isVisible }, onInsertTextBox = onInsertTextBox, onClearTextBoxSelection = { selectedTextBoxId = null richTextController.clearSelection() }, bottomDockPadding = bottomPadding, customFonts = customFonts, onImportFont = viewModel::importFont, onFontSelected = { name, path -> Timber.tag("PdfFontDebug").i("UI Action: Font Selected -> Name: $name, Path: $path") if (selectedTextBoxId != null) { val idx = textBoxes.indexOfFirst { it.id == selectedTextBoxId } if (idx != -1) { val oldBox = textBoxes[idx] textBoxes[idx] = oldBox.copy(fontPath = path, fontName = name) Timber.tag("PdfTextBoxDebug").d("Updated TextBox ${oldBox.id} font to: $name ($path)") } Timber.tag("PdfFontDebug").d("UI: Updating TextBox $selectedTextBoxId with font path: $path") } else { val currentConfig = toolSettings.textStyle val newConfig = currentConfig.copy(fontPath = path, fontName = name) annotationSettingsRepo.updateTextStyle(newConfig) richTextController.let { controller -> val style = SpanStyle( color = Color(newConfig.colorArgb), background = Color(newConfig.backgroundColorArgb), fontSize = newConfig.fontSize.sp, fontWeight = if (newConfig.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (newConfig.isItalic) FontStyle.Italic else FontStyle.Normal, textDecoration = run { val decs = mutableListOf() if (newConfig.isUnderline) decs.add(TextDecoration.Underline) if (newConfig.isStrikeThrough) decs.add(TextDecoration.LineThrough) if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs) }, fontFamily = PdfFontCache.getFontFamily(path) ) Timber.tag("PdfFontDebug").d("UI Action: Manually updating controller with font $path") controller.updateCurrentStyle(style, path, name) } } }, currentFontName = remember(selectedTextBoxId, textBoxes, toolSettings.textStyle) { if (selectedTextBoxId != null) { val box = textBoxes.find { it.id == selectedTextBoxId } box?.fontName ?: box?.fontPath?.let { File(it).nameWithoutExtension } } else { toolSettings.textStyle.fontName ?: toolSettings.textStyle.fontPath?.let { File(it).nameWithoutExtension } } }, ) } } if (showSummarizationPopup) { SummarizationPopup( title = "Page Summary", result = summarizationResult, isLoading = isSummarizationLoading, onDismiss = { showSummarizationPopup = false }, isMainTtsActive = isTtsSessionActive ) } if (showPermissionRationaleDialog) { AlertDialog( onDismissRequest = { showPermissionRationaleDialog = false }, title = { Text("Permission Required") }, text = { Text( "To show playback controls while the app is in the background, please grant the notification permission." ) }, confirmButton = { TextButton( onClick = { showPermissionRationaleDialog = false permissionLauncher.launch( Manifest.permission.POST_NOTIFICATIONS ) }) { Text("Continue") } }, dismissButton = { TextButton( onClick = { showPermissionRationaleDialog = false startTts() }) { Text("Not now") } }) } if (showSummarizationUpsellDialog) { AlertDialog( onDismissRequest = { showSummarizationUpsellDialog = false }, icon = { Icon( painter = painterResource(id = R.drawable.summarize), contentDescription = null ) }, title = { Text("Unlock Page Summarization") }, text = { Text( "Get concise summaries of any page with Episteme Pro. Upgrade to start using this feature." ) }, confirmButton = { TextButton( onClick = { showSummarizationUpsellDialog = false onNavigateToPro() }) { Text("Learn More") } }, dismissButton = { TextButton(onClick = { showSummarizationUpsellDialog = false }) { Text("Not Now") } }) } if (showPasswordDialog) { PasswordDialog( isError = isPasswordError, onDismiss = { onNavigateBack() }, onConfirm = { password -> documentPassword = password }) } if (showPenPlayground) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.6f)) .clickable { showPenPlayground = false }, contentAlignment = Alignment.Center ) { PenPlayground(onClose = { showPenPlayground = false }) } } if (showAiDefinitionPopup) { AiDefinitionPopup( word = selectedTextForAi, result = aiDefinitionResult, isLoading = isAiDefinitionLoading, onDismiss = { showAiDefinitionPopup = false selectedTextForAi = null aiDefinitionResult = null }, isMainTtsActive = isTtsSessionActive ) } if (showDictionaryUpsellDialog) { AlertDialog(onDismissRequest = { showDictionaryUpsellDialog = false }, icon = { Icon( painter = painterResource(id = R.drawable.ai), contentDescription = null ) }, title = { Text("Unlock Smart Dictionary") }, text = { Text( "Defining entire phrases and paragraphs up to 2000 characters is a Pro feature. Upgrade to get instant definitions for any selected text." ) }, confirmButton = { TextButton( onClick = { showDictionaryUpsellDialog = false onNavigateToPro() }) { Text("Learn More") } }, dismissButton = { TextButton(onClick = { showDictionaryUpsellDialog = false }) { Text("Not Now") } }) } showReindexDialog?.let { newLanguage -> if (BuildConfig.IS_PRO) { AlertDialog( onDismissRequest = { showReindexDialog = null }, icon = { Icon(Icons.Default.Info, contentDescription = null) }, title = { Text("Re-index Document?") }, text = { Text( "You are changing the OCR script to ${newLanguage.displayName}.\n\n" + "To ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\n" + "This will happen in the background." ) }, confirmButton = { TextButton( onClick = { coroutineScope.launch { ocrLanguage = newLanguage saveOcrLanguage(context, newLanguage) hasSelectedOcrLanguage = true currentBookId?.let { id -> isBackgroundIndexing = true backgroundIndexingProgress = 0f withContext(Dispatchers.IO) { pdfTextRepository.clearBookText(id) pdfTextRepository.setBookLanguage(id, newLanguage.name) } isBackgroundIndexing = false } pendingActionAfterOcrSelection?.invoke() pendingActionAfterOcrSelection = null showReindexDialog = null showOcrLanguageDialog = false } } ) { Text("Re-index") } }, dismissButton = { TextButton(onClick = { showReindexDialog = null }) { Text("Cancel") } } ) } else { showReindexDialog = null } } if (showOcrLanguageDialog) { OcrLanguageSelectionDialog( currentLanguage = ocrLanguage, isFirstRun = !hasSelectedOcrLanguage, onDismiss = { showOcrLanguageDialog = false pendingActionAfterOcrSelection = null }, onLanguageSelected = { selected -> coroutineScope.launch { val storedLangName = currentBookId?.let { pdfTextRepository.getBookLanguage(it) } val hasIndexedPages = currentBookId?.let { pdfTextRepository.getIndexedPages(it).isNotEmpty() } == true if (hasIndexedPages && storedLangName != null && storedLangName != selected.name) { showReindexDialog = selected showOcrLanguageDialog = false } else { ocrLanguage = selected saveOcrLanguage(context, selected) hasSelectedOcrLanguage = true currentBookId?.let { pdfTextRepository.setBookLanguage(it, selected.name) } showOcrLanguageDialog = false pendingActionAfterOcrSelection?.invoke() pendingActionAfterOcrSelection = null } } }) } if (clickedLinkUrl != null) { val url = clickedLinkUrl!! AlertDialog( onDismissRequest = { clickedLinkUrl = null }, title = { Text("External Link") }, text = { Text("You are about to navigate to:\n$url") }, confirmButton = { TextButton( onClick = { try { uriHandler.openUri(url) } catch (e: Exception) { Timber.e(e, "Failed to open URI") } clickedLinkUrl = null }) { Text("Visit") } }, dismissButton = { Row { TextButton( onClick = { clipboardManager.setText(AnnotatedString(url)) clickedLinkUrl = null }) { Text("Copy") } TextButton(onClick = { clickedLinkUrl = null }) { Text("Cancel") } } }) } if (showSaveDialog) { AlertDialog( onDismissRequest = { showSaveDialog = false }, title = { Text("Save to Device") }, text = { Text("Choose format to save:") }, confirmButton = { TextButton( onClick = { showSaveDialog = false pendingSaveMode = SaveMode.ANNOTATED val suggestedName = getSuggestedFilename( originalFileName, isAnnotated = true ) saveLauncher.launch(suggestedName) }) { Text("With Annotations") } }, dismissButton = { Row { TextButton( onClick = { showSaveDialog = false pendingSaveMode = SaveMode.ORIGINAL val suggestedName = getSuggestedFilename( originalFileName, isAnnotated = false ) saveLauncher.launch(suggestedName) }) { Text("Original") } Spacer(Modifier.width(8.dp)) TextButton( onClick = { showSaveDialog = false pendingSaveMode = null }) { Text("Cancel") } } }) } if (showShareDialog) { AlertDialog( onDismissRequest = { showShareDialog = false }, title = { Text("Share PDF") }, text = { Text("Choose format to share:") }, confirmButton = { TextButton( onClick = { showShareDialog = false isShareLoading = true val filename = getSuggestedFilename( originalFileName, isAnnotated = true ) coroutineScope.launch { val currentRichTextLayouts = richTextController?.pageLayouts viewModel.sharePdf( activityContext = context, sourceUri = pdfUri, annotations = allAnnotations, richTextPageLayouts = currentRichTextLayouts, textBoxes = textBoxes.toList(), includeAnnotations = true, filename = filename, bookId = currentBookId ) isShareLoading = false } }) { Text("With Annotations") } }, dismissButton = { Row { TextButton( onClick = { showShareDialog = false isShareLoading = true val filename = getSuggestedFilename( originalFileName, isAnnotated = false ) coroutineScope.launch { viewModel.sharePdf( activityContext = context, sourceUri = pdfUri, annotations = allAnnotations, includeAnnotations = false, filename = filename ) isShareLoading = false } }) { Text("Original") } Spacer(Modifier.width(8.dp)) TextButton(onClick = { showShareDialog = false }) { Text("Cancel") } } }) } if (isShareLoading) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.5f)) .clickable(enabled = false) {}, contentAlignment = Alignment.Center ) { Surface( shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp ) { Column( modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { CircularProgressIndicator( modifier = Modifier.size(32.dp), strokeWidth = 3.dp ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Preparing PDF...", style = MaterialTheme.typography.bodyLarge ) } } } } val autoScrollPadding by animateDpAsState( targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, label = "AutoScrollPadding" ) val isAutoScrollControlsVisible = isAutoScrollModeActive && (!isAutoScrollLocked || showBars) val alignmentBias by animateFloatAsState( targetValue = if (isAutoScrollCollapsed) 1f else 0f, label = "AutoScrollAlignAnimation" ) AnimatedVisibility( visible = isAutoScrollControlsVisible, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), modifier = Modifier .align(BiasAlignment(alignmentBias, 1f)) .padding(bottom = autoScrollPadding) .padding(horizontal = 16.dp) ) { AutoScrollControls( isPlaying = isAutoScrollPlaying, isTempPaused = isAutoScrollTempPaused, onPlayPauseToggle = { isAutoScrollPlaying = !isAutoScrollPlaying }, speed = autoScrollSpeed, minSpeed = autoScrollMinSpeed, maxSpeed = autoScrollMaxSpeed, onSpeedChange = { autoScrollSpeed = it savePdfAutoScrollSpeed(context, it) }, onMinSpeedChange = { newMin -> autoScrollMinSpeed = newMin savePdfAutoScrollMinSpeed(context, newMin) if (autoScrollMaxSpeed < newMin) { autoScrollMaxSpeed = newMin savePdfAutoScrollMaxSpeed(context, newMin) } if (autoScrollSpeed < newMin) { autoScrollSpeed = newMin savePdfAutoScrollSpeed(context, newMin) } else if (autoScrollSpeed > autoScrollMaxSpeed) { autoScrollSpeed = autoScrollMaxSpeed savePdfAutoScrollSpeed(context, autoScrollMaxSpeed) } }, onMaxSpeedChange = { newMax -> autoScrollMaxSpeed = newMax savePdfAutoScrollMaxSpeed(context, newMax) if (autoScrollMinSpeed > newMax) { autoScrollMinSpeed = newMax savePdfAutoScrollMinSpeed(context, newMax) } if (autoScrollSpeed > newMax) { autoScrollSpeed = newMax savePdfAutoScrollSpeed(context, newMax) } else if (autoScrollSpeed < autoScrollMinSpeed) { autoScrollSpeed = autoScrollMinSpeed savePdfAutoScrollSpeed(context, autoScrollMinSpeed) } }, onClose = { isAutoScrollModeActive = false isAutoScrollPlaying = false showBars = true }, isCollapsed = isAutoScrollCollapsed, onCollapseChange = { isAutoScrollCollapsed = it }, isLocked = isAutoScrollLocked, onLockToggle = { isAutoScrollLocked = !isAutoScrollLocked savePdfAutoScrollLocked(context, isAutoScrollLocked) }, useSlider = autoScrollUseSlider, onInputModeToggle = { autoScrollUseSlider = !autoScrollUseSlider savePdfAutoScrollUseSlider(context, autoScrollUseSlider) } ) } } } } } @Composable private fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { Box( modifier = Modifier .fillMaxSize() .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }) {}, contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .background( color = MaterialTheme.colorScheme.surface.copy( alpha = 0.9f ), shape = RoundedCornerShape(16.dp) ) .padding(horizontal = 24.dp, vertical = 16.dp) ) { Icon( painter = painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(Modifier.height(12.dp)) Text( text = "Page $currentPage of $totalPages", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface ) } } } @Composable private fun ThumbnailWithIndicator( thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit ) { val borderColor = MaterialTheme.colorScheme.primary Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Surface( modifier = Modifier .width(45.dp) .height(64.dp) .clickable(onClick = onClick), shape = RoundedCornerShape(4.dp), border = BorderStroke(2.dp, borderColor) ) { Image( bitmap = thumbnail.asImageBitmap(), contentDescription = "Start page thumbnail", contentScale = ContentScale.FillBounds, modifier = Modifier.fillMaxSize() ) } Box(modifier = Modifier .offset(y = (-4).dp) .size(8.dp) .rotate(45f) .background(borderColor)) } } private fun debugPdfLinks( context: Context, pdfUri: Uri, pdfiumCore: PdfiumCoreKt, coroutineScope: CoroutineScope ) { Timber.d("--- Starting PDF Link Analysis ---") Timber.d("URI: $pdfUri") coroutineScope.launch(Dispatchers.IO) { var pfd: ParcelFileDescriptor? var doc: PdfDocumentKt? = null var page: PdfPageKt? = null try { pfd = context.contentResolver.openFileDescriptor(pdfUri, "r") if (pfd == null) { Timber.e("Failed to open ParcelFileDescriptor.") return@launch } doc = pdfiumCore.newDocument(pfd) val pageCount = doc.getPageCount() Timber.d("Document loaded. Page count: $pageCount") if (pageCount > 0) { val pageIndex = 0 // Testing the first page page = doc.openPage(pageIndex) Timber.d("Opened page $pageIndex") Timber.d( "Performing a dummy 1x1 render with renderAnnot=true to force annotation parsing..." ) val dummyBitmap = createBitmap(1, 1) page.renderPageBitmap( bitmap = dummyBitmap, startX = 0, startY = 0, drawSizeX = 1, drawSizeY = 1, renderAnnot = true // This is the crucial flag ) dummyBitmap.recycle() // Clean up immediately Timber.d("Dummy render complete. Now checking for links again.") // Method 1: The one that is failing (now should work) val annotationLinks = page.getPageLinks() Timber.d("[METHOD 1] getPageLinks() found ${annotationLinks.size} links.") if (annotationLinks.isNotEmpty()) { annotationLinks.forEachIndexed { index, link -> Timber.d(" - Link ${index}: URI='${link.uri}', Bounds='${link.bounds}'") } } // Method 2: The one that is working page.openTextPage().use { textPage -> textPage.loadWebLink().use { webLinks -> val webLinkCount = webLinks.countWebLinks() Timber.d("[METHOD 2] loadWebLink() found $webLinkCount links.") if (webLinkCount > 0) { for (i in 0 until webLinkCount) { val url = webLinks.getURL(i, 2048) Timber.d( " - WebLink ${i}: URL='${url?.substringBefore('\u0000')}'" ) } } } } } } catch (e: Exception) { Timber.e(e, "An error occurred during link debugging.") } finally { try { page?.close() } catch (_: Exception) { } try { doc?.close() } catch (_: Exception) { } Timber.d("--- PDF Link Analysis Finished ---") } } } @Composable internal fun BookmarkButton( isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { Box( modifier = modifier .width(48.dp) .height(48.dp) .clip(RectangleShape) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick ), contentAlignment = Alignment.TopCenter ) { AnimatedVisibility(visible = isBookmarked, enter = fadeIn(), exit = fadeOut()) { Icon( painter = painterResource(id = R.drawable.bookmark), contentDescription = "Bookmark", modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) } } } @Composable private fun ZoomPercentageIndicator(percentage: Int) { Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) ) { Text( text = "$percentage%", color = Color.White, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } } @Composable private fun PasswordDialog(isError: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { var password by remember { mutableStateOf("") } var passwordVisible by remember { mutableStateOf(false) } AlertDialog(onDismissRequest = onDismiss, title = { Text("Password Protected") }, text = { Column { Text("This document is encrypted. Please enter the password to view it.") Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardActions = KeyboardActions(onDone = { onConfirm(password) }), isError = isError, supportingText = if (isError) { { Text("Incorrect password") } } else null, trailingIcon = { val image = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff val description = if (passwordVisible) "Hide password" else "Show password" IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon(imageVector = image, description) } }, modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { Button(onClick = { onConfirm(password) }, enabled = password.isNotBlank()) { Text("Open") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }) } @Composable fun PenPlayground(onClose: () -> Unit) { var selectedPen by remember { mutableStateOf(PenType.FOUNTAIN_PEN) } var selectedColor by remember { mutableStateOf(Color(0xFF2196F3)) } // Default Blue val colors = listOf( Color(0xFFF44336), // Red Color(0xFFFFEB3B), // Yellow Color(0xFF2196F3), // Blue Color(0xFF4CAF50), // Green Color(0xFF9C27B0), // Purple Color.White, // White Color.Black // Black ) // Dark Card Background Surface( modifier = Modifier .fillMaxWidth(0.95f) .padding(16.dp), shape = RoundedCornerShape(28.dp), color = Color(0xFF1E1E1E), // Deep Matte Dark Grey shadowElevation = 16.dp, tonalElevation = 0.dp ) { Column( modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Header with Close Button Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Placeholder icon (Star) on left 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 ), // Ensure you have a close icon or use Icons.Default.Close contentDescription = "Close", tint = Color.Gray ) } } Spacer(Modifier.height(40.dp)) // --- The Pen Rack --- Row( modifier = Modifier .fillMaxWidth() .height(140.dp), // Height for pens + ink stroke space horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Bottom ) { PenType.entries.forEach { type -> val isSelected = selectedPen == type // Selected pens float up slightly val offsetY by animateDpAsState( targetValue = if (isSelected) (-20).dp else 0.dp, label = "offset" ) // Selected pens scale up 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 // Remove ripple for cleaner look ) { selectedPen = type }) { // Drawing Area Box( modifier = Modifier .width(40.dp) .height(120.dp), // Tall enough for stroke + pen contentAlignment = Alignment.BottomCenter ) { PenIcon( color = selectedColor, type = type, isSelected = isSelected, modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.8f) ) } } } } Spacer(Modifier.height(24.dp)) // Subtle Divider HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp), color = Color.White.copy(alpha = 0.1f), thickness = 1.dp ) Spacer(Modifier.height(24.dp)) // --- Color Palette --- Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { colors.forEach { color -> val isSelected = selectedColor == color Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .clickable { selectedColor = color }) { Canvas(modifier = Modifier.fillMaxSize()) { drawCircle(color = color) if (isSelected) { drawCircle( color = Color.White, radius = size.minDimension / 2, style = Stroke(width = 3.dp.toPx()) ) } } if (isSelected && color == Color.White) { Icon( imageVector = Icons.Default.Check, contentDescription = null, tint = Color.Black, modifier = Modifier.size(20.dp) ) } else if (isSelected) { Icon( imageVector = Icons.Default.Check, contentDescription = null, tint = if (color == Color.Black) Color.White else Color.Black.copy(alpha = 0.6f), modifier = Modifier.size(20.dp) ) } } } } Spacer(Modifier.height(16.dp)) } } } @Composable private fun OcrLanguageSelectionDialog( currentLanguage: OcrLanguage, isFirstRun: Boolean, onDismiss: () -> Unit, onLanguageSelected: (OcrLanguage) -> Unit ) { AlertDialog(onDismissRequest = onDismiss, title = { Text("Select OCR Language") }, text = { Column(Modifier.selectableGroup()) { Text( "Choose the primary language/script of this document for better text recognition results.", 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( "You can change this later in More Options > OCR Language.", 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("Cancel") } }) } @Composable private fun SearchNavigationPill( text: String, mode: SearchHighlightMode, onToggleMode: () -> Unit, onPrev: () -> Unit, onNext: () -> Unit, onTextClick: () -> Unit, isPrevEnabled: Boolean = true, isNextEnabled: Boolean = true ) { Surface( shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSurface, modifier = Modifier .shadow(6.dp, RoundedCornerShape(50)) .height(56.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 8.dp) ) { // Toggle Mode IconButton(onClick = onToggleMode) { Icon( imageVector = if (mode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = "Toggle Highlights", tint = if (mode == SearchHighlightMode.ALL) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } // Vertical Divider Box( modifier = Modifier .width(1.dp) .height(24.dp) .background( MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.3f ) ) ) // Prev IconButton(onClick = onPrev, enabled = isPrevEnabled) { Icon( imageVector = Icons.Default.KeyboardArrowUp, contentDescription = "Previous", tint = if (isPrevEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } // Counter/Text Box( modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onTextClick ) .padding(horizontal = 8.dp) ) { Text( text = text, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold ) } // Next IconButton(onClick = onNext, enabled = isNextEnabled) { Icon( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = "Next", tint = if (isNextEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } } } @Composable fun PdfSearchResultsPanel( lazyResults: LazyPagingItems, totalPageCount: Int, onResultClick: (SearchResult) -> Unit, modifier: Modifier = Modifier ) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { if (lazyResults.itemCount == 0 && lazyResults.loadState.refresh !is LoadState.Loading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No results found.", style = MaterialTheme.typography.bodyLarge) } } else { Column { Text( text = "Results found on ${totalPageCount}+ pages", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) ) HorizontalDivider() LazyColumn(modifier = Modifier.testTag("SearchResultsList")) { items(count = lazyResults.itemCount, key = lazyResults.itemKey { "${it.locationInSource}_${it.occurrenceIndexInLocation}" }, contentType = lazyResults.itemContentType { "SearchResult" }) { index -> val result = lazyResults[index] if (result != null) { ListItem( headlineContent = { Text( result.locationTitle, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, supportingContent = { Text( result.snippet, style = MaterialTheme.typography.bodyMedium ) }, modifier = Modifier .testTag( "SearchResultItem_${result.locationInSource}" ) .clickable { onResultClick(result) }) HorizontalDivider() } } } } } if (lazyResults.loadState.refresh is LoadState.Loading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } } } @Composable fun PdfSearchResultsList( results: List, onResultClick: (SearchResult) -> Unit, modifier: Modifier = Modifier ) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { if (results.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No results found.", 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() } } } } } }