book-reader/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt
Aryan 056485a140
General improvements (#316)
* Added support for right text alignment in epub reader

* Added tabs management to PDF navigation drawer

* Implemented unified selection menu placement logic

* Added reset functionality for toolbar customization

* Improved highlight filtering in epub pagination

* Migrated hardcoded UI strings to string resources

* Extracted desktop application logic from Main.kt into modular files

* Refactored Main.kt by extracting PDF and EPUB logic into specialized files

* Added Spanish language support

* Added system default option to app language selection
2026-05-16 16:19:31 +05:30

1208 lines
58 KiB
Kotlin

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.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Description
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.LinearProgressIndicator
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
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 com.aryan.reader.R
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
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.pdf.data.VirtualPage
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)
private enum class PdfDrawerSection {
TABS,
CHAPTERS,
BOOKMARKS,
HIGHLIGHTS,
PAGES
}
private val PdfDrawerSection.titleResId: Int
get() = when (this) {
PdfDrawerSection.TABS -> R.string.tab_tabs
PdfDrawerSection.CHAPTERS -> R.string.tab_chapters
PdfDrawerSection.BOOKMARKS -> R.string.tab_bookmarks
PdfDrawerSection.HIGHLIGHTS -> R.string.tab_highlights
PdfDrawerSection.PAGES -> R.string.tab_pages
}
private val PdfDrawerSection.testTag: String?
get() = when (this) {
PdfDrawerSection.TABS -> "TabsTab"
PdfDrawerSection.BOOKMARKS -> "BookmarksTab"
PdfDrawerSection.HIGHLIGHTS -> "HighlightsTab"
PdfDrawerSection.PAGES -> "PagesTab"
PdfDrawerSection.CHAPTERS -> null
}
/**
* 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 = stringResource(
if (isExpanded) R.string.content_desc_collapse else R.string.content_desc_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)
)
}
}
@Composable
private fun PdfTabsDrawerPage(
openTabs: List<RecentFileItem>,
activeTabBookId: String?,
currentPage: Int,
totalPages: Int,
onTabSelected: (String) -> Unit,
onTabClosed: (String) -> Unit,
onNewTabClick: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 10.dp, end = 8.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.active_tabs),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Text(
text = openTabs.size.toString(),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
IconButton(
onClick = onNewTabClick,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.content_desc_new_tab)
)
}
}
HorizontalDivider()
if (openTabs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.msg_no_other_pdfs_found),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
) {
items(openTabs, key = { it.bookId }) { tab ->
PdfDrawerTabItem(
tab = tab,
isSelected = tab.bookId == activeTabBookId,
currentPage = currentPage,
totalPages = totalPages,
onTabSelected = onTabSelected,
onTabClosed = onTabClosed
)
}
}
}
}
}
@Composable
private fun PdfDrawerTabItem(
tab: RecentFileItem,
isSelected: Boolean,
currentPage: Int,
totalPages: Int,
onTabSelected: (String) -> Unit,
onTabClosed: (String) -> Unit
) {
val shape = RoundedCornerShape(8.dp)
val containerColor by animateColorAsState(
targetValue = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.surface
},
label = "PdfDrawerTabContainer"
)
val borderColor by animateColorAsState(
targetValue = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)
},
label = "PdfDrawerTabBorder"
)
val contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
val progressPercent = remember(isSelected, currentPage, totalPages, tab.progressPercentage) {
when {
isSelected && totalPages > 0 -> (((currentPage + 1).toFloat() / totalPages.toFloat()) * 100f)
.coerceIn(0f, 100f)
.toInt()
else -> tab.progressPercentage
?.coerceIn(0f, 100f)
?.toInt()
}
}
val pageLabel = when {
isSelected && totalPages > 0 -> stringResource(R.string.page_of_pages, currentPage + 1, totalPages)
tab.lastPage != null -> stringResource(R.string.pdf_page_short, tab.lastPage + 1)
else -> null
}
val progressLabel = progressPercent
?.takeIf { it > 0 }
?.let { stringResource(R.string.progress_complete, it) }
val supportingText = remember(pageLabel, progressLabel, tab.author) {
listOfNotNull(pageLabel, progressLabel, tab.author)
.distinct()
.joinToString(" - ")
}
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(shape)
.background(containerColor)
.border(1.dp, borderColor, shape)
.clickable { onTabSelected(tab.bookId) }
.testTag("PdfDrawerTab_${tab.bookId}")
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 68.dp)
.padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
}
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Description,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = tab.customName ?: tab.title ?: tab.displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (supportingText.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.72f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(
onClick = { onTabClosed(tab.bookId) },
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close_tab),
tint = contentColor.copy(alpha = 0.8f)
)
}
}
progressPercent
?.takeIf { it > 0 }
?.let { percent ->
LinearProgressIndicator(
progress = { percent / 100f },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
trackColor = Color.Transparent
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?,
documentKey: String,
flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>,
currentPage: Int,
totalPages: Int,
isTabsEnabled: Boolean = false,
openTabs: List<RecentFileItem> = emptyList(),
activeTabBookId: String? = null,
customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit,
onTabSelected: (String) -> Unit = {},
onTabClosed: (String) -> Unit = {},
onNewTabClick: () -> Unit = {},
onRenameBookmark: (PdfBookmark, String) -> Unit,
onDeleteBookmark: (PdfBookmark) -> Unit,
onDeleteHighlight: (PdfUserHighlight) -> Unit,
onNoteRequested: (String?) -> Unit,
onCloseDrawer: () -> Unit
) {
val showTabsPane = isTabsEnabled && openTabs.isNotEmpty()
val drawerSections = remember(showTabsPane) {
buildList {
if (showTabsPane) add(PdfDrawerSection.TABS)
add(PdfDrawerSection.CHAPTERS)
add(PdfDrawerSection.BOOKMARKS)
add(PdfDrawerSection.HIGHLIGHTS)
add(PdfDrawerSection.PAGES)
}
}
val drawerPagerState = rememberPagerState(pageCount = { drawerSections.size })
val drawerScope = rememberCoroutineScope()
LaunchedEffect(drawerSections.size) {
if (drawerPagerState.currentPage >= drawerSections.size) {
drawerPagerState.scrollToPage(drawerSections.lastIndex.coerceAtLeast(0))
}
}
Column(modifier = Modifier.fillMaxSize()) {
val selectedDrawerTabIndex = drawerPagerState.currentPage.coerceIn(0, drawerSections.lastIndex)
ScrollableTabRow(
selectedTabIndex = selectedDrawerTabIndex,
edgePadding = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
drawerSections.forEachIndexed { index, section ->
Tab(
selected = selectedDrawerTabIndex == index,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(index) }
},
text = { Text(stringResource(section.titleResId)) },
modifier = section.testTag?.let { Modifier.testTag(it) } ?: Modifier
)
}
}
HorizontalPager(
state = drawerPagerState,
modifier = Modifier.fillMaxWidth().weight(1f)
) { page ->
when (drawerSections[page]) {
PdfDrawerSection.TABS -> PdfTabsDrawerPage(
openTabs = openTabs,
activeTabBookId = activeTabBookId,
currentPage = currentPage,
totalPages = totalPages,
onTabSelected = { bookId ->
if (bookId == activeTabBookId) {
onCloseDrawer()
} else {
onCloseDrawer()
onTabSelected(bookId)
}
},
onTabClosed = onTabClosed,
onNewTabClick = onNewTabClick
)
PdfDrawerSection.CHAPTERS -> { // Chapters Page
if (flatTableOfContents.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.msg_chapters_not_available),
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(stringResource(R.string.action_expand_all))
}
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
Text(stringResource(R.string.action_collapse_all))
}
TextButton(onClick = onScrollToCurrent) {
Text(stringResource(R.string.action_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)
)
}
}
}
}
PdfDrawerSection.BOOKMARKS -> { // Bookmarks Page
if (bookmarks.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.no_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(
stringResource(R.string.page_of_pages, bookmark.pageIndex + 1, bookmark.totalPages),
style = MaterialTheme.typography.bodySmall
)
}, trailingContent = {
Box {
IconButton(
onClick = {
bookmarkMenuExpandedFor = bookmark
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.content_desc_more_options_bookmark)
)
}
DropdownMenu(
expanded = bookmarkMenuExpandedFor == bookmark,
onDismissRequest = {
bookmarkMenuExpandedFor = null
}) {
DropdownMenuItem(text = {
Text(stringResource(R.string.action_rename))
}, onClick = {
showRenameBookmarkDialog = bookmark
bookmarkMenuExpandedFor = null
})
DropdownMenuItem(text = {
Text(stringResource(R.string.action_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(stringResource(R.string.dialog_rename_bookmark)) }, text = {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text(stringResource(R.string.label_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(stringResource(R.string.action_save)) }
}, dismissButton = {
TextButton(
onClick = {
showRenameBookmarkDialog = null
}) { Text(stringResource(R.string.action_cancel)) }
})
}
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
AlertDialog(onDismissRequest = {
showDeleteConfirmDialogFor = null
}, title = { Text(stringResource(R.string.dialog_delete_bookmark)) }, text = {
Text(
stringResource(R.string.dialog_delete_bookmark_desc)
)
}, confirmButton = {
TextButton(
onClick = {
onDeleteBookmark(bookmarkToDelete)
showDeleteConfirmDialogFor = null
}) { Text(stringResource(R.string.action_delete)) }
}, dismissButton = {
TextButton(
onClick = {
showDeleteConfirmDialogFor = null
}) { Text(stringResource(R.string.action_cancel)) }
})
}
}
}
PdfDrawerSection.HIGHLIGHTS -> { // Highlights Page
if (userHighlights.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.no_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(stringResource(R.string.read_status_all)) }
)
FilterChip(
selected = filterWithNotesOnly,
onClick = { filterWithNotesOnly = true },
label = { Text(stringResource(R.string.filter_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 { stringResource(R.string.msg_highlighted_section_default) },
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(
stringResource(R.string.pdf_page_short, 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 = stringResource(R.string.content_desc_options))
}
DropdownMenu(
expanded = highlightMenuExpanded,
onDismissRequest = { highlightMenuExpanded = false }
) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (highlight.note.isNullOrBlank()) R.string.menu_add_note else R.string.menu_edit_note
)
)
},
onClick = {
onNoteRequested(highlight.id)
highlightMenuExpanded = false
onCloseDrawer()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.action_delete)) },
onClick = {
showDeleteConfirmDialogFor = highlight
highlightMenuExpanded = false
}
)
}
}
},
modifier = Modifier.clickable {
onCloseDrawer()
onPageSelected(highlight.pageIndex)
}
)
HorizontalDivider()
}
}
}
showDeleteConfirmDialogFor?.let { highlightToDelete ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialogFor = null },
title = { Text(stringResource(R.string.dialog_delete_highlight)) },
text = { Text(stringResource(R.string.dialog_delete_highlight_desc)) },
confirmButton = {
TextButton(
onClick = {
onDeleteHighlight(highlightToDelete)
showDeleteConfirmDialogFor = null
}
) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(
onClick = { showDeleteConfirmDialogFor = null }
) { Text(stringResource(R.string.action_cancel)) }
}
)
}
}
}
PdfDrawerSection.PAGES -> { // 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(stringResource(R.string.action_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
) {
val thumbPageId = remember(documentKey, pageIdx) {
pdfRenderPageId(documentKey, pageIdx, VirtualPage.PdfPage(pageIdx))
}
var thumb by remember(thumbPageId) { mutableStateOf(PdfThumbnailCache.get(thumbPageId)) }
LaunchedEffect(thumbPageId, pdfDocument) {
if (thumb == null && pdfDocument != null) {
withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val cached = PdfThumbnailCache.get(thumbPageId)
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(thumbPageId, bmp)
thumb = bmp
}
}
} catch (_: Exception) { }
}
}
}
if (thumb != null) {
Image(
bitmap = thumb!!.asImageBitmap(),
contentDescription = stringResource(R.string.pdf_page_short, 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)
)
}
}
}
}
}
}
}