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

@ -26,8 +26,8 @@ android {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 37 versionCode = 38
versionName = "1.0.36" versionName = "1.0.37"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild { externalNativeBuild {

View file

@ -60,6 +60,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -187,11 +188,13 @@ fun HomeScreen(
} }
LaunchedEffect(uiState.bannerMessage) { LaunchedEffect(uiState.bannerMessage) {
if (uiState.bannerMessage != null) { uiState.bannerMessage?.let { msg ->
if (!msg.isPersistent) {
delay(3000L) delay(3000L)
viewModel.bannerMessageShown() viewModel.bannerMessageShown()
} }
} }
}
LaunchedEffect(uiState.errorMessage) { LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { message -> uiState.errorMessage?.let { message ->
@ -304,6 +307,7 @@ fun HomeScreen(
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedContextItems.size, selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() }, onNavIconClick = { viewModel.clearContextualAction() },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() }) onSelectAllClick = { viewModel.selectAllRecentFiles() })
} }
@ -336,6 +340,7 @@ fun HomeScreen(
RecentFilesContent( RecentFilesContent(
recentFiles = recentFilesForHome, recentFiles = recentFilesForHome,
selectedContextItems = selectedContextItems, selectedContextItems = selectedContextItems,
pinnedHomeBookIds = uiState.pinnedHomeBookIds,
onItemClick = { item -> viewModel.onRecentFileClicked(item) }, onItemClick = { item -> viewModel.onRecentFileClicked(item) },
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) }, onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
onSelectFileClick = onSelectFileClick, onSelectFileClick = onSelectFileClick,
@ -435,6 +440,7 @@ fun HomeScreen(
private fun RecentFilesContent( private fun RecentFilesContent(
recentFiles: List<RecentFileItem>, recentFiles: List<RecentFileItem>,
selectedContextItems: Collection<RecentFileItem>, selectedContextItems: Collection<RecentFileItem>,
pinnedHomeBookIds: Set<String>,
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
onSelectFileClick: () -> Unit, onSelectFileClick: () -> Unit,
@ -456,6 +462,7 @@ private fun RecentFilesContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
recentFiles = recentFiles, recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
pinnedHomeBookIds = pinnedHomeBookIds,
onItemClick = onItemClick, onItemClick = onItemClick,
onItemLongClick = onItemLongClick, onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
@ -498,6 +505,7 @@ private fun RecentFilesContent(
private fun RecentFilesGrid( private fun RecentFilesGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
recentFiles: List<RecentFileItem>, recentFiles: List<RecentFileItem>,
pinnedHomeBookIds: Set<String>,
selectedItemUris: Set<String>, selectedItemUris: Set<String>,
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
@ -527,6 +535,7 @@ private fun RecentFilesGrid(
RecentFileCard( RecentFileCard(
item = item, item = item,
isSelected = item.uriString in selectedItemUris, isSelected = item.uriString in selectedItemUris,
isPinned = item.bookId in pinnedHomeBookIds,
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
onLongClick = { onItemLongClick(item) }, onLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds
@ -541,9 +550,10 @@ private fun RecentFilesGrid(
fun RecentFileCard( fun RecentFileCard(
item: RecentFileItem, item: RecentFileItem,
isSelected: Boolean, isSelected: Boolean,
modifier: Modifier = Modifier,
isPinned: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier,
isDownloading: Boolean, isDownloading: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -598,6 +608,26 @@ fun RecentFileCard(
} }
} }
if (isPinned) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(4.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
if (!item.isAvailable) { if (!item.isAvailable) {
Box( Box(
modifier = Modifier modifier = Modifier

View file

@ -27,6 +27,15 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.AssistChip
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -140,6 +149,7 @@ fun LibraryScreen(
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery val searchQuery = uiState.searchQuery
@ -222,11 +232,13 @@ fun LibraryScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
LibraryScreenContent( LibraryScreenContent(
recentFiles = uiState.recentFiles, recentFiles = uiState.allRecentFiles,
shelves = shelves, shelves = shelves,
selectedItems = selectedItems, selectedItems = selectedItems,
selectedShelves = selectedShelves, selectedShelves = selectedShelves,
sortOrder = sortOrder, sortOrder = sortOrder,
libraryFilters = uiState.libraryFilters,
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
pagerState = pagerState, pagerState = pagerState,
scope = scope, scope = scope,
searchQuery = searchQuery, searchQuery = searchQuery,
@ -234,6 +246,10 @@ fun LibraryScreen(
onSearchQueryChange = viewModel::onSearchQueryChange, onSearchQueryChange = viewModel::onSearchQueryChange,
onSearchActiveChange = viewModel::setSearchActive, onSearchActiveChange = viewModel::setSearchActive,
onSortOrderChange = viewModel::setSortOrder, onSortOrderChange = viewModel::setSortOrder,
onFilterClick = { showFilterSheet = true },
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
onClearSelection = { viewModel.clearContextualAction() }, onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked, onItemClick = viewModel::onRecentFileClicked,
onItemLongClick = viewModel::onRecentItemLongPress, onItemLongClick = viewModel::onRecentItemLongPress,
@ -260,7 +276,8 @@ fun LibraryScreen(
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds, downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime, lastFolderScanTime = uiState.lastFolderScanTime,
isLoading = uiState.isLoading isLoading = uiState.isLoading,
isRefreshing = uiState.isRefreshing,
) )
@ -284,6 +301,15 @@ fun LibraryScreen(
) )
} }
if (showFilterSheet) {
LibraryFilterSheet(
filters = uiState.libraryFilters,
syncedFolders = uiState.syncedFolders,
onApply = { viewModel.updateLibraryFilters(it) },
onDismiss = { showFilterSheet = false }
)
}
if (showDeleteShelvesDialog) { if (showDeleteShelvesDialog) {
DeleteShelvesConfirmationDialog( DeleteShelvesConfirmationDialog(
count = selectedShelves.size, count = selectedShelves.size,
@ -421,6 +447,7 @@ fun ShelfScreen(
} }
} }
@Suppress("unused")
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LibraryScreenContent( fun LibraryScreenContent(
@ -429,6 +456,8 @@ fun LibraryScreenContent(
selectedItems: Set<RecentFileItem>, selectedItems: Set<RecentFileItem>,
selectedShelves: Set<String>, selectedShelves: Set<String>,
sortOrder: SortOrder, sortOrder: SortOrder,
libraryFilters: LibraryFilters,
pinnedLibraryBookIds: Set<String>,
pagerState: PagerState, pagerState: PagerState,
scope: CoroutineScope, scope: CoroutineScope,
searchQuery: String, searchQuery: String,
@ -436,6 +465,10 @@ fun LibraryScreenContent(
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
onSearchActiveChange: (Boolean) -> Unit, onSearchActiveChange: (Boolean) -> Unit,
onSortOrderChange: (SortOrder) -> Unit, onSortOrderChange: (SortOrder) -> Unit,
onFilterClick: () -> Unit,
onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit,
onPinClick: () -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit,
@ -455,6 +488,7 @@ fun LibraryScreenContent(
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
lastFolderScanTime: Long?, lastFolderScanTime: Long?,
isLoading: Boolean, isLoading: Boolean,
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,
onAddFolderClick: (android.net.Uri) -> Unit, onAddFolderClick: (android.net.Uri) -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
@ -492,6 +526,7 @@ fun LibraryScreenContent(
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedItems.size, selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onPinClick = onPinClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick onSelectAllClick = onSelectAllClick
@ -550,6 +585,9 @@ fun LibraryScreenContent(
title = { Text("Library") }, title = { Text("Library") },
actions = { actions = {
if (pagerState.currentPage == 0) { if (pagerState.currentPage == 0) {
IconButton(onClick = onFilterClick) {
Icon(Icons.Default.FilterList, contentDescription = "Filter")
}
Box { Box {
TextButton(onClick = { showSortMenu = true }) { TextButton(onClick = { showSortMenu = true }) {
Icon( Icon(
@ -600,6 +638,40 @@ fun LibraryScreenContent(
) )
} }
} }
androidx.compose.animation.AnimatedVisibility(
visible = libraryFilters.isActive && pagerState.currentPage == 0
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (libraryFilters.fileTypes.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(fileTypes = emptySet())) },
label = { Text("Types: ${libraryFilters.fileTypes.joinToString { it.name }}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.sourceFolders.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) },
label = { Text("Folders: ${libraryFilters.sourceFolders.size}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.readStatus != ReadStatusFilter.ALL) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) },
label = { Text("Status: ${libraryFilters.readStatus.displayName}") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
}
}
} }
} }
}, },
@ -658,6 +730,7 @@ fun LibraryScreenContent(
LibraryListItem( LibraryListItem(
item = item, item = item,
isSelected = selectedItems.any { it.bookId == item.bookId }, isSelected = selectedItems.any { it.bookId == item.bookId },
isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) }, onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) }, onItemLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds
@ -681,7 +754,7 @@ fun LibraryScreenContent(
onRemoveFolderClick = onRemoveFolderClick, onRemoveFolderClick = onRemoveFolderClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick, onSyncMetadataClick = onSyncMetadataClick,
isLoading = isLoading isLoading = isLoading || isRefreshing
) )
} }
} }
@ -1159,6 +1232,7 @@ private fun ShelfListItem(
private fun LibraryListItem( private fun LibraryListItem(
item: RecentFileItem, item: RecentFileItem,
isSelected: Boolean, isSelected: Boolean,
isPinned: Boolean = false,
onItemClick: () -> Unit, onItemClick: () -> Unit,
onItemLongClick: () -> Unit, onItemLongClick: () -> Unit,
isDownloading: Boolean, isDownloading: Boolean,
@ -1217,6 +1291,16 @@ private fun LibraryListItem(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
if (isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
}
Text( Text(
text = item.title ?: item.displayName, text = item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -1556,3 +1640,90 @@ private fun FolderCard(
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
syncedFolders: List<SyncedFolder>,
onApply: (LibraryFilters) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var currentFilters by remember { mutableStateOf(filters) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Filter Library", style = MaterialTheme.typography.titleLarge)
Text("File Type", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FileType.entries.forEach { type ->
FilterChip(
selected = type in currentFilters.fileTypes,
onClick = {
val newSet = if (type in currentFilters.fileTypes) currentFilters.fileTypes - type else currentFilters.fileTypes + type
currentFilters = currentFilters.copy(fileTypes = newSet)
},
label = { Text(type.name) }
)
}
}
if (syncedFolders.isNotEmpty()) {
Text("Source Folder", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
syncedFolders.forEach { folder ->
FilterChip(
selected = folder.uriString in currentFilters.sourceFolders,
onClick = {
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(folder.name) }
)
}
}
}
Text("Read Status", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ReadStatusFilter.entries.forEach { status ->
FilterChip(
selected = currentFilters.readStatus == status,
onClick = { currentFilters = currentFilters.copy(readStatus = status) },
label = { Text(status.displayName) }
)
}
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text("Clear All")
}
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) {
Text("Apply")
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}

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_SYNC_ENABLED = "folder_sync_enabled"
private const val KEY_FOLDER_MIGRATION_COMPLETED = "folder_migration_completed_v2" 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( data class UserData(
val uid: String, val displayName: String?, val photoUrl: String?, val email: String? 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( data class ReaderScreenState(
val selectedPdfUri: Uri? = null, val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null, val selectedBookId: String? = null,
@ -209,6 +226,9 @@ data class ReaderScreenState(
val reflowProgress: Float? = null, val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(), val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: 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) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -291,11 +311,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentUser = authRepository.getSignedInUser(), currentUser = authRepository.getSignedInUser(),
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_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(), syncedFolders = loadSyncedFoldersFromPrefs(),
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
KEY_LAST_FOLDER_SCAN_TIME, 0L 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) { val baseVisibleFiles = rawFilteredByQuery.filterNot { it.bookId.endsWith("_reflow") }
SortOrder.RECENT -> rawFilteredByQuery
SortOrder.TITLE_ASC -> rawFilteredByQuery.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } val filters = internalState.libraryFilters
SortOrder.AUTHOR_ASC -> rawFilteredByQuery.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) val libraryFiltered = baseVisibleFiles.filter { item ->
SortOrder.PERCENT_ASC -> rawFilteredByQuery.sortedBy { it.progressPercentage ?: 0f } val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
SortOrder.PERCENT_DESC -> rawFilteredByQuery.sortedByDescending { it.progressPercentage ?: 0f } 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 -> val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
visibleRecentFiles.any { dbItem -> dbItem.uriString == contextItem.uriString } baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString }
}.toSet() }.toSet()
val shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet() 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 shelvesFromPrefs = shelfNames.map { shelfName ->
val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet() 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 }) shelvedBookIds.addAll(booksForShelf.map { it.bookId })
Shelf(shelfName, booksForShelf) Shelf(shelfName, booksForShelf)
}.sortedBy { it.name } }.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 allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks)
val booksAvailableForAdding = val booksAvailableForAdding =
@ -348,7 +407,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (internalState.addBooksSource) { when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> visibleRecentFiles.filter { AddBooksSource.ALL_BOOKS -> baseVisibleFiles.filter {
it.uriString !in currentShelfBooksUris it.uriString !in currentShelfBooksUris
} }
} }
@ -358,7 +417,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
internalState.copy( internalState.copy(
recentFiles = visibleRecentFiles, recentFiles = visibleRecentFiles,
allRecentFiles = sortedAllFiles, allRecentFiles = sortedLibraryFiles,
contextualActionItems = validContextualItems, contextualActionItems = validContextualItems,
shelves = allShelves, shelves = allShelves,
booksAvailableForAdding = booksAvailableForAdding 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( suspend fun sharePdf(
activityContext: Context, activityContext: Context,
sourceUri: Uri, sourceUri: Uri,
@ -1480,7 +1571,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy( it.copy(
isLoading = false, isLoading = false,
isRefreshing = true, isRefreshing = true,
bannerMessage = BannerMessage(msg) bannerMessage = BannerMessage(msg, isPersistent = true)
) )
} }
} }
@ -1492,7 +1583,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false, isLoading = false,
isRefreshing = false, isRefreshing = false,
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage, 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( it.copy(
isLoading = false, isLoading = false,
isRefreshing = 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" 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 KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
private const val MAX_FOLDER_LIMIT = 3 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"
} }
} }

View file

@ -68,15 +68,15 @@ class MetadataExtractionWorker(
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
parseContent = false parseContent = false
) )
title = book.title.takeIf { it.isNotBlank() } title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() } author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
} }
FileType.MOBI -> { FileType.MOBI -> {
val book = mobiParser.createMobiBook(inputStream, item.displayName) val book = mobiParser.createMobiBook(inputStream, item.displayName)
book?.let { book?.let {
title = it.title.takeIf { t -> t.isNotBlank() } title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() } author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) } it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
} }
} }
@ -84,9 +84,11 @@ class MetadataExtractionWorker(
pdfCoverGenerator.generateCover(uri)?.let { pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
title = item.displayName.substringBeforeLast(".") title = item.displayName
}
else -> {
title = item.displayName
} }
else -> {}
} }
} }

View file

@ -84,6 +84,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -192,6 +193,7 @@ fun ContextualTopAppBar(
onNavIconClick: () -> Unit, onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null, onInfoClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
) { ) {
CustomTopAppBar( CustomTopAppBar(
@ -202,6 +204,11 @@ fun ContextualTopAppBar(
} }
}, },
actions = { actions = {
if (onPinClick != null) {
IconButton(onClick = onPinClick) {
Icon(Icons.Filled.PushPin, contentDescription = "Pin/Unpin")
}
}
if (selectedItemCount == 1 && onInfoClick != null) { if (selectedItemCount == 1 && onInfoClick != null) {
IconButton(onClick = onInfoClick) { IconButton(onClick = onInfoClick) {
Icon(Icons.Filled.Info, contentDescription = "Info") Icon(Icons.Filled.Info, contentDescription = "Info")

View file

@ -15,7 +15,6 @@ data class FolderBookMetadata(
val lastPositionCfi: String?, val lastPositionCfi: String?,
val progressPercentage: Float, val progressPercentage: Float,
val isRecent: Boolean, val isRecent: Boolean,
// REMOVED: val isDeleted: Boolean,
val lastModifiedTimestamp: Long, val lastModifiedTimestamp: Long,
val bookmarksJson: String?, val bookmarksJson: String?,
val locatorBlockIndex: Int?, val locatorBlockIndex: Int?,
@ -33,7 +32,6 @@ data class FolderBookMetadata(
json.put("lastPositionCfi", lastPositionCfi) json.put("lastPositionCfi", lastPositionCfi)
json.put("progressPercentage", progressPercentage.toDouble()) json.put("progressPercentage", progressPercentage.toDouble())
json.put("isRecent", isRecent) json.put("isRecent", isRecent)
// REMOVED: json.put("isDeleted", isDeleted)
json.put("lastModifiedTimestamp", lastModifiedTimestamp) json.put("lastModifiedTimestamp", lastModifiedTimestamp)
json.put("bookmarksJson", bookmarksJson) json.put("bookmarksJson", bookmarksJson)
json.put("locatorBlockIndex", locatorBlockIndex ?: -1) json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
@ -65,7 +63,6 @@ data class FolderBookMetadata(
lastPositionCfi = json.optStringNull("lastPositionCfi"), lastPositionCfi = json.optStringNull("lastPositionCfi"),
progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(), progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
isRecent = json.optBoolean("isRecent", true), isRecent = json.optBoolean("isRecent", true),
// REMOVED: isDeleted deserialization
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L), lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
bookmarksJson = json.optStringNull("bookmarksJson"), bookmarksJson = json.optStringNull("bookmarksJson"),
locatorBlockIndex = json.optIntNull("locatorBlockIndex"), locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
@ -75,7 +72,6 @@ data class FolderBookMetadata(
} }
} }
// Update the converter
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem { fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
return RecentFileItem( return RecentFileItem(
bookId = this.bookId, bookId = this.bookId,
@ -95,7 +91,7 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
isRecent = this.isRecent, isRecent = this.isRecent,
isAvailable = true, isAvailable = true,
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = false, // ALWAYS FALSE for folder sync isDeleted = false,
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
sourceFolderUri = sourceFolderUri sourceFolderUri = sourceFolderUri
) )