/* * 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 . * * 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(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(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, recentFiles: List, rawLibraryFiles: List, shelves: List, selectedItems: Set, selectedShelves: Set, sortOrder: SortOrder, libraryFilters: LibraryFilters, allTags: List, pinnedLibraryBookIds: Set, 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) -> Unit, onDisconnectSyncFolderClick: () -> Unit, downloadingBookIds: Set, lastFolderScanTime: Long?, isLoading: Boolean, isRefreshing: Boolean, syncedFolders: List, 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, onShelfClick: (Shelf) -> Unit, onShelfLongClick: (Shelf) -> Unit, selectedShelves: Set, ) { 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, selectedItems: Set, 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, 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, selectedBookUris: Set, currentSource: AddBooksSource, sortOrder: SortOrder, onSortOrderChange: (SortOrder) -> Unit, onSourceChange: (AddBooksSource) -> Unit, onBookClick: (RecentFileItem) -> Unit, onBack: () -> Unit, onAddSelectedBooks: () -> Unit, downloadingBookIds: Set, 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, allRecentFiles: List, onAddFolderClick: () -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit, onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, onScanNowClick: () -> Unit, onSyncMetadataClick: () -> Unit, isLoading: Boolean ) { var editingFolder by remember { mutableStateOf(null) } var disablingFolder by remember { mutableStateOf(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 ) { 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) -> 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, syncedFolders: List, 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, 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(null) } var showCatalogDialog by remember { mutableStateOf(false) } var editingCatalog by remember { mutableStateOf(null) } var catalogToDelete by remember { mutableStateOf(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, 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, 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("
", "\n") .replace("

", "\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)) } } } }