* 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
3352 lines
150 KiB
Kotlin
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))
|
|
}
|
|
}
|
|
}
|
|
}
|