book-reader/app/src/main/java/com/aryan/reader/LibraryScreen.kt
Aryan 83dcafa4b6
Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
2026-06-02 00:51:42 +05:30

3352 lines
150 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// LibraryScreen.kt
@file:Suppress("KotlinConstantConditions")
package com.aryan.reader
import android.annotation.SuppressLint
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsDownloadState
import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsRepository
import com.aryan.reader.opds.OpdsViewModel
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count)
}
@UnstableApi
@SuppressLint("LocalContextGetResourceValueCall")
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
navController: NavHostController,
) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms")
}
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty()
val selectedShelves = uiState.contextualActionShelfIds
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val sortOrder = uiState.sortOrder
val shelves = uiState.shelves
val rawLibraryFiles = uiState.rawLibraryFiles
val tabTitles = remember {
buildList {
add(context.getString(R.string.tab_all_books))
add(context.getString(R.string.tab_shelves))
add(context.getString(R.string.tab_folders))
if (!BuildConfig.IS_OFFLINE) {
add(context.getString(R.string.tab_catalogs))
}
}
}
val pagerState = rememberPagerState(
initialPage = uiState.libraryScreenStartPage,
pageCount = { tabTitles.size }
)
val containsFolderItems = selectedItems.any { it.sourceFolderUri != null }
val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
viewModel.addSyncedFolder(it)
}
}
val onSelectSyncFolderClick = {
try {
pickFolderLauncher.launch(null)
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner(context.getString(R.string.error_folder_selection_unsupported), isError = true)
}
}
val pickFileLauncher = rememberFilePickerLauncher { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
viewModel.onFilesSelected(uris)
}
val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
viewModel.onFilesSelected(uris)
}
val onSelectFileClick = {
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*")
try {
pickFileLauncher.launch(mimeTypes)
} catch (_: android.content.ActivityNotFoundException) {
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
try {
fallbackFilePickerLauncher.launch("*/*")
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true)
}
}
}
LaunchedEffect(pagerState) {
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.drop(1)
.distinctUntilChanged()
.collect { page ->
viewModel.setLibraryScreenPage(page)
}
}
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction()
}
BackHandler(enabled = isShelfContextualModeActive) {
viewModel.clearShelfContextualAction()
}
BackHandler(enabled = isSearchActive) {
viewModel.setSearchActive(false)
}
Box(modifier = Modifier.fillMaxSize()) {
LibraryScreenContent(
tabTitles = tabTitles,
recentFiles = uiState.allRecentFiles,
rawLibraryFiles = rawLibraryFiles,
shelves = shelves,
selectedItems = selectedItems,
selectedShelves = selectedShelves,
sortOrder = sortOrder,
libraryFilters = uiState.libraryFilters,
allTags = uiState.allTags,
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
pagerState = pagerState,
scope = scope,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
onSearchQueryChange = viewModel::onSearchQueryChange,
onSearchActiveChange = viewModel::setSearchActive,
onSortOrderChange = viewModel::setSortOrder,
onFilterClick = { showFilterSheet = true },
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked,
onItemLongClick = viewModel::onRecentItemLongPress,
onInfoClick = {
if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first()
showInfoDialog = true
}
},
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick,
onShelfLongClick = viewModel::onShelfLongPress,
onClearShelfSelection = viewModel::clearShelfContextualAction,
onDeleteShelves = { showDeleteShelvesDialog = true },
onNewShelfClick = viewModel::showCreateShelfDialog,
onSelectFileClick = onSelectFileClick,
onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick,
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime,
isLoading = uiState.isLoading,
isRefreshing = uiState.isRefreshing,
onOpdsBookDownloaded = { uri, title ->
viewModel.showBanner(context.getString(R.string.banner_downloaded, title))
viewModel.onFileSelected(uri, isFromRecent = false)
},
onStreamOpdsBook = { entry, catalog ->
viewModel.streamOpdsBook(
bookId = entry.id,
title = entry.title,
urlTemplate = entry.pseUrlTemplate!!,
pageCount = entry.pseCount!!,
catalogId = catalog?.id
)
},
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
if (uiState.showCreateShelfDialog) {
CreateShelfDialog(
onConfirm = viewModel::createShelf,
onDismiss = viewModel::dismissCreateShelfDialog
)
}
if (showDeleteConfirmDialog) {
DeleteConfirmationDialog(
count = selectedItems.size,
onConfirm = {
viewModel.deleteContextualItemsPermanently()
showDeleteConfirmDialog = false
},
onDismiss = { showDeleteConfirmDialog = false },
isPermanentDelete = true,
containsFolderItems = containsFolderItems
)
}
if (showFilterSheet) {
LibraryFilterSheet(
filters = uiState.libraryFilters,
allTags = uiState.allTags,
syncedFolders = uiState.syncedFolders,
onApply = { viewModel.updateLibraryFilters(it) },
onDismiss = { showFilterSheet = false }
)
}
if (showDeleteShelvesDialog) {
DeleteShelvesConfirmationDialog(
count = selectedShelves.size,
onConfirm = {
viewModel.deleteSelectedShelves()
showDeleteShelvesDialog = false
},
onDismiss = { showDeleteShelvesDialog = false }
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@UnstableApi
@Composable
fun ShelfScreen(
viewModel: MainViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val viewingShelfId = uiState.viewingShelfId
val isAddingBooks = uiState.isAddingBooksToShelf
val shelves = uiState.shelves
val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
BackHandler(enabled = true) {
when {
selectedItems.isNotEmpty() -> viewModel.clearContextualAction()
isAddingBooks -> viewModel.dismissAddBooksToShelf()
else -> viewModel.navigateBackFromShelf()
}
}
val currentShelf = shelves.find { it.id == viewingShelfId }
val childShelves = remember(shelves, currentShelf) {
currentShelf?.childShelfIds?.mapNotNull { childId -> shelves.find { it.id == childId } } ?: emptyList()
}
Box(modifier = Modifier.fillMaxSize()) {
if (viewingShelfId != null && currentShelf != null) {
if (isAddingBooks) {
AddBooksModeScreen(
shelfName = currentShelf.name,
availableBooks = uiState.booksAvailableForAdding,
selectedBookUris = uiState.booksSelectedForAdding,
currentSource = uiState.addBooksSource,
sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder,
onSourceChange = viewModel::setAddBooksSource,
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
onBack = viewModel::dismissAddBooksToShelf,
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
downloadingBookIds = uiState.downloadingBookIds,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
} else {
ShelfDetailScreen(
shelf = currentShelf,
childShelves = childShelves,
selectedItems = selectedItems,
sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder,
onBack = viewModel::navigateBackFromShelf,
onAddBooksClick = viewModel::showAddBooksToShelf,
onChildShelfClick = viewModel::onShelfClick,
onBookClick = viewModel::onRecentFileClicked,
onBookLongClick = viewModel::onRecentItemLongPress,
onClearSelection = viewModel::clearContextualAction,
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onInfoClick = {
if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first()
showInfoDialog = true
}
},
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
downloadingBookIds = uiState.downloadingBookIds,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
}
}
if (showRenameDialogFor != null) {
val shelfToRename = shelves.find { it.id == showRenameDialogFor }
if (shelfToRename != null) {
RenameShelfDialog(
initialName = shelfToRename.name,
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
onDismiss = viewModel::dismissRenameShelfDialog
)
}
}
if (showDeleteDialogFor != null) {
DeleteShelfConfirmationDialog(
shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "",
onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) },
onDismiss = viewModel::dismissDeleteShelfDialog
)
}
if (showRemoveFromShelfDialog) {
RemoveFromShelfConfirmationDialog(
count = selectedItems.size,
shelfName = currentShelf?.name ?: "",
onConfirm = {
viewModel.removeContextualItemsFromShelf()
showRemoveFromShelfDialog = false
},
onDismiss = { showRemoveFromShelfDialog = false }
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@Suppress("unused")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryScreenContent(
tabTitles: List<String>,
recentFiles: List<RecentFileItem>,
rawLibraryFiles: List<RecentFileItem>,
shelves: List<Shelf>,
selectedItems: Set<RecentFileItem>,
selectedShelves: Set<String>,
sortOrder: SortOrder,
libraryFilters: LibraryFilters,
allTags: List<TagEntity>,
pinnedLibraryBookIds: Set<String>,
pagerState: PagerState,
scope: CoroutineScope,
searchQuery: String,
isSearchActive: Boolean,
onSearchQueryChange: (String) -> Unit,
onSearchActiveChange: (Boolean) -> Unit,
onSortOrderChange: (SortOrder) -> Unit,
onFilterClick: () -> Unit,
onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit,
onTagClick: () -> Unit,
onPinClick: () -> Unit,
onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
onClearShelfSelection: () -> Unit,
onDeleteShelves: () -> Unit,
onNewShelfClick: () -> Unit,
onSelectFileClick: () -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>,
lastFolderScanTime: Long?,
isLoading: Boolean,
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
usePdfFileNameAsDisplayName: Boolean,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } }
var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
LaunchedEffect(searchQuery) {
if (textFieldValue.text != searchQuery) {
textFieldValue = textFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
Column {
if (isBookContextualModeActive) {
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick
)
} else if (isShelfContextualModeActive && pagerState.currentPage == 1) {
ContextualTopAppBar(
selectedItemCount = selectedShelves.size,
onNavIconClick = onClearShelfSelection,
onDeleteClick = onDeleteShelves
)
} else if (isSearchActive) {
Surface(
shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onSearchActiveChange(false) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_close_search))
}
OutlinedTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
onSearchQueryChange(it.text)
},
placeholder = { Text(stringResource(R.string.search_placeholder)) },
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester)
.testTag("LibrarySearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_clear_query))
}
}
}
)
}
}
} else {
CustomTopAppBar(title = { Text(stringResource(R.string.library_title)) },
actions = {
if (pagerState.currentPage == 0) {
IconButton(onClick = onFilterClick) {
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
}
Box {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("LibrarySortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(sortOrder.labelRes))
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(stringResource(order.labelRes)) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
}
}
)
}
}
}
IconButton(onClick = { onSearchActiveChange(true) }) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
}
)
TabRow(selectedTabIndex = pagerState.currentPage) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
ReaderPerfLog.d("LibraryPager click page=$index title=$title")
if (pagerState.currentPage != index) {
scope.launch {
val start = ReaderPerfLog.nowNanos()
pagerState.animateScrollToPage(index)
ReaderPerfLog.d(
"LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms"
)
}
}
},
text = { Text(title) }
)
}
}
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(stringResource(R.string.filter_types, libraryFilters.fileTypes.joinToString { it.name })) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.sourceFolders.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) },
label = { Text(stringResource(R.string.filter_folders, libraryFilters.sourceFolders.size)) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.readStatus != ReadStatusFilter.ALL) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) },
label = { Text(stringResource(R.string.filter_status, stringResource(libraryFilters.readStatus.labelRes))) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.tagIds.isNotEmpty()) {
val selectedTags = allTags.filter { it.id in libraryFilters.tagIds }
val tagLabel = when {
selectedTags.isEmpty() -> pluralStringResource(R.plurals.tag_count, libraryFilters.tagIds.size, libraryFilters.tagIds.size)
selectedTags.size <= 2 -> selectedTags.joinToString { it.name }
else -> pluralStringResource(R.plurals.tag_count, selectedTags.size, selectedTags.size)
}
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(tagIds = emptySet())) },
label = { Text(stringResource(R.string.filter_tags, tagLabel)) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) }
)
}
}
}
}
}
},
floatingActionButton = {
if (!isBookContextualModeActive && !isShelfContextualModeActive) {
when (pagerState.currentPage) {
0 -> {
if (recentFiles.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_add_file)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_add_file)) },
onClick = onSelectFileClick,
modifier = Modifier.padding(16.dp)
)
}
}
1 -> {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick,
modifier = Modifier
.padding(16.dp)
.testTag("LibraryNewShelfFab")
)
}
}
}
}
) { paddingValues ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapPositionalThreshold = 0.25f
),
beyondViewportPageCount = 0,
key = { it }
) { page ->
when (page) {
0 -> {
if (recentFiles.isEmpty() && searchQuery.isNotEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.no_results_found, searchQuery))
}
} else if (recentFiles.isEmpty()) {
EmptyState(
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.library_empty_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(recentFiles, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = item.bookId in selectedBookIds,
isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
)
}
}
}
}
1 -> {
ShelvesScreen(
shelves = shelves,
onShelfClick = onShelfClick,
onShelfLongClick = onShelfLongClick,
selectedShelves = selectedShelves
)
}
2 -> {
FolderSyncScreen(
syncedFolders = syncedFolders,
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
isLoading = isLoading || isRefreshing
)
}
3 -> {
if (!BuildConfig.IS_OFFLINE) {
OpdsTab(
localLibraryFiles = rawLibraryFiles,
onBookDownloaded = onOpdsBookDownloaded,
onReadBook = onItemClick,
onStreamBook = onStreamOpdsBook,
onDeleteCatalogStreams = onDeleteCatalogStreams
)
}
}
}
}
}
}
@Composable
private fun ShelvesScreen(
shelves: List<Shelf>,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
selectedShelves: Set<String>,
) {
val tagShelves = remember(shelves) { shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } }
val visibleShelves = remember(shelves) {
shelves.filter { shelf ->
when {
shelf.type == ShelfType.TAG -> false
shelf.type == ShelfType.FOLDER -> shelf.parentShelfId == null
else -> true
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (tagShelves.isNotEmpty() && selectedShelves.isEmpty()) {
item {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.section_browse_by_tag),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tagShelves.forEach { shelf ->
FilterChip(
selected = false,
onClick = { onShelfClick(shelf) },
label = { Text(shelf.name) },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.tag),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
}
}
items(visibleShelves, key = { it.id }) { shelf ->
ShelfListItem(
shelf = shelf,
isSelected = shelf.id in selectedShelves,
onItemClick = { onShelfClick(shelf) },
onItemLongClick = { onShelfLongClick(shelf) }
)
}
}
}
@Composable
private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var text by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.create_new_shelf)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text(stringResource(R.string.shelf_name_hint)) },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank()
) {
Text(stringResource(R.string.action_create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
}
@Composable
private fun ShelfDetailScreen(
shelf: Shelf,
childShelves: List<Shelf>,
selectedItems: Set<RecentFileItem>,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onBack: () -> Unit,
onAddBooksClick: () -> Unit,
onChildShelfClick: (Shelf) -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBookLongClick: (RecentFileItem) -> Unit,
onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit,
downloadingBookIds: Set<String>,
usePdfFileNameAsDisplayName: Boolean,
) {
val isContextualModeActive = selectedItems.isNotEmpty()
val isFolderShelf = shelf.type == ShelfType.FOLDER
var showSortMenu by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
var isSearchActive by remember(shelf.id) { mutableStateOf(false) }
var searchQuery by remember(shelf.id) { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
var searchFieldValue by remember(isSearchActive, shelf.id) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
val normalizedQuery = searchQuery.trim()
val filteredChildShelves = remember(childShelves, normalizedQuery) {
if (normalizedQuery.isBlank()) {
childShelves
} else {
childShelves.filter { childShelf ->
childShelf.name.contains(normalizedQuery, ignoreCase = true) ||
childShelf.books.any { item ->
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
item.author?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
}
val filteredDirectBooks = remember(shelf.directBooks, normalizedQuery) {
if (normalizedQuery.isBlank()) {
shelf.directBooks
} else {
shelf.directBooks.filter { item ->
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
item.author?.contains(normalizedQuery, ignoreCase = true) == true ||
item.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
}
}
}
LaunchedEffect(searchQuery) {
if (searchFieldValue.text != searchQuery) {
searchFieldValue = searchFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
fun clearShelfSearchQuery() {
searchQuery = ""
searchFieldValue = TextFieldValue("", TextRange.Zero)
}
fun closeShelfSearch() {
isSearchActive = false
clearShelfSearchQuery()
}
BackHandler(enabled = isSearchActive) {
closeShelfSearch()
}
Scaffold(
modifier = Modifier,
topBar = {
if (isContextualModeActive) {
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick
)
} else if (isSearchActive) {
Surface(
shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { closeShelfSearch() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_close_search))
}
OutlinedTextField(
value = searchFieldValue,
onValueChange = {
searchFieldValue = it
searchQuery = it.text
},
placeholder = { Text(stringResource(R.string.search_placeholder)) },
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester)
.testTag("ShelfSearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { clearShelfSearchQuery() }) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_clear_query))
}
}
}
)
}
}
} else {
CustomTopAppBar(
title = {
Column {
Text(
text = shelf.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = when {
isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 ->
stringResource(
R.string.folder_subtitle_folder_book_counts,
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount),
getBookCountString(shelf.directBookCount)
)
isFolderShelf && shelf.childShelfCount > 0 ->
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)
isFolderShelf -> getBookCountString(shelf.directBookCount)
else -> getBookCountString(shelf.bookCount)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
Box {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("ShelfSortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(sortOrder.labelRes))
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(stringResource(order.labelRes)) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
}
}
)
}
}
}
IconButton(onClick = { isSearchActive = true }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.content_desc_search_shelf)
)
}
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
Box {
IconButton(onClick = { showMoreMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.content_desc_more_options)
)
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_rename_shelf)) },
onClick = {
onRenameShelf()
showMoreMenu = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete_shelf)) },
onClick = {
onDeleteShelf()
showMoreMenu = false
}
)
}
}
}
}
)
}
},
floatingActionButton = {
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) {
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.fab_add_books)) }
)
}
},
content = { paddingValues ->
if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource(
R.string.no_results_found,
normalizedQuery
),
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (filteredChildShelves.isNotEmpty()) {
if (isFolderShelf) {
item {
Text(
text = stringResource(R.string.section_folders),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(filteredChildShelves, key = { it.id }) { childShelf ->
ShelfListItem(
shelf = childShelf,
isSelected = false,
onItemClick = { onChildShelfClick(childShelf) },
onItemLongClick = {},
showHierarchyIndent = false
)
}
}
if (filteredDirectBooks.isNotEmpty() && isFolderShelf && filteredChildShelves.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = stringResource(R.string.section_files),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(filteredDirectBooks, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookLongClick(item) },
isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
)
}
}
}
}
)
}
@Composable
private fun AddBooksModeScreen(
shelfName: String,
availableBooks: List<RecentFileItem>,
selectedBookUris: Set<String>,
currentSource: AddBooksSource,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onSourceChange: (AddBooksSource) -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBack: () -> Unit,
onAddSelectedBooks: () -> Unit,
downloadingBookIds: Set<String>,
usePdfFileNameAsDisplayName: Boolean,
) {
var showSortMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier,
topBar = {
Column {
CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
Box {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("AddBooksSortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(sortOrder.labelRes))
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(stringResource(order.labelRes)) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
}
}
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AddBooksSource.entries.forEach { source ->
FilterChip(
selected = source == currentSource,
onClick = { onSourceChange(source) },
label = { Text(stringResource(source.labelRes)) }
)
}
}
}
},
floatingActionButton = {
if (selectedBookUris.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_add_count, selectedBookUris.size)) },
icon = { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.fab_add_books)) },
onClick = onAddSelectedBooks
)
}
},
content = { paddingValues ->
if (availableBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (currentSource == AddBooksSource.UNSHELVED) {
stringResource(R.string.no_unshelved_books)
} else {
stringResource(R.string.all_books_in_shelf)
},
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(availableBooks, key = { it.bookId }) { item ->
val isSelected = item.bookId in selectedBookUris
LibraryListItem(
item = item,
isSelected = isSelected,
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookClick(item) },
isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
)
}
}
}
}
)
}
@Composable
private fun ShelfCover(shelf: Shelf) {
val booksForCovers = shelf.books.take(4).reversed()
val coverWidth = 52.dp
val coverHeight = 75.dp
val horizontalOffset = 12.dp
val maxWidth = coverWidth + (horizontalOffset * (4 - 1))
Box(
modifier = Modifier
.width(maxWidth)
.height(coverHeight),
contentAlignment = Alignment.CenterStart
) {
if (booksForCovers.size <= 1) {
val topBook = shelf.topBook
if (topBook != null) {
ThemedBookCover(
item = topBook,
contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.clip(MaterialTheme.shapes.small)
)
} else {
EmptyShelfCover(
shelfName = shelf.name,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.clip(MaterialTheme.shapes.small)
)
}
} else {
Box(
modifier = Modifier
.width(coverWidth + (horizontalOffset * (booksForCovers.size - 1)))
.height(coverHeight)
) {
booksForCovers.forEachIndexed { index, book ->
Surface(
shape = MaterialTheme.shapes.small,
shadowElevation = 4.dp,
modifier = Modifier
.size(width = coverWidth, height = coverHeight)
.align(Alignment.CenterEnd)
.offset(x = -horizontalOffset * index)
) {
ThemedBookCover(
item = book,
contentDescription = null,
contentScale = ContentScale.Crop
)
}
}
}
}
}
}
@Composable
private fun EmptyShelfCover(
shelfName: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.surfaceContainerHighest
)
)
)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Text(
text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(8.dp)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ShelfListItem(
shelf: Shelf,
isSelected: Boolean,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
showHierarchyIndent: Boolean = true,
) {
val folderIndent = if (showHierarchyIndent && shelf.type == ShelfType.FOLDER) (shelf.depth * 14).dp else 0.dp
androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp
),
modifier = Modifier
.fillMaxWidth()
.testTag("ShelfItem_${shelf.id}")
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(
onClick = onItemClick,
onLongClick = {
if (shelf.name != "Unshelved") {
onItemLongClick()
}
}
)
) {
Row(
modifier = Modifier.padding(start = 12.dp + folderIndent, end = 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShelfCover(shelf = shelf)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val icon = when (shelf.type) {
ShelfType.SMART -> Icons.Default.Star
ShelfType.TAG -> Icons.AutoMirrored.Filled.LibraryBooks
ShelfType.FOLDER -> Icons.Default.Folder
ShelfType.SERIES -> Icons.AutoMirrored.Filled.LibraryBooks
ShelfType.MANUAL -> Icons.AutoMirrored.Filled.List
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = shelf.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = getBookCountString(shelf.bookCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LibraryListItem(
item: RecentFileItem,
isSelected: Boolean,
isPinned: Boolean = false,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
isDownloading: Boolean,
usePdfFileNameAsDisplayName: Boolean = false,
) {
androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 6.dp else 2.dp
),
modifier = Modifier
.fillMaxWidth()
.testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.height(132.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(0.7f)
.clip(MaterialTheme.shapes.medium)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
) {
ThemedBookCover(
item = item,
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
if (isSelected) {
Box(
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), modifier = Modifier.size(36.dp).background(MaterialTheme.colorScheme.primary, CircleShape).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary)
}
}
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = item.cardAuthor(),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
minLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges(
item = item,
isPinned = isPinned
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FileTypeBadge(type = item.type, overlay = false)
if (item.tags.isNotEmpty()) {
BookTagChipsRow(
tags = item.tags,
compact = true,
modifier = Modifier.weight(1f, fill = false)
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (!item.isAvailable) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
ReadingProgressSection(
progressPercentage = item.progressPercentage,
compact = true,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun RenameShelfDialog(
initialName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var textFieldValue by remember {
mutableStateOf(
TextFieldValue(
text = initialName,
selection = TextRange(initialName.length)
)
)
}
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.menu_rename_shelf)) },
text = {
OutlinedTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
placeholder = { Text(stringResource(R.string.shelf_name_hint)) },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(textFieldValue.text) },
enabled = textFieldValue.text.isNotBlank() && textFieldValue.text != initialName
) {
Text(stringResource(R.string.action_rename))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
}
@Composable
private fun DeleteShelfConfirmationDialog(
shelfName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_shelf)) },
text = { Text(stringResource(R.string.dialog_delete_shelf_desc, shelfName)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
private fun RemoveFromShelfConfirmationDialog(
count: Int,
shelfName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_remove_from_shelf)) },
text = { Text(pluralStringResource(R.plurals.dialog_remove_from_shelf_desc, count, count, shelfName)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_remove)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
private fun DeleteShelvesConfirmationDialog(
count: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val shelfStr = pluralStringResource(id = R.plurals.shelf_count, count)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_shelves, shelfStr)) },
text = { Text(stringResource(R.string.dialog_delete_shelves_desc, count, shelfStr)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.mapValues { (_, files) ->
FolderFileStats(
totalBooks = files.size,
countsByType = files.groupingBy { it.type }.eachCount()
)
}
}
Scaffold(
floatingActionButton = {
if (syncedFolders.size < 10) {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_add_folder)) },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = onAddFolderClick
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (syncedFolders.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp))
Text(if (isLoading) stringResource(R.string.scanning) else stringResource(R.string.scan_all))
}
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.sync_meta))
}
}
} else {
EmptyState(
title = stringResource(R.string.sync_local_folders),
message = stringResource(R.string.sync_folders_desc),
onSelectFileClick = onAddFolderClick,
primaryButtonText = stringResource(R.string.action_select_folder),
modifier = Modifier.fillMaxSize()
)
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard(
folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick,
onLocalSyncToggleClick = { selectedFolder ->
if (selectedFolder.localSyncEnabled) {
disablingFolder = selectedFolder
} else {
onFolderLocalSyncChange(selectedFolder, true, false)
}
},
onEditFiltersClick = { editingFolder = folder }
)
}
}
}
}
editingFolder?.let { folder ->
EditFolderFiltersDialog(
folder = folder,
onConfirm = { newFilters ->
onEditFolderFiltersClick(folder, newFilters)
editingFolder = null
},
onDismiss = { editingFolder = null }
)
}
disablingFolder?.let { folder ->
AlertDialog(
onDismissRequest = { disablingFolder = null },
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
text = {
Text(
stringResource(
R.string.dialog_disable_folder_local_sync_desc,
LOCAL_FOLDER_SYNC_DATA_DIR
)
)
},
confirmButton = {
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, true)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_remove_sync_data))
}
},
dismissButton = {
Row {
TextButton(onClick = { disablingFolder = null }) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, false)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_keep_sync_data))
}
}
}
)
}
}
private data class FolderFileStats(
val totalBooks: Int,
val countsByType: Map<FileType, Int>
) {
companion object {
val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun FolderCard(
folder: SyncedFolder,
stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit,
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!folder.localSyncEnabled) {
Text(
text = stringResource(R.string.folder_local_sync_disabled),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, "Options")
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_edit_filters)) },
onClick = {
showMenu = false
onEditFiltersClick(folder)
}
)
DropdownMenuItem(
text = {
Text(
if (folder.localSyncEnabled) {
stringResource(R.string.menu_disable_folder_local_sync)
} else {
stringResource(R.string.menu_enable_folder_local_sync)
}
)
},
onClick = {
showMenu = false
onLocalSyncToggleClick(folder)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = {
showMenu = false
onRemoveClick(folder)
},
colors = androidx.compose.material3.MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error
)
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.last_sync),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text(
text = stringResource(R.string.books_count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
if (stats.countsByType.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
stats.countsByType.forEach { (type, count) ->
AssistChip(
onClick = { },
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
)
}
}
}
}
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun EditFolderFiltersDialog(
folder: SyncedFolder,
onConfirm: (Set<FileType>) -> Unit,
onDismiss: () -> Unit
) {
var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text(
text = stringResource(R.string.filter_file_types),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.filter_file_types_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
androidx.compose.foundation.layout.FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
val isSelected = type in selectedTypes
FilterChip(
selected = isSelected,
onClick = {
selectedTypes = if (isSelected) {
selectedTypes - type
} else {
selectedTypes + type
}
},
label = {
Text(
text = type.name,
style = MaterialTheme.typography.labelLarge
)
},
leadingIcon = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null,
shape = MaterialTheme.shapes.medium
)
}
}
}
},
confirmButton = {
androidx.compose.material3.Button(
onClick = { onConfirm(selectedTypes) },
enabled = selectedTypes.isNotEmpty(),
shape = MaterialTheme.shapes.medium
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
allTags: List<TagEntity>,
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(stringResource(R.string.filter_library), style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.filter_file_type), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ANDROID_READABLE_FILE_TYPES.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) }
)
}
}
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = "IN_APP_STORAGE" in currentFilters.sourceFolders,
onClick = {
val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE"
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(stringResource(R.string.filter_in_app_storage)) }
)
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(stringResource(R.string.filter_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(stringResource(status.labelRes)) }
)
}
}
if (allTags.isNotEmpty()) {
Text(stringResource(R.string.section_tags), style = MaterialTheme.typography.titleMedium)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
allTags.forEach { tag ->
val selected = tag.id in currentFilters.tagIds
FilterChip(
selected = selected,
onClick = {
val newSet = if (selected) {
currentFilters.tagIds - tag.id
} else {
currentFilters.tagIds + tag.id
}
currentFilters = currentFilters.copy(tagIds = newSet)
},
label = { Text(tag.name) },
leadingIcon = {
Box(
modifier = Modifier
.size(10.dp)
.background(
Color(tag.color ?: 0xFF64B5F6.toInt()),
CircleShape
)
)
}
)
}
}
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text(stringResource(R.string.clear_all))
}
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) {
Text(stringResource(R.string.action_apply))
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun OpdsTab(
localLibraryFiles: List<RecentFileItem>,
onBookDownloaded: (Uri, String) -> Unit,
onReadBook: (RecentFileItem) -> Unit,
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
opdsViewModel: OpdsViewModel = viewModel()
) {
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState
val context = LocalContext.current
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
BackHandler(enabled = uiState.isViewingCatalog) {
opdsViewModel.navigateBack()
}
Box(modifier = Modifier.fillMaxSize()) {
if (!uiState.isViewingCatalog) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 88.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.catalogs, key = { it.id }) { catalog ->
OpdsCatalogCard(
catalog = catalog,
onClick = { opdsViewModel.openCatalog(catalog) },
onEdit = if (catalog.isDefault) null else {
{
editingCatalog = catalog
showCatalogDialog = true
}
},
onDelete = if (catalog.isDefault) null else {
{ catalogToDelete = catalog }
})
}
}
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_add_catalog)) },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = {
editingCatalog = null
showCatalogDialog = true
},
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
)
}
} else {
// Screen 2: Viewing a specific feed/catalog
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
var showSearch by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
LaunchedEffect(showSearch) {
if (showSearch) {
delay(100)
searchFocusRequester.requestFocus()
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(64.dp)
.padding(horizontal = 4.dp)
) {
IconButton(onClick = {
if (showSearch) {
showSearch = false
query = ""
} else {
opdsViewModel.navigateBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
if (showSearch) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) },
modifier = Modifier.weight(1f).padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
IconButton(onClick = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
}) {
Icon(Icons.Default.Search, "Search")
}
},
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Search
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSearch = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
})
)
} else {
Text(
text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
if (uiState.searchUrlTemplate != null) {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, "Search")
}
}
}
}
if (uiState.isLoading) {
androidx.compose.material3.LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
)
}
}
}
if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.feed_empty))
}
} else {
val facets = uiState.currentFeed?.facets ?: emptyList()
if (facets.isNotEmpty()) {
val groups = facets.groupBy { it.group }
LazyRow(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
groups.forEach { (groupName, groupFacets) ->
item(key = groupName) {
var expanded by remember { mutableStateOf(false) }
val activeFacet = groupFacets.find { it.isActive }
?: groupFacets.firstOrNull()
Box {
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
null
)
})
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
groupFacets.forEach { facet ->
DropdownMenuItem(
text = { Text(facet.title) },
onClick = {
expanded = false
opdsViewModel.openFeedUrl(facet.url)
},
trailingIcon = if (facet.isActive) {
{ Icon(Icons.Default.Check, null) }
} else null)
}
}
}
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val entries = uiState.currentFeed?.entries ?: emptyList()
itemsIndexed(
entries,
key = { index, item -> "${item.id}_$index" }) { index, entry ->
if (index == entries.lastIndex) {
LaunchedEffect(index) { opdsViewModel.loadNextPage() }
}
if (entry.isNavigation) {
OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
} else {
OpdsBookCard(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition ->
opdsViewModel.downloadBook(
entry, acquisition, context
) { downloadedUri ->
onBookDownloaded(downloadedUri, entry.title)
}
},
onReadClick = onReadBook,
onStreamClick = {
onStreamBook(
entry,
uiState.currentCatalog
)
},
onClick = { selectedEntry = entry })
}
}
}
}
}
}
}
// Error Banner overlay
uiState.errorMessage?.let { error ->
LaunchedEffect(error) {
delay(4000)
opdsViewModel.clearError()
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
.padding(bottom = 70.dp)
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
if (selectedEntry != null) {
OpdsBookDetailsSheet(
entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id],
coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title)
}
},
onReadClick = onReadBook,
onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } },
onAuthorOrCategoryClick = { url, fallbackName ->
if (url != null) opdsViewModel.openFeedUrl(url)
else opdsViewModel.search(fallbackName)
selectedEntry = null
},
onDismiss = { selectedEntry = null }
)
}
}
// Dynamic Add/Edit Dialog
if (showCatalogDialog) {
var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") }
var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") }
var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") }
var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") }
val isEditMode = editingCatalog != null
AlertDialog(
onDismissRequest = {
showCatalogDialog = false
editingCatalog = null
},
title = { Text(if (isEditMode) stringResource(R.string.edit_catalog) else stringResource(R.string.add_opds_catalog)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text(stringResource(R.string.catalog_name)) },
singleLine = true
)
OutlinedTextField(
value = newUrl,
onValueChange = { newUrl = it },
label = { Text(stringResource(R.string.url)) },
placeholder = { Text(stringResource(R.string.url_placeholder)) },
singleLine = true
)
Text(stringResource(R.string.auth_optional),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
OutlinedTextField(
value = newUsername,
onValueChange = { newUsername = it },
label = { Text(stringResource(R.string.username)) },
singleLine = true
)
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isEditMode) {
opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword)
} else {
opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword)
}
showCatalogDialog = false
editingCatalog = null
},
enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
) { Text(stringResource(R.string.action_save)) }
},
dismissButton = {
TextButton(onClick = {
showCatalogDialog = false
editingCatalog = null
}) { Text(stringResource(R.string.action_cancel)) }
}
)
}
if (catalogToDelete != null) {
val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
AlertDialog(
onDismissRequest = { catalogToDelete = null },
title = { Text(stringResource(R.string.delete_catalog)) },
text = {
Column {
Text(stringResource(R.string.delete_catalog_desc, catalogToDelete!!.title))
if (streamedBooksCount > 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
stringResource(R.string.delete_catalog_warning, streamedBooksCount),
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(
onClick = {
opdsViewModel.removeCatalog(catalogToDelete!!.id)
if (streamedBooksCount > 0) {
onDeleteCatalogStreams(catalogToDelete!!.id)
}
catalogToDelete = null
},
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = { catalogToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
}
@Composable
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
val context = LocalContext.current.applicationContext
val username = catalog?.username
val password = catalog?.password
val imageLoader = remember(context, username, password) {
ImageLoader.Builder(context)
.okHttpClient {
OpdsRepository.sharedHttpClient.newBuilder()
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
.build()
}
.components {
add(SvgDecoder.Factory())
}
.build()
}
DisposableEffect(imageLoader) {
onDispose { imageLoader.shutdown() }
}
return imageLoader
}
@Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (catalog.isDefault) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(stringResource(R.string.preset_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (onEdit != null) {
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.label_edit))
}
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_remove))
}
}
}
}
}
@Composable
fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
Surface(
onClick = { entry.navigationUrl?.let { onClick(it) } },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(entry.title, style = MaterialTheme.typography.titleMedium)
entry.summary?.let {
val cleanSummary = remember(it) { Jsoup.parse(it).text() }
Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
fun OpdsBookCard(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onClick: () -> Unit
) {
val libraryItem = remember(entry, localLibraryFiles) {
SharedOpdsLocalBookMatcher.find(
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
var showFormatMenu by remember { mutableStateOf(false) }
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(12.dp)) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
entry.author?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
entry.summary?.let {
val cleanSummary = remember(it) { Jsoup.parse(it).text() }
Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
Spacer(modifier = Modifier.height(8.dp))
if (libraryItem != null) {
androidx.compose.material3.OutlinedButton(
onClick = { onReadClick(libraryItem) },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_read))
}
} else if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
}
}
Spacer(modifier = Modifier.height(4.dp))
if (progress != null) {
androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
} else {
androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (entry.isStreamable) {
FilledTonalButton(
onClick = onStreamClick,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_stream))
}
}
Box {
FilledTonalButton(
onClick = {
if (uniqueAcquisitions.size == 1) {
onDownloadClick(uniqueAcquisitions.first())
} else if (uniqueAcquisitions.size > 1) {
showFormatMenu = true
}
},
enabled = uniqueAcquisitions.isNotEmpty(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
if (uniqueAcquisitions.isEmpty()) {
Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_unavailable))
} else {
Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_download))
}
}
}
DropdownMenu(
expanded = showFormatMenu,
onDismissRequest = { showFormatMenu = false }
) {
uniqueAcquisitions.forEach { acq ->
DropdownMenuItem(
text = { Text(acq.formatName) },
onClick = {
showFormatMenu = false
onDownloadClick(acq)
}
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onAuthorOrCategoryClick: (String?, String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) {
SharedOpdsLocalBookMatcher.find(
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 110.dp, height = 160.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
lineHeight = 28.sp
)
if (entry.authors.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
entry.authors.forEach { author ->
Text(
text = author.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
onAuthorOrCategoryClick(author.url, author.name)
}
)
}
}
}
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
Spacer(modifier = Modifier.height(8.dp))
val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series
Text(
text = seriesText,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable {
onAuthorOrCategoryClick(null, series)
}
)
}
}
}
if (libraryItem != null) {
androidx.compose.material3.Button(
onClick = {
onDismiss()
onReadClick(libraryItem)
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.action_read))
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.action_read), fontWeight = FontWeight.Bold)
}
}
if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
}
}
Spacer(modifier = Modifier.height(8.dp))
if (progress != null) {
androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp))
} else {
androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp))
}
}
} else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) {
if (entry.isStreamable) {
androidx.compose.material3.Button(
onClick = {
onStreamClick()
onDismiss()
},
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.action_stream_now), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (uniqueAcquisitions.isNotEmpty()) {
Text(stringResource(R.string.download_format),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
uniqueAcquisitions.forEach { acq ->
FilledTonalButton(onClick = { onDownloadFormat(acq) }) {
Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(acq.formatName, fontWeight = FontWeight.Bold)
}
}
}
}
} else {
Text(stringResource(R.string.no_supported_formats), color = MaterialTheme.colorScheme.error)
}
if (entry.categories.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
entry.categories.distinct().forEach { category ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
onClick = { onAuthorOrCategoryClick(null, category) }
) {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
}
}
val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank()
if (hasSecondaryMeta) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
entry.publisher?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.publisher), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
entry.published?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.published), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
val cleanDate = it.substringBefore("T")
Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
entry.language?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
}
}
}
val summary = entry.summary
if (!summary.isNullOrBlank()) {
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(summary) {
val preProcessed = summary
.replace("<br>", "\n")
.replace("</p>", "\n\n")
Jsoup.parse(preProcessed).text().trim()
}
Text(
text = cleanSummary,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 24.sp,
modifier = Modifier.padding(bottom = 48.dp)
)
} else {
Spacer(modifier = Modifier.height(48.dp))
}
}
}
}