Library update (#78)

* Added print functionality to PDF viewer

* feat(pdf): add interactive button support and visibility reveal heuristic

* Moved search state management to MainViewModel and improved search UI interaction.

* Updated `pdfiumandroid` to version 2.0.0 and handled resulting API changes, including nullable page/text page returns and revised native pointer access.

increased compileSdk to 36.

* Fixed Table of Contents (TOC) truncation bug and improved the TOC UI in `PdfViewerScreen`.

- Implemented `getFixedTableOfContents` using reflection to bypass a library issue where sibling nodes were incorrectly truncated during traversal.
- Enhanced the TOC drawer with a nested, expandable tree structure using the new `PdfTocTreeItem` component.
- Added a custom `VerticalScrollbar` with draggable support for better navigation within long TOC lists.
- Integrated `animateColorAsState` and `animateFloatAsState` for smoother UI transitions in the TOC and scrollbar.
- Optimized TOC loading by flattening the tree structure and managing expansion states with `rememberSaveable`.

* Improved zoom pivot calculation and interaction handling in PdfVerticalReader

* Fixed high-res PDF tile bleeding by implementing clipRect in PdfBitmapLayer

* fix(pdf): resolve zoom stuttering, in pagination mode, by removing eager scale snapping

* Added book pinning and library filtering functionality

* Updated folder sync logic, improved metadata extraction, and added persistent banner message while syncing

* build: bump version to 1.0.37 (38)
This commit is contained in:
Aryan 2026-03-16 17:42:42 +05:30 committed by GitHub
parent 1884ace646
commit d6f7509e81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 338 additions and 37 deletions

View file

@ -116,7 +116,11 @@ private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
private const val KEY_FOLDER_MIGRATION_COMPLETED = "folder_migration_completed_v2"
data class BannerMessage(val message: String, val isError: Boolean = false)
private const val KEY_FILTER_FILE_TYPES = "filter_file_types"
private const val KEY_FILTER_FOLDERS = "filter_folders"
private const val KEY_FILTER_READ_STATUS = "filter_read_status"
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
data class UserData(
val uid: String, val displayName: String?, val photoUrl: String?, val email: String?
@ -161,6 +165,19 @@ enum class SortOrder(val displayName: String) {
)
}
enum class ReadStatusFilter(val displayName: String) {
ALL("All"), UNREAD("Unread"), IN_PROGRESS("In Progress"), COMPLETED("Completed")
}
data class LibraryFilters(
val fileTypes: Set<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() || sourceFolders.isNotEmpty() || readStatus != ReadStatusFilter.ALL
}
data class ReaderScreenState(
val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
@ -209,6 +226,9 @@ data class ReaderScreenState(
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -291,11 +311,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentUser = authRepository.getSignedInUser(),
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
libraryFilters = LibraryFilters(
fileTypes = prefs.getStringSet(KEY_FILTER_FILE_TYPES, emptySet())?.mapNotNull {
runCatching { FileType.valueOf(it) }.getOrNull()
}?.toSet() ?: emptySet(),
sourceFolders = prefs.getStringSet(KEY_FILTER_FOLDERS, emptySet()) ?: emptySet(),
readStatus = runCatching {
ReadStatusFilter.valueOf(prefs.getString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) ?: ReadStatusFilter.ALL.name)
}.getOrDefault(ReadStatusFilter.ALL)
),
syncedFolders = loadSyncedFoldersFromPrefs(),
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
KEY_LAST_FOLDER_SCAN_TIME, 0L
)
else null
else null,
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet()
)
)
@ -313,18 +344,46 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
val sortedAllFiles = when (internalState.sortOrder) {
SortOrder.RECENT -> rawFilteredByQuery
SortOrder.TITLE_ASC -> rawFilteredByQuery.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> rawFilteredByQuery.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> rawFilteredByQuery.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> rawFilteredByQuery.sortedByDescending { it.progressPercentage ?: 0f }
val baseVisibleFiles = rawFilteredByQuery.filterNot { it.bookId.endsWith("_reflow") }
val filters = internalState.libraryFilters
val libraryFiltered = baseVisibleFiles.filter { item ->
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
val matchFolder = if (filters.sourceFolders.isNotEmpty()) item.sourceFolderUri in filters.sourceFolders else true
val progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
ReadStatusFilter.UNREAD -> progress == 0f
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
ReadStatusFilter.COMPLETED -> progress >= 100f
}
matchType && matchFolder && matchStatus
}
val visibleRecentFiles = sortedAllFiles.filterNot { it.bookId.endsWith("_reflow") }
fun sortFiles(files: List<RecentFileItem>): List<RecentFileItem> {
return when (internalState.sortOrder) {
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
}
}
val sortedLibraryFiles = sortFiles(libraryFiltered).let { list ->
val pinned = list.filter { it.bookId in internalState.pinnedLibraryBookIds }
val unpinned = list.filter { it.bookId !in internalState.pinnedLibraryBookIds }
pinned + unpinned
}
val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list ->
val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds }
val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds }
pinned + unpinned
}
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
visibleRecentFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
}.toSet()
val shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet()
@ -332,12 +391,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val shelvesFromPrefs = shelfNames.map { shelfName ->
val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet()
val booksForShelf = visibleRecentFiles.filter { it.bookId in bookIds }
val booksForShelf = baseVisibleFiles.filter { it.bookId in bookIds }
shelvedBookIds.addAll(booksForShelf.map { it.bookId })
Shelf(shelfName, booksForShelf)
}.sortedBy { it.name }
val unshelvedBooks = visibleRecentFiles.filter { it.bookId !in shelvedBookIds }
val unshelvedBooks = baseVisibleFiles.filter { it.bookId !in shelvedBookIds }
val allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks)
val booksAvailableForAdding =
@ -348,7 +407,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> visibleRecentFiles.filter {
AddBooksSource.ALL_BOOKS -> baseVisibleFiles.filter {
it.uriString !in currentShelfBooksUris
}
}
@ -358,7 +417,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
internalState.copy(
recentFiles = visibleRecentFiles,
allRecentFiles = sortedAllFiles,
allRecentFiles = sortedLibraryFiles,
contextualActionItems = validContextualItems,
shelves = allShelves,
booksAvailableForAdding = booksAvailableForAdding
@ -1009,6 +1068,38 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun togglePinForContextualItems(isHome: Boolean) {
val selectedIds = _internalState.value.contextualActionItems.map { it.bookId }.toSet()
if (selectedIds.isEmpty()) return
_internalState.update { state ->
val currentPins = if (isHome) state.pinnedHomeBookIds else state.pinnedLibraryBookIds
val allPinned = selectedIds.all { it in currentPins }
val newPins = if (allPinned) currentPins - selectedIds else currentPins + selectedIds
prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, newPins) }
if (isHome) {
state.copy(pinnedHomeBookIds = newPins, contextualActionItems = emptySet())
} else {
state.copy(pinnedLibraryBookIds = newPins, contextualActionItems = emptySet())
}
}
}
fun updateLibraryFilters(filters: LibraryFilters) {
_internalState.update { it.copy(libraryFilters = filters) }
prefs.edit {
putStringSet(KEY_FILTER_FILE_TYPES, filters.fileTypes.map { it.name }.toSet())
putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders)
putString(KEY_FILTER_READ_STATUS, filters.readStatus.name)
}
Timber.d("Library filters updated and persisted: $filters")
}
suspend fun sharePdf(
activityContext: Context,
sourceUri: Uri,
@ -1480,7 +1571,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy(
isLoading = false,
isRefreshing = true,
bannerMessage = BannerMessage(msg)
bannerMessage = BannerMessage(msg, isPersistent = true)
)
}
}
@ -1492,7 +1583,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false,
isRefreshing = false,
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
lastFolderScanTime = System.currentTimeMillis()
lastFolderScanTime = System.currentTimeMillis(),
syncedFolders = loadSyncedFoldersFromPrefs()
)
}
}
@ -1502,7 +1594,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage,
bannerMessage = null
)
}
}
@ -3738,5 +3831,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
private const val MAX_FOLDER_LIMIT = 3
internal const val KEY_PINNED_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
}
}