V1.0.46 oss (#253)
* add a UI trigger for demo annotations * Implement internal link navigation * Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode * Add support for Calibre metadata * Implement advanced library management with Room-backed collections, tags, and smart rules. * Display book series information in book details and remove tags from home screen * Implement zoom reset functionality in PDF viewer * Add a "Pages" thumbnail view to the PDF navigation drawer * Implement jump-back navigation in PDF viewer * MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging * Replace Gson with Kotlin Serialization in SmartCollectionEngine * Implement ONNX-based speech bubble detection * Implement speech bubble detection in PDF viewer * Implement "Smart Comic Zoom" feature for PDF manga/comic reading * Implement reader session persistence and restoration in `MainViewModel` * Add tap-to-turn page feature to PDF viewer * Improve book cache management and recovery * Refactor top overlay padding logic in `PdfViewerScreen` * Implement stylus eraser support in PDF reader * Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls * Introduce a customizable horizontal margin setting for the EPUB reader * Refine folder sync and metadata handling for better conflict resolution and stability. * Introduce user-adjustable image scaling for the EPUB reader * Use maxWidthPx as default width fallback for blocks * Improve cross-page text selection and header styling in the paginated reader * Refactor speech bubble detection and UI to support segmentation masks and interactive scaling * Improve speech bubble detection masking and rendering quality * Update ONNX speech bubble detector to use `.ort` model and optimize inference * Improve pagination accuracy * Implement hierarchical folder navigation for the library. * Refactor library item layout for improved space efficiency * Add support for browsing and downloading Google Fonts * Persist library landing state and add shelf search functionality * Fix base tts sample. * Move SpeechBubbleDetector to main source set and update ONNX dependency * Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers. * Refine TTS synchronization and voice management in the EPUB reader * Adjust OCR checks for OSS flavor and optimize external dictionary intent flags * Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail * fix various crashes * Refactor SpeechBubbleDetector into product flavors * Implement speech bubble detection caching and background prefetching * Implement on-demand download for Bubble Zoom ML model * Add smooth animation for PDF speech bubble expansion * fixes #252 * hide Google Fonts option, in FontsScreen, for offline variant * Bump version to 1.0.46(46)
This commit is contained in:
parent
579b6f25bf
commit
d16bdd70d4
67 changed files with 9724 additions and 1890 deletions
919
app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
Normal file
919
app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
Normal file
|
|
@ -0,0 +1,919 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||
val tag = "PdfTocFix"
|
||||
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||
|
||||
return try {
|
||||
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||
|
||||
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||
|
||||
// 3. Get the native pointer (long) from PdfDocumentU
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||
|
||||
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||
val nClass = nativeDocInstance.javaClass
|
||||
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||
|
||||
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||
|
||||
val topLevel = mutableListOf<Bookmark>()
|
||||
val visited = mutableSetOf<Long>()
|
||||
|
||||
/**
|
||||
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||
*/
|
||||
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||
var currentPtr = startPtr
|
||||
var itemIndex = 0
|
||||
|
||||
while (currentPtr != 0L) {
|
||||
if (visited.contains(currentPtr)) break
|
||||
visited.add(currentPtr)
|
||||
|
||||
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
|
||||
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||
|
||||
val bookmark = Bookmark().apply {
|
||||
this.mNativePtr = currentPtr
|
||||
this.title = title
|
||||
this.pageIdx = pageIdx
|
||||
}
|
||||
parentList.add(bookmark)
|
||||
|
||||
// Recursive dive into children
|
||||
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||
walk(bookmark.children, firstChild, level + 1)
|
||||
}
|
||||
|
||||
// Iterative move to next sibling
|
||||
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
itemIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start from the root (Pass 0L as primitive long)
|
||||
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||
if (firstRoot != 0L) {
|
||||
walk(topLevel, firstRoot, 0)
|
||||
}
|
||||
|
||||
if (topLevel.isEmpty()) {
|
||||
Timber.tag(tag).w("No items found, falling back to library.")
|
||||
getTableOfContents()
|
||||
} else {
|
||||
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||
topLevel
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||
this.getTableOfContents()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||
val title = bookmark.title ?: "Untitled Chapter"
|
||||
val childCount = bookmark.children.size
|
||||
|
||||
Timber.tag("PdfTocDebug").d(
|
||||
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||
)
|
||||
|
||||
entries.add(
|
||||
TocEntry(
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
|
||||
if (childCount > 0) {
|
||||
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
val jsonArray = JSONArray(bookmarksJson)
|
||||
(0 until jsonArray.length()).mapNotNull { i ->
|
||||
try {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
PdfBookmark(
|
||||
pageIndex = json.getInt("pageIndex"),
|
||||
title = json.getString("title"),
|
||||
totalPages = json.getInt("totalPages")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTocTreeItem(
|
||||
label: String,
|
||||
nestLevel: Int,
|
||||
isExpanded: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||
label = "TocItemBackground"
|
||||
)
|
||||
|
||||
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
pdfDocument: ReaderDocument?,
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||
onNoteRequested: (String?) -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 4 })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = drawerPagerState.currentPage,
|
||||
edgePadding = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||
}, text = { Text("Chapters") })
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 1,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
|
||||
},
|
||||
text = { Text("Bookmarks") },
|
||||
modifier = Modifier.testTag("BookmarksTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 2,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
|
||||
},
|
||||
text = { Text("Highlights") },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 3,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
|
||||
},
|
||||
text = { Text("Pages") },
|
||||
modifier = Modifier.testTag("PagesTab")
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = drawerPagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> { // Chapters Page
|
||||
if (flatTableOfContents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Chapters are not available for this book.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val allParentIndices = remember(flatTableOfContents) {
|
||||
flatTableOfContents.indices.filter { i ->
|
||||
val next = flatTableOfContents.getOrNull(i + 1)
|
||||
next != null && next.nestLevel > flatTableOfContents[i].nestLevel
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
var expandedEntryIndices by rememberSaveable(flatTableOfContents) {
|
||||
mutableStateOf(allParentIndices)
|
||||
}
|
||||
|
||||
val visibleItemInfo by remember(flatTableOfContents) {
|
||||
derivedStateOf {
|
||||
val result = mutableListOf<Pair<Int, TocEntry>>()
|
||||
val visibilityStack = BooleanArray(20) { false }
|
||||
visibilityStack[0] = true
|
||||
|
||||
for (i in flatTableOfContents.indices) {
|
||||
val entry = flatTableOfContents[i]
|
||||
val level = entry.nestLevel.coerceIn(0, 19)
|
||||
|
||||
if (visibilityStack[level]) {
|
||||
result.add(i to entry)
|
||||
val isExpanded = expandedEntryIndices.contains(i)
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = isExpanded
|
||||
}
|
||||
} else {
|
||||
if (level + 1 < visibilityStack.size) {
|
||||
visibilityStack[level + 1] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
val currentTocEntry by remember(currentPage, flatTableOfContents) {
|
||||
derivedStateOf {
|
||||
flatTableOfContents.lastOrNull { it.pageIndex <= currentPage }
|
||||
}
|
||||
}
|
||||
|
||||
val onScrollToCurrent = {
|
||||
drawerScope.launch {
|
||||
val targetEntry = currentTocEntry ?: return@launch
|
||||
val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
|
||||
if (targetOriginalIndex != -1) {
|
||||
var currentLevel = targetEntry.nestLevel
|
||||
val newExpanded = expandedEntryIndices.toMutableSet()
|
||||
|
||||
for (i in targetOriginalIndex downTo 0) {
|
||||
val entry = flatTableOfContents[i]
|
||||
if (entry.nestLevel < currentLevel) {
|
||||
newExpanded.add(i)
|
||||
currentLevel = entry.nestLevel
|
||||
}
|
||||
if (currentLevel == 0) break
|
||||
}
|
||||
|
||||
expandedEntryIndices = newExpanded
|
||||
|
||||
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
||||
|
||||
if (visibleIdx != -1) {
|
||||
var attempts = 0
|
||||
while (listState.layoutInfo.totalItemsCount <= visibleIdx && attempts < 10) {
|
||||
delay(30)
|
||||
attempts++
|
||||
}
|
||||
|
||||
listState.animateScrollToItem(visibleIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) {
|
||||
Text("Expand All")
|
||||
}
|
||||
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
|
||||
Text("Collapse All")
|
||||
}
|
||||
TextButton(onClick = onScrollToCurrent) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(
|
||||
items = visibleItemInfo,
|
||||
key = { it.second.title + it.first }
|
||||
) { item ->
|
||||
val (originalIndex, entry) = item
|
||||
|
||||
val nextItem = flatTableOfContents.getOrNull(originalIndex + 1)
|
||||
val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel
|
||||
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||
val isCurrentChapter = entry == currentTocEntry
|
||||
|
||||
PdfTocTreeItem(
|
||||
label = entry.title,
|
||||
nestLevel = entry.nestLevel,
|
||||
isExpanded = isExpanded,
|
||||
hasChildren = hasChildren,
|
||||
isCurrent = isCurrentChapter,
|
||||
onToggleExpand = {
|
||||
expandedEntryIndices = if (isExpanded) {
|
||||
expandedEntryIndices - originalIndex
|
||||
} else {
|
||||
expandedEntryIndices + originalIndex
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onCloseDrawer()
|
||||
onPageSelected(entry.pageIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1 -> { // Bookmarks Page
|
||||
if (bookmarks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"You haven't added any bookmarks yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var bookmarkMenuExpandedFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
|
||||
|
||||
val sortedBookmarks = remember(bookmarks) {
|
||||
bookmarks.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = sortedBookmarks, key = { index, bookmark ->
|
||||
"bm_${index}_${bookmark.pageIndex}"
|
||||
}) { _, bookmark ->
|
||||
ListItem(headlineContent = {
|
||||
Text(
|
||||
bookmark.title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}, supportingContent = {
|
||||
Text(
|
||||
"Page ${bookmark.pageIndex + 1} of ${bookmark.totalPages}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}, trailingContent = {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
bookmarkMenuExpandedFor = bookmark
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More options for bookmark"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = bookmarkMenuExpandedFor == bookmark,
|
||||
onDismissRequest = {
|
||||
bookmarkMenuExpandedFor = null
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text("Rename")
|
||||
}, onClick = {
|
||||
showRenameBookmarkDialog = bookmark
|
||||
bookmarkMenuExpandedFor = null
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text("Delete")
|
||||
}, onClick = {
|
||||
showDeleteConfirmDialogFor = bookmark
|
||||
bookmarkMenuExpandedFor = null
|
||||
})
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier
|
||||
.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(bookmark.pageIndex)
|
||||
}
|
||||
.testTag(
|
||||
"BookmarkItem_${bookmark.pageIndex}"
|
||||
))
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
showRenameBookmarkDialog?.let { bookmarkToRename ->
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(onDismissRequest = {
|
||||
showRenameBookmarkDialog = null
|
||||
}, title = { Text("Rename Bookmark") }, text = {
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
label = { Text("New Title") },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = bookmarkToRename.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.6f
|
||||
)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}, confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onRenameBookmark(bookmarkToRename, newTitle)
|
||||
showRenameBookmarkDialog = null
|
||||
}) { Text("Save") }
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRenameBookmarkDialog = null
|
||||
}) { Text("Cancel") }
|
||||
})
|
||||
}
|
||||
|
||||
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
|
||||
AlertDialog(onDismissRequest = {
|
||||
showDeleteConfirmDialogFor = null
|
||||
}, title = { Text("Delete Bookmark?") }, text = {
|
||||
Text(
|
||||
"Are you sure you want to permanently delete this bookmark?"
|
||||
)
|
||||
}, confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteBookmark(bookmarkToDelete)
|
||||
showDeleteConfirmDialogFor = null
|
||||
}) { Text("Delete") }
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmDialogFor = null
|
||||
}) { Text("Cancel") }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> { // Highlights Page
|
||||
if (userHighlights.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"You haven't added any highlights yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var showDeleteConfirmDialogFor by remember { mutableStateOf<PdfUserHighlight?>(null) }
|
||||
var filterWithNotesOnly by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = false },
|
||||
label = { Text("All") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = true },
|
||||
label = { Text("With Notes") }
|
||||
)
|
||||
}
|
||||
|
||||
val filteredHighlights = if (filterWithNotesOnly) {
|
||||
userHighlights.filter { !it.note.isNullOrBlank() }
|
||||
} else {
|
||||
userHighlights.toList()
|
||||
}
|
||||
|
||||
val sortedHighlights = remember(filteredHighlights) {
|
||||
filteredHighlights.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = sortedHighlights,
|
||||
key = { _, highlight -> highlight.id }
|
||||
) { _, highlight ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = highlight.text.ifBlank { "Highlighted section" },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val displayColor = customHighlightColors[highlight.color] ?: highlight.color.color
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(displayColor, CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Page ${highlight.pageIndex + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (!highlight.note.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = highlight.note,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
var highlightMenuExpanded by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { highlightMenuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = highlightMenuExpanded,
|
||||
onDismissRequest = { highlightMenuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (highlight.note.isNullOrBlank()) "Add Note" else "Edit Note") },
|
||||
onClick = {
|
||||
onNoteRequested(highlight.id)
|
||||
highlightMenuExpanded = false
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
showDeleteConfirmDialogFor = highlight
|
||||
highlightMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(highlight.pageIndex)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showDeleteConfirmDialogFor?.let { highlightToDelete ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialogFor = null },
|
||||
title = { Text("Delete Highlight?") },
|
||||
text = { Text("Are you sure you want to permanently delete this highlight?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteHighlight(highlightToDelete)
|
||||
showDeleteConfirmDialogFor = null
|
||||
}
|
||||
) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmDialogFor = null }
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
3 -> { // Pages Page
|
||||
val listState = rememberLazyListState()
|
||||
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
||||
|
||||
val currentRowIndex = currentPage / 3
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
drawerScope.launch {
|
||||
if (currentRowIndex in pageRows.indices) {
|
||||
listState.animateScrollToItem(currentRowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(pageRows, key = { it.firstOrNull() ?: 0 }) { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
row.forEach { pageIdx ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.707f)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
.border(
|
||||
width = if (currentPage == pageIdx) 2.dp else 1.dp,
|
||||
color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(pageIdx)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
||||
|
||||
LaunchedEffect(pageIdx, pdfDocument) {
|
||||
if (thumb == null && pdfDocument != null) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val cached = PdfThumbnailCache.get(pageIdx)
|
||||
if (cached != null) {
|
||||
thumb = cached
|
||||
} else {
|
||||
pdfDocument.openPage(pageIdx)?.use { p ->
|
||||
val w = p.getPageWidthPoint()
|
||||
val h = p.getPageHeightPoint()
|
||||
val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f
|
||||
val thumbW = 200
|
||||
val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1)
|
||||
val bmp = createBitmap(thumbW, thumbH)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||
PdfThumbnailCache.put(pageIdx, bmp)
|
||||
thumb = bmp
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thumb != null) {
|
||||
Image(
|
||||
bitmap = thumb!!.asImageBitmap(),
|
||||
contentDescription = "Page ${pageIdx + 1}",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${pageIdx + 1}",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(6.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue