/* * 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 package com.aryan.reader import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import com.aryan.reader.opds.OpdsViewModel import com.aryan.reader.opds.OpdsEntry import com.aryan.reader.opds.OpdsCatalog import org.jsoup.Jsoup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.AssistChip import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.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.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit 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.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.runtime.Composable 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.res.painterResource 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 coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem import com.aryan.reader.opds.OpdsAcquisition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale private fun getBookCountString(count: Int): String { return if (count == 1) "1 book" else "$count books" } @Composable fun LibraryScreen( viewModel: MainViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val selectedItems = uiState.contextualActionItems val isContextualModeActive = selectedItems.isNotEmpty() val selectedShelves = uiState.contextualActionShelfNames val isShelfContextualModeActive = selectedShelves.isNotEmpty() val sortOrder = uiState.sortOrder val shelves = uiState.shelves val rawLibraryFiles = uiState.rawLibraryFiles val pagerState = rememberPagerState( initialPage = uiState.libraryScreenStartPage, pageCount = { 4 } ) val containsFolderItems = remember(selectedItems) { selectedItems.any { it.sourceFolderUri != null } } LaunchedEffect(uiState.libraryScreenStartPage) { if (pagerState.currentPage != uiState.libraryScreenStartPage) { pagerState.animateScrollToPage(uiState.libraryScreenStartPage) } } 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("Your device doesn't support folder selection. You can still import files individually.", isError = true) } } val pickFileLauncher = rememberFilePickerLauncher { uris -> if (isContextualModeActive) { viewModel.clearContextualAction() } uris.forEach { uri -> viewModel.onFileSelected(uri, isFromRecent = false) } } val fallbackFilePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents() ) { uris -> if (isContextualModeActive) { viewModel.clearContextualAction() } uris.forEach { uri -> viewModel.onFileSelected(uri, isFromRecent = false) } } val onSelectFileClick = { if (isContextualModeActive) { viewModel.clearContextualAction() } try { pickFileLauncher.launch(arrayOf("*/*")) } catch (_: android.content.ActivityNotFoundException) { Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.") try { fallbackFilePickerLauncher.launch("*/*") } catch (_: android.content.ActivityNotFoundException) { viewModel.showBanner("No file manager found. Please install a file manager app.", isError = true) } } } LaunchedEffect(pagerState) { androidx.compose.runtime.snapshotFlow { pagerState.settledPage } .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( recentFiles = uiState.allRecentFiles, rawLibraryFiles = rawLibraryFiles, shelves = shelves, selectedItems = selectedItems, selectedShelves = selectedShelves, sortOrder = sortOrder, libraryFilters = uiState.libraryFilters, 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) }, 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) }, onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, downloadingBookIds = uiState.downloadingBookIds, lastFolderScanTime = uiState.lastFolderScanTime, isLoading = uiState.isLoading, isRefreshing = uiState.isRefreshing, onOpdsBookDownloaded = { uri, title -> viewModel.showBanner("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 ) 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, 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, onDismiss = { showInfoDialog = false itemForInfoDialog = null }, onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) } ) } } CustomTopBanner(bannerMessage = uiState.bannerMessage) } } @Composable fun ShelfScreen( viewModel: MainViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val selectedItems = uiState.contextualActionItems val viewingShelfName = uiState.viewingShelfName 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.unselectShelf() } } val currentShelf = shelves.find { it.name == viewingShelfName } Box(modifier = Modifier.fillMaxSize()) { if (viewingShelfName != null && currentShelf != null) { if (isAddingBooks) { AddBooksModeScreen( shelfName = viewingShelfName, 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(viewingShelfName) }, downloadingBookIds = uiState.downloadingBookIds ) } else { ShelfDetailScreen( shelf = currentShelf, selectedItems = selectedItems, sortOrder = sortOrder, onSortOrderChange = viewModel::setSortOrder, onBack = viewModel::unselectShelf, onAddBooksClick = viewModel::showAddBooksToShelf, onBookClick = viewModel::onRecentFileClicked, onBookLongClick = viewModel::onRecentItemLongPress, onClearSelection = viewModel::clearContextualAction, onInfoClick = { if (selectedItems.size == 1) { itemForInfoDialog = selectedItems.first() showInfoDialog = true } }, onDeleteClick = { showRemoveFromShelfDialog = true }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) }, downloadingBookIds = uiState.downloadingBookIds ) } } if (showRenameDialogFor != null) { RenameShelfDialog( initialName = showRenameDialogFor, onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) }, onDismiss = viewModel::dismissRenameShelfDialog ) } if (showDeleteDialogFor != null) { DeleteShelfConfirmationDialog( shelfName = showDeleteDialogFor, 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, onDismiss = { showInfoDialog = false itemForInfoDialog = null }, onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) } ) } } CustomTopBanner(bannerMessage = uiState.bannerMessage) } } @Suppress("unused") @OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryScreenContent( recentFiles: List, rawLibraryFiles: List, shelves: List, selectedItems: Set, selectedShelves: Set, sortOrder: SortOrder, libraryFilters: LibraryFilters, 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, 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, onOpdsBookDownloaded: (Uri, String) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onDeleteCatalogStreams: (String) -> Unit, ) { val isBookContextualModeActive = selectedItems.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty() var showSortMenu by remember { mutableStateOf(false) } val tabTitles = listOf("All Books", "Shelves", "Folders", "Catalogs") val searchFocusRequester = remember { FocusRequester() } 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, 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() .height(64.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { onSearchActiveChange(false) }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") } OutlinedTextField( value = textFieldValue, onValueChange = { textFieldValue = it onSearchQueryChange(it.text) }, placeholder = { Text("Search title or author...") }, 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 = { if (searchQuery.isNotEmpty()) { IconButton(onClick = { onSearchQueryChange("") }) { Icon(Icons.Default.Close, contentDescription = "Clear query") } } } ) } } } else { CustomTopAppBar( title = { Text("Library") }, actions = { if (pagerState.currentPage == 0) { IconButton(onClick = onFilterClick) { Icon(Icons.Default.FilterList, contentDescription = "Filter") } Box { TextButton(onClick = { showSortMenu = true }) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = "Sort", modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(sortOrder.displayName) } DropdownMenu( expanded = showSortMenu, onDismissRequest = { showSortMenu = false } ) { SortOrder.entries.forEach { order -> DropdownMenuItem( text = { Text(order.displayName) }, onClick = { onSortOrderChange(order) showSortMenu = false }, trailingIcon = { if (order == sortOrder) { Icon( Icons.Default.Check, contentDescription = "Selected" ) } } ) } } } IconButton(onClick = { onSearchActiveChange(true) }) { Icon(Icons.Default.Search, contentDescription = "Search") } } } ) TabRow(selectedTabIndex = pagerState.currentPage) { tabTitles.forEachIndexed { index, title -> Tab( selected = pagerState.currentPage == index, onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, 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("Types: ${libraryFilters.fileTypes.joinToString { it.name }}") }, trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } ) } if (libraryFilters.sourceFolders.isNotEmpty()) { AssistChip( onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) }, label = { Text("Folders: ${libraryFilters.sourceFolders.size}") }, trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } ) } if (libraryFilters.readStatus != ReadStatusFilter.ALL) { AssistChip( onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) }, label = { Text("Status: ${libraryFilters.readStatus.displayName}") }, trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } ) } } } } } }, floatingActionButton = { if (!isBookContextualModeActive && !isShelfContextualModeActive) { when (pagerState.currentPage) { 0 -> { if (recentFiles.isNotEmpty()) { ExtendedFloatingActionButton( text = { Text("Add file") }, icon = { Icon(Icons.Default.Add, contentDescription = "Add file") }, onClick = onSelectFileClick, modifier = Modifier.padding(16.dp) ) } } 1 -> { ExtendedFloatingActionButton( text = { Text("New shelf") }, icon = { Icon(Icons.Default.Add, contentDescription = "New shelf") }, onClick = onNewShelfClick, modifier = Modifier.padding(16.dp) ) } } } } ) { paddingValues -> HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() .padding(paddingValues), key = { it } ) { page -> when (page) { 0 -> { if (recentFiles.isEmpty() && searchQuery.isNotEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No results found for \"$searchQuery\"") } } else if (recentFiles.isEmpty()) { EmptyState( title = "Your Library is Empty", message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.", 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 = selectedItems.any { it.bookId == item.bookId }, isPinned = item.bookId in pinnedLibraryBookIds, onItemClick = { onItemClick(item) }, onItemLongClick = { onItemLongClick(item) }, isDownloading = item.bookId in downloadingBookIds ) } } } } 1 -> { ShelvesScreen( shelves = shelves, onShelfClick = onShelfClick, onShelfLongClick = onShelfLongClick, selectedShelves = selectedShelves ) } 2 -> { FolderSyncScreen( syncedFolders = syncedFolders, allRecentFiles = rawLibraryFiles, onAddFolderClick = onSelectSyncFolderClick, onRemoveFolderClick = onRemoveFolderClick, onEditFolderFiltersClick = onEditFolderFiltersClick, onScanNowClick = onScanNowClick, onSyncMetadataClick = onSyncMetadataClick, isLoading = isLoading || isRefreshing ) } 3 -> { 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, ) { LazyColumn( modifier = Modifier .fillMaxSize(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(shelves, key = { it.name }) { shelf -> ShelfListItem( shelf = shelf, isSelected = shelf.name 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("Create New Shelf") }, text = { OutlinedTextField( value = text, onValueChange = { text = it }, placeholder = { Text("Shelf Name") }, singleLine = true, modifier = Modifier.focusRequester(focusRequester) ) }, confirmButton = { TextButton( onClick = { onConfirm(text) }, enabled = text.isNotBlank() ) { Text("Create") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) LaunchedEffect(Unit) { delay(100) focusRequester.requestFocus() } } @Composable private fun ShelfDetailScreen( shelf: Shelf, selectedItems: Set, sortOrder: SortOrder, onSortOrderChange: (SortOrder) -> Unit, onBack: () -> Unit, onAddBooksClick: () -> Unit, onBookClick: (RecentFileItem) -> Unit, onBookLongClick: (RecentFileItem) -> Unit, onClearSelection: () -> Unit, onInfoClick: () -> Unit, onDeleteClick: () -> Unit, onRenameShelf: () -> Unit, onDeleteShelf: () -> Unit, downloadingBookIds: Set, ) { val isContextualModeActive = selectedItems.isNotEmpty() var showSortMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.statusBarsPadding(), topBar = { if (isContextualModeActive) { ContextualTopAppBar( selectedItemCount = selectedItems.size, onNavIconClick = onClearSelection, onInfoClick = onInfoClick, onDeleteClick = onDeleteClick ) } else { CustomTopAppBar( title = { Column { Text( text = shelf.name, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = getBookCountString(shelf.bookCount), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { Box { TextButton(onClick = { showSortMenu = true }) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = "Sort", modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(sortOrder.displayName) } DropdownMenu( expanded = showSortMenu, onDismissRequest = { showSortMenu = false } ) { SortOrder.entries.forEach { order -> DropdownMenuItem( text = { Text(order.displayName) }, onClick = { onSortOrderChange(order) showSortMenu = false }, trailingIcon = { if (order == sortOrder) { Icon( Icons.Default.Check, contentDescription = "Selected" ) } } ) } } } if (shelf.name != "Unshelved") { Box { IconButton(onClick = { showMoreMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = "More options" ) } DropdownMenu( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false } ) { DropdownMenuItem( text = { Text("Rename shelf") }, onClick = { onRenameShelf() showMoreMenu = false } ) DropdownMenuItem( text = { Text("Delete shelf") }, onClick = { onDeleteShelf() showMoreMenu = false } ) } } } } ) } }, floatingActionButton = { if (shelf.name != "Unshelved" && !isContextualModeActive) { ExtendedFloatingActionButton( onClick = onAddBooksClick, icon = { Icon(Icons.Default.Add, contentDescription = null) }, text = { Text("Add books") } ) } } ) { paddingValues -> if (shelf.books.isEmpty()) { Box( modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center ) { Text("This shelf is empty", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn( modifier = Modifier.fillMaxSize().padding(paddingValues), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(shelf.books, 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 ) } } } } } @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, ) { var showSortMenu by remember { mutableStateOf(false) } var showSourceMenu by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.statusBarsPadding(), topBar = { CustomTopAppBar( title = { Text("Add to $shelfName") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { Box { TextButton(onClick = { showSortMenu = true }) { Icon( painter = painterResource(id = R.drawable.sort), contentDescription = "Sort", modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(sortOrder.displayName) } DropdownMenu( expanded = showSortMenu, onDismissRequest = { showSortMenu = false } ) { SortOrder.entries.forEach { order -> DropdownMenuItem( text = { Text(order.displayName) }, onClick = { onSortOrderChange(order) showSortMenu = false }, trailingIcon = { if (order == sortOrder) { Icon(Icons.Default.Check, contentDescription = "Selected") } } ) } } } Box { IconButton(onClick = { showSourceMenu = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More options") } DropdownMenu( expanded = showSourceMenu, onDismissRequest = { showSourceMenu = false } ) { AddBooksSource.entries.forEach { source -> DropdownMenuItem( text = { Text(source.displayName) }, onClick = { onSourceChange(source) showSourceMenu = false }, trailingIcon = { if (source == currentSource) { Icon(Icons.Default.Check, contentDescription = "Selected") } } ) } } } } ) }, floatingActionButton = { if (selectedBookUris.isNotEmpty()) { ExtendedFloatingActionButton( text = { Text("ADD (${selectedBookUris.size})") }, icon = { Icon(Icons.Default.Check, contentDescription = "Add books") }, onClick = onAddSelectedBooks ) } } ) { paddingValues -> if (availableBooks.isEmpty()) { Box( modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center ) { Text( text = if (currentSource == AddBooksSource.UNSHELVED) "No unshelved books to add" else "All books are already in this 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 ) } } } } } @Composable private fun ShelfCover(shelf: Shelf) { val context = LocalContext.current val placeholder = R.drawable.epub_placeholder 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 imageModel = remember(shelf.topBook?.coverImagePath) { shelf.topBook?.coverImagePath?.let { File(it) } ?: placeholder } AsyncImage( model = ImageRequest.Builder(context) .data(imageModel) .error(placeholder) .fallback(placeholder) .crossfade(true) .build(), contentDescription = "${shelf.name} shelf cover", contentScale = ContentScale.Crop, 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 -> val imageModel = remember(book.coverImagePath) { book.coverImagePath?.let { File(it) } ?: placeholder } Surface( shape = MaterialTheme.shapes.small, shadowElevation = 4.dp, modifier = Modifier .size(width = coverWidth, height = coverHeight) .align(Alignment.CenterEnd) .offset(x = -horizontalOffset * index) ) { AsyncImage( model = ImageRequest.Builder(context) .data(imageModel) .error(placeholder) .fallback(placeholder) .crossfade(true) .build(), contentDescription = null, contentScale = ContentScale.Crop ) } } } } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun ShelfListItem( shelf: Shelf, isSelected: Boolean, onItemClick: () -> Unit, onItemLongClick: () -> Unit, ) { Surface( shape = MaterialTheme.shapes.medium, tonalElevation = if (isSelected) 8.dp else 2.dp, shadowElevation = 4.dp, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = onItemClick, onLongClick = { if (shelf.name != "Unshelved") { onItemLongClick() } } ) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { ShelfCover(shelf = shelf) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { 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, ) { val context = LocalContext.current val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder } val imageModel = remember(item.coverImagePath) { item.coverImagePath?.let { File(it) } ?: placeholder } Surface( shape = MaterialTheme.shapes.medium, tonalElevation = if (isSelected) 8.dp else 2.dp, shadowElevation = 4.dp, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, modifier = Modifier .fillMaxWidth() .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } .combinedClickable( onClick = onItemClick, onLongClick = onItemLongClick ) ) { Row( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top ) { AsyncImage( model = ImageRequest.Builder(context) .data(imageModel) .error(placeholder) .fallback(placeholder) .crossfade(true) .build(), contentDescription = item.displayName, contentScale = ContentScale.Crop, modifier = Modifier .size(width = 70.dp, height = 100.dp) .clip(MaterialTheme.shapes.small) ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { if (item.sourceFolderUri != null) { Icon( imageVector = Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.secondary ) Spacer(modifier = Modifier.width(4.dp)) } val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true if (isOpdsStream) { Icon( imageVector = Icons.Default.Cloud, contentDescription = "OPDS Stream", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.tertiary ) Spacer(modifier = Modifier.width(4.dp)) } if (isPinned) { Icon( imageVector = Icons.Default.PushPin, contentDescription = "Pinned", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(4.dp)) } Text( text = item.customName ?: item.title ?: item.displayName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) if (!item.isAvailable) { Spacer(modifier = Modifier.width(8.dp)) if (isDownloading) { CircularProgressIndicator(modifier = Modifier.size(18.dp)) } else { Icon( imageVector = Icons.Filled.Info, contentDescription = "Not available locally", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } } item.author?.takeIf { it.isNotBlank() }?.let { Spacer(modifier = Modifier.height(4.dp)) Text( text = it, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant ) } item.progressPercentage?.let { Spacer(modifier = Modifier.height(8.dp)) Text( text = "${it.toInt()}% complete", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) } } } } } @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("Rename Shelf") }, text = { OutlinedTextField( value = textFieldValue, onValueChange = { textFieldValue = it }, placeholder = { Text("Shelf Name") }, singleLine = true, modifier = Modifier.focusRequester(focusRequester) ) }, confirmButton = { TextButton( onClick = { onConfirm(textFieldValue.text) }, enabled = textFieldValue.text.isNotBlank() && textFieldValue.text != initialName ) { Text("Rename") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) LaunchedEffect(Unit) { delay(100) focusRequester.requestFocus() } } @Composable private fun DeleteShelfConfirmationDialog( shelfName: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Delete Shelf?") }, text = { Text("Are you sure you want to delete the '$shelfName' shelf? All books will be moved to Unshelved.") }, confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) } @Composable private fun RemoveFromShelfConfirmationDialog( count: Int, shelfName: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val bookStr = if (count == 1) "book" else "books" AlertDialog( onDismissRequest = onDismiss, title = { Text("Remove from Shelf?") }, text = { Text("Are you sure you want to remove $count $bookStr from the '$shelfName' shelf? The book(s) will remain in your library and appear under Unshelved.") }, confirmButton = { TextButton(onClick = onConfirm) { Text("Remove") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) } @Composable private fun DeleteShelvesConfirmationDialog( count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val shelfStr = if (count == 1) "shelf" else "shelves" AlertDialog( onDismissRequest = onDismiss, title = { Text("Delete $shelfStr?") }, text = { Text("Are you sure you want to delete the $count selected $shelfStr? All books within will be moved to Unshelved.") }, confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) } @Composable private fun FolderSyncScreen( syncedFolders: List, allRecentFiles: List, onAddFolderClick: () -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit, onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, onScanNowClick: () -> Unit, onSyncMetadataClick: () -> Unit, isLoading: Boolean ) { var editingFolder by remember { mutableStateOf(null) } Scaffold( floatingActionButton = { if (syncedFolders.size < 3) { ExtendedFloatingActionButton( text = { Text("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, 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) "Scanning..." else "Scan All") } androidx.compose.material3.OutlinedButton( onClick = onSyncMetadataClick, enabled = !isLoading, 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("Sync Meta") } } } else { EmptyState( title = "Sync Local Folders", message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.", onSelectFileClick = onAddFolderClick, primaryButtonText = "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, allRecentFiles = allRecentFiles, onRemoveClick = onRemoveFolderClick, onEditFiltersClick = { editingFolder = folder } ) } } } } if (editingFolder != null) { EditFolderFiltersDialog( folder = editingFolder!!, onConfirm = { newFilters -> onEditFolderFiltersClick(editingFolder!!, newFilters) editingFolder = null }, onDismiss = { editingFolder = null } ) } } @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun FolderCard( folder: SyncedFolder, allRecentFiles: List, onRemoveClick: (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) "Never" else dateFormat.format(Date(folder.lastScanTime)) val folderFiles = remember(allRecentFiles, folder.uriString) { allRecentFiles.filter { it.sourceFolderUri == folder.uriString } } val totalBooks = folderFiles.size val countsByType = remember(folderFiles) { folderFiles.groupBy { it.type }.mapValues { it.value.size } } 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)) Text( text = folder.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } Box { IconButton(onClick = { showMenu = true }) { Icon(Icons.Default.MoreVert, "Options") } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { DropdownMenuItem( text = { Text("Edit Filters") }, onClick = { showMenu = false onEditFiltersClick(folder) } ) DropdownMenuItem( text = { Text("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 = "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 = "BOOKS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold ) Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium) } } if (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() ) { countsByType.forEach { (type, count) -> AssistChip( onClick = { }, label = { Text("${type.name}: $count") } ) } } } } } } @Composable private fun EditFolderFiltersDialog( folder: SyncedFolder, onConfirm: (Set) -> Unit, onDismiss: () -> Unit ) { var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Filter File Types") }, text = { Column { Text( text = "Select the file types you want to sync from this folder:", style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.height(8.dp)) FileType.entries.forEach { type -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type } .padding(vertical = 4.dp) ) { androidx.compose.material3.Checkbox( checked = type in selectedTypes, onCheckedChange = { checked -> selectedTypes = if (checked) selectedTypes + type else selectedTypes - type } ) Spacer(modifier = Modifier.width(8.dp)) Text(type.name) } } } }, confirmButton = { TextButton( onClick = { onConfirm(selectedTypes) }, enabled = selectedTypes.isNotEmpty() ) { Text("Save") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryFilterSheet( filters: LibraryFilters, 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("Filter Library", style = MaterialTheme.typography.titleLarge) Text("File Type", style = MaterialTheme.typography.titleMedium) Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FileType.entries.forEach { type -> FilterChip( selected = type in currentFilters.fileTypes, onClick = { val newSet = if (type in currentFilters.fileTypes) currentFilters.fileTypes - type else currentFilters.fileTypes + type currentFilters = currentFilters.copy(fileTypes = newSet) }, label = { Text(type.name) } ) } } if (syncedFolders.isNotEmpty()) { Text("Source Folder", style = MaterialTheme.typography.titleMedium) Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { syncedFolders.forEach { folder -> FilterChip( selected = folder.uriString in currentFilters.sourceFolders, onClick = { val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString currentFilters = currentFilters.copy(sourceFolders = newSet) }, label = { Text(folder.name) } ) } } } Text("Read Status", style = MaterialTheme.typography.titleMedium) Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ReadStatusFilter.entries.forEach { status -> FilterChip( selected = currentFilters.readStatus == status, onClick = { currentFilters = currentFilters.copy(readStatus = status) }, label = { Text(status.displayName) } ) } } Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { TextButton(onClick = { currentFilters = LibraryFilters() }) { Text("Clear All") } Spacer(modifier = Modifier.width(8.dp)) androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) { Text("Apply") } } Spacer(modifier = Modifier.height(32.dp)) } } } @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 by opdsViewModel.downloadingState.collectAsStateWithLifecycle() val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle() val context = LocalContext.current 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("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("Search catalog...") }, 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 ?: "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("This feed is 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("${groupName}: ${activeFacet?.title ?: "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], 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], 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) "Edit Catalog" else "Add OPDS Catalog") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = newTitle, onValueChange = { newTitle = it }, label = { Text("Catalog Name") }, singleLine = true ) OutlinedTextField( value = newUrl, onValueChange = { newUrl = it }, label = { Text("URL") }, placeholder = { Text("e.g. http://192.168.1.50:8080/opds") }, singleLine = true ) Text( "Authentication (Optional)", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp) ) OutlinedTextField( value = newUsername, onValueChange = { newUsername = it }, label = { Text("Username") }, singleLine = true ) OutlinedTextField( value = newPassword, onValueChange = { newPassword = it }, label = { Text("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("Save") } }, dismissButton = { TextButton(onClick = { showCatalogDialog = false editingCatalog = null }) { Text("Cancel") } } ) } if (catalogToDelete != null) { val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true } AlertDialog( onDismissRequest = { catalogToDelete = null }, title = { Text("Delete Catalog") }, text = { Column { Text("Are you sure you want to delete '${catalogToDelete!!.title}'?") if (streamedBooksCount > 0) { Spacer(modifier = Modifier.height(8.dp)) Text( "Deleting this catalog will also permanently remove $streamedBooksCount streaming books associated with it from your library.", 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("Delete") } }, dismissButton = { TextButton(onClick = { catalogToDelete = null }) { Text("Cancel") } } ) } } @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( text = "Preset", 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 = "Edit") } } if (onDelete != null) { IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "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: OpdsViewModel.DownloadState?, onDownloadClick: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, onClick: () -> Unit ) { val libraryItem = remember(entry, localLibraryFiles) { localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) } } 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, 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("Read") } } else if (isDownloading) { Column(modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { Text("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("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("Unavailable") } else { Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) Text("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: OpdsViewModel.DownloadState?, onDownloadFormat: (OpdsAcquisition) -> Unit, onReadClick: (RecentFileItem) -> Unit, onStreamClick: () -> Unit, onAuthorOrCategoryClick: (String?, String) -> Unit, onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val libraryItem = remember(entry, localLibraryFiles) { localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) } } 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, 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 = "Read") Spacer(modifier = Modifier.width(8.dp)) Text("Read", fontWeight = FontWeight.Bold) } } if (isDownloading) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("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("Stream Now", fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(16.dp)) } if (uniqueAcquisitions.isNotEmpty()) { Text( "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("No supported formats available.", 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("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("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("LANGUAGE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) } } } } } if (!entry.summary.isNullOrBlank()) { Text("Synopsis", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val cleanSummary = remember(entry.summary) { val preProcessed = entry.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)) } } } }