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:
parent
1884ace646
commit
d6f7509e81
7 changed files with 338 additions and 37 deletions
|
|
@ -60,6 +60,7 @@ import androidx.compose.material.icons.filled.Info
|
|||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
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.outlined.AccountCircle
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -187,9 +188,11 @@ fun HomeScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(uiState.bannerMessage) {
|
||||
if (uiState.bannerMessage != null) {
|
||||
delay(3000L)
|
||||
viewModel.bannerMessageShown()
|
||||
uiState.bannerMessage?.let { msg ->
|
||||
if (!msg.isPersistent) {
|
||||
delay(3000L)
|
||||
viewModel.bannerMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -304,6 +307,7 @@ fun HomeScreen(
|
|||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedContextItems.size,
|
||||
onNavIconClick = { viewModel.clearContextualAction() },
|
||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||
}
|
||||
|
|
@ -336,6 +340,7 @@ fun HomeScreen(
|
|||
RecentFilesContent(
|
||||
recentFiles = recentFilesForHome,
|
||||
selectedContextItems = selectedContextItems,
|
||||
pinnedHomeBookIds = uiState.pinnedHomeBookIds,
|
||||
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
|
||||
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
|
||||
onSelectFileClick = onSelectFileClick,
|
||||
|
|
@ -435,6 +440,7 @@ fun HomeScreen(
|
|||
private fun RecentFilesContent(
|
||||
recentFiles: List<RecentFileItem>,
|
||||
selectedContextItems: Collection<RecentFileItem>,
|
||||
pinnedHomeBookIds: Set<String>,
|
||||
onItemClick: (RecentFileItem) -> Unit,
|
||||
onItemLongClick: (RecentFileItem) -> Unit,
|
||||
onSelectFileClick: () -> Unit,
|
||||
|
|
@ -456,6 +462,7 @@ private fun RecentFilesContent(
|
|||
.padding(horizontal = 16.dp),
|
||||
recentFiles = recentFiles,
|
||||
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||
onItemClick = onItemClick,
|
||||
onItemLongClick = onItemLongClick,
|
||||
windowSizeClass = windowSizeClass,
|
||||
|
|
@ -498,6 +505,7 @@ private fun RecentFilesContent(
|
|||
private fun RecentFilesGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
recentFiles: List<RecentFileItem>,
|
||||
pinnedHomeBookIds: Set<String>,
|
||||
selectedItemUris: Set<String>,
|
||||
onItemClick: (RecentFileItem) -> Unit,
|
||||
onItemLongClick: (RecentFileItem) -> Unit,
|
||||
|
|
@ -527,6 +535,7 @@ private fun RecentFilesGrid(
|
|||
RecentFileCard(
|
||||
item = item,
|
||||
isSelected = item.uriString in selectedItemUris,
|
||||
isPinned = item.bookId in pinnedHomeBookIds,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = { onItemLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
|
|
@ -541,9 +550,10 @@ private fun RecentFilesGrid(
|
|||
fun RecentFileCard(
|
||||
item: RecentFileItem,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isPinned: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isDownloading: Boolean,
|
||||
) {
|
||||
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) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ import androidx.compose.foundation.BorderStroke
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -140,6 +149,7 @@ fun LibraryScreen(
|
|||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var showFilterSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val isSearchActive = uiState.isSearchActive
|
||||
val searchQuery = uiState.searchQuery
|
||||
|
|
@ -222,11 +232,13 @@ fun LibraryScreen(
|
|||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LibraryScreenContent(
|
||||
recentFiles = uiState.recentFiles,
|
||||
recentFiles = uiState.allRecentFiles,
|
||||
shelves = shelves,
|
||||
selectedItems = selectedItems,
|
||||
selectedShelves = selectedShelves,
|
||||
sortOrder = sortOrder,
|
||||
libraryFilters = uiState.libraryFilters,
|
||||
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
|
||||
pagerState = pagerState,
|
||||
scope = scope,
|
||||
searchQuery = searchQuery,
|
||||
|
|
@ -234,6 +246,10 @@ fun LibraryScreen(
|
|||
onSearchQueryChange = viewModel::onSearchQueryChange,
|
||||
onSearchActiveChange = viewModel::setSearchActive,
|
||||
onSortOrderChange = viewModel::setSortOrder,
|
||||
onFilterClick = { showFilterSheet = true },
|
||||
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
|
||||
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
|
||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
|
||||
onClearSelection = { viewModel.clearContextualAction() },
|
||||
onItemClick = viewModel::onRecentFileClicked,
|
||||
onItemLongClick = viewModel::onRecentItemLongPress,
|
||||
|
|
@ -260,7 +276,8 @@ fun LibraryScreen(
|
|||
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
||||
downloadingBookIds = uiState.downloadingBookIds,
|
||||
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) {
|
||||
DeleteShelvesConfirmationDialog(
|
||||
count = selectedShelves.size,
|
||||
|
|
@ -421,6 +447,7 @@ fun ShelfScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LibraryScreenContent(
|
||||
|
|
@ -429,6 +456,8 @@ fun LibraryScreenContent(
|
|||
selectedItems: Set<RecentFileItem>,
|
||||
selectedShelves: Set<String>,
|
||||
sortOrder: SortOrder,
|
||||
libraryFilters: LibraryFilters,
|
||||
pinnedLibraryBookIds: Set<String>,
|
||||
pagerState: PagerState,
|
||||
scope: CoroutineScope,
|
||||
searchQuery: String,
|
||||
|
|
@ -436,6 +465,10 @@ fun LibraryScreenContent(
|
|||
onSearchQueryChange: (String) -> Unit,
|
||||
onSearchActiveChange: (Boolean) -> Unit,
|
||||
onSortOrderChange: (SortOrder) -> Unit,
|
||||
onFilterClick: () -> Unit,
|
||||
onClearFilters: () -> Unit,
|
||||
onRemoveFilter: (LibraryFilters) -> Unit,
|
||||
onPinClick: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onItemClick: (RecentFileItem) -> Unit,
|
||||
onItemLongClick: (RecentFileItem) -> Unit,
|
||||
|
|
@ -455,6 +488,7 @@ fun LibraryScreenContent(
|
|||
downloadingBookIds: Set<String>,
|
||||
lastFolderScanTime: Long?,
|
||||
isLoading: Boolean,
|
||||
isRefreshing: Boolean,
|
||||
syncedFolders: List<SyncedFolder>,
|
||||
onAddFolderClick: (android.net.Uri) -> Unit,
|
||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||
|
|
@ -492,6 +526,7 @@ fun LibraryScreenContent(
|
|||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedItems.size,
|
||||
onNavIconClick = onClearSelection,
|
||||
onPinClick = onPinClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
onSelectAllClick = onSelectAllClick
|
||||
|
|
@ -550,6 +585,9 @@ fun LibraryScreenContent(
|
|||
title = { Text("Library") },
|
||||
actions = {
|
||||
if (pagerState.currentPage == 0) {
|
||||
IconButton(onClick = onFilterClick) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = "Filter")
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
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(
|
||||
item = item,
|
||||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
||||
isPinned = item.bookId in pinnedLibraryBookIds,
|
||||
onItemClick = { onItemClick(item) },
|
||||
onItemLongClick = { onItemLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
|
|
@ -681,7 +754,7 @@ fun LibraryScreenContent(
|
|||
onRemoveFolderClick = onRemoveFolderClick,
|
||||
onScanNowClick = onScanNowClick,
|
||||
onSyncMetadataClick = onSyncMetadataClick,
|
||||
isLoading = isLoading
|
||||
isLoading = isLoading || isRefreshing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1159,6 +1232,7 @@ private fun ShelfListItem(
|
|||
private fun LibraryListItem(
|
||||
item: RecentFileItem,
|
||||
isSelected: Boolean,
|
||||
isPinned: Boolean = false,
|
||||
onItemClick: () -> Unit,
|
||||
onItemLongClick: () -> Unit,
|
||||
isDownloading: Boolean,
|
||||
|
|
@ -1217,6 +1291,16 @@ private fun LibraryListItem(
|
|||
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 = item.title ?: item.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
|
|
@ -1555,4 +1639,91 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,15 +68,15 @@ class MetadataExtractionWorker(
|
|||
originalBookNameHint = item.displayName,
|
||||
parseContent = false
|
||||
)
|
||||
title = book.title.takeIf { it.isNotBlank() }
|
||||
author = book.author.takeIf { it.isNotBlank() }
|
||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
|
||||
}
|
||||
FileType.MOBI -> {
|
||||
val book = mobiParser.createMobiBook(inputStream, item.displayName)
|
||||
book?.let {
|
||||
title = it.title.takeIf { t -> t.isNotBlank() }
|
||||
author = it.author.takeIf { a -> a.isNotBlank() }
|
||||
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
|
||||
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
|
||||
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
|
||||
}
|
||||
}
|
||||
|
|
@ -84,9 +84,11 @@ class MetadataExtractionWorker(
|
|||
pdfCoverGenerator.generateCover(uri)?.let {
|
||||
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
||||
}
|
||||
title = item.displayName.substringBeforeLast(".")
|
||||
title = item.displayName
|
||||
}
|
||||
else -> {
|
||||
title = item.displayName
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
|
|
@ -192,6 +193,7 @@ fun ContextualTopAppBar(
|
|||
onNavIconClick: () -> Unit,
|
||||
onInfoClick: (() -> Unit)? = null,
|
||||
onSelectAllClick: (() -> Unit)? = null,
|
||||
onPinClick: (() -> Unit)? = null,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
CustomTopAppBar(
|
||||
|
|
@ -202,6 +204,11 @@ fun ContextualTopAppBar(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
if (onPinClick != null) {
|
||||
IconButton(onClick = onPinClick) {
|
||||
Icon(Icons.Filled.PushPin, contentDescription = "Pin/Unpin")
|
||||
}
|
||||
}
|
||||
if (selectedItemCount == 1 && onInfoClick != null) {
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(Icons.Filled.Info, contentDescription = "Info")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ data class FolderBookMetadata(
|
|||
val lastPositionCfi: String?,
|
||||
val progressPercentage: Float,
|
||||
val isRecent: Boolean,
|
||||
// REMOVED: val isDeleted: Boolean,
|
||||
val lastModifiedTimestamp: Long,
|
||||
val bookmarksJson: String?,
|
||||
val locatorBlockIndex: Int?,
|
||||
|
|
@ -33,7 +32,6 @@ data class FolderBookMetadata(
|
|||
json.put("lastPositionCfi", lastPositionCfi)
|
||||
json.put("progressPercentage", progressPercentage.toDouble())
|
||||
json.put("isRecent", isRecent)
|
||||
// REMOVED: json.put("isDeleted", isDeleted)
|
||||
json.put("lastModifiedTimestamp", lastModifiedTimestamp)
|
||||
json.put("bookmarksJson", bookmarksJson)
|
||||
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
|
||||
|
|
@ -65,7 +63,6 @@ data class FolderBookMetadata(
|
|||
lastPositionCfi = json.optStringNull("lastPositionCfi"),
|
||||
progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
|
||||
isRecent = json.optBoolean("isRecent", true),
|
||||
// REMOVED: isDeleted deserialization
|
||||
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
|
||||
bookmarksJson = json.optStringNull("bookmarksJson"),
|
||||
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
||||
|
|
@ -75,7 +72,6 @@ data class FolderBookMetadata(
|
|||
}
|
||||
}
|
||||
|
||||
// Update the converter
|
||||
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
|
||||
return RecentFileItem(
|
||||
bookId = this.bookId,
|
||||
|
|
@ -95,7 +91,7 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
|||
isRecent = this.isRecent,
|
||||
isAvailable = true,
|
||||
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
||||
isDeleted = false, // ALWAYS FALSE for folder sync
|
||||
isDeleted = false,
|
||||
bookmarksJson = this.bookmarksJson,
|
||||
sourceFolderUri = sourceFolderUri
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue