book-reader/app/src/main/java/com/aryan/reader/LibraryScreen.kt
Aryan 381193d774
String extract (#148)
* FolderSyncWorker: update supported file types in getFileType

* Extract hardcoded strings to `strings.xml` and `plurals.xml`

* Extract hardcoded strings to `strings.xml` and `plurals.xml`

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in MainViewModel

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in Common.kt

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in Epub Reader
2026-04-05 09:32:59 +05:30

2732 lines
No EOL
120 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// LibraryScreen.kt
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.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 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
@Composable
private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count)
}
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
) {
val context = LocalContext.current
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(context.getString(R.string.error_folder_selection_unsupported), 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(context.getString(R.string.error_no_file_manager), 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<RecentFileItem?>(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(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
)
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<RecentFileItem?>(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<RecentFileItem>,
rawLibraryFiles: List<RecentFileItem>,
shelves: List<Shelf>,
selectedItems: Set<RecentFileItem>,
selectedShelves: Set<String>,
sortOrder: SortOrder,
libraryFilters: LibraryFilters,
pinnedLibraryBookIds: Set<String>,
pagerState: PagerState,
scope: CoroutineScope,
searchQuery: String,
isSearchActive: Boolean,
onSearchQueryChange: (String) -> Unit,
onSearchActiveChange: (Boolean) -> Unit,
onSortOrderChange: (SortOrder) -> Unit,
onFilterClick: () -> Unit,
onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit,
onPinClick: () -> Unit,
onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
onClearShelfSelection: () -> Unit,
onDeleteShelves: () -> Unit,
onNewShelfClick: () -> Unit,
onSelectFileClick: () -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>,
lastFolderScanTime: Long?,
isLoading: Boolean,
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit,
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(
stringResource(R.string.tab_all_books),
stringResource(R.string.tab_shelves),
stringResource(R.string.tab_folders),
stringResource(R.string.tab_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(stringResource(R.string.search_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 = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear query")
}
}
}
)
}
}
} else {
CustomTopAppBar(title = { Text(stringResource(R.string.library_title)) },
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(stringResource(R.string.filter_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(stringResource(R.string.filter_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(stringResource(R.string.filter_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(stringResource(R.string.fab_add_file)) },
icon = { Icon(Icons.Default.Add, contentDescription = "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 = "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(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 = 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<Shelf>,
onShelfClick: (Shelf) -> Unit,
onShelfLongClick: (Shelf) -> Unit,
selectedShelves: Set<String>,
) {
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(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,
selectedItems: Set<RecentFileItem>,
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<String>,
) {
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(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.name != "Unshelved" && !isContextualModeActive) {
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.fab_add_books)) }
)
}
}
) { paddingValues ->
if (shelf.books.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.shelf_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<RecentFileItem>,
selectedBookUris: Set<String>,
currentSource: AddBooksSource,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onSourceChange: (AddBooksSource) -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBack: () -> Unit,
onAddSelectedBooks: () -> Unit,
downloadingBookIds: Set<String>,
) {
var showSortMenu by remember { mutableStateOf(false) }
var showSourceMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, 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(stringResource(R.string.fab_add_count, 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) 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
)
}
}
}
}
}
@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, FileType.DOCX -> 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(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)) },
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
) {
val bookStr = pluralStringResource(id = R.plurals.book_word, count)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_remove_from_shelf)) },
text = { Text(stringResource(R.string.dialog_remove_from_shelf_desc, count, bookStr, shelfName)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_remove)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
private fun DeleteShelvesConfirmationDialog(
count: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val shelfStr = pluralStringResource(id = R.plurals.shelf_count, count)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_shelves, shelfStr)) },
text = { Text(stringResource(R.string.dialog_delete_shelves_desc, count, shelfStr)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
Scaffold(
floatingActionButton = {
if (syncedFolders.size < 3) {
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,
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,
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,
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<RecentFileItem>,
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) stringResource(R.string.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(stringResource(R.string.menu_edit_filters)) },
onClick = {
showMenu = false
onEditFiltersClick(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 = 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(stringResource(R.string.folder_filter_count, type.name, count)) }
)
}
}
}
}
}
}
@Composable
private fun EditFolderFiltersDialog(
folder: SyncedFolder,
onConfirm: (Set<FileType>) -> Unit,
onDismiss: () -> Unit
) {
var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.filter_file_types)) },
text = {
Column {
Text(
stringResource(R.string.filter_file_types_desc),
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(stringResource(R.string.action_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
syncedFolders: List<SyncedFolder>,
onApply: (LibraryFilters) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var currentFilters by remember { mutableStateOf(filters) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(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)
) {
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(stringResource(R.string.filter_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(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(status.displayName) }
)
}
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text(stringResource(R.string.clear_all))
}
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) {
Text(stringResource(R.string.action_apply))
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun OpdsTab(
localLibraryFiles: List<RecentFileItem>,
onBookDownloaded: (Uri, String) -> Unit,
onReadBook: (RecentFileItem) -> Unit,
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
opdsViewModel: OpdsViewModel = viewModel()
) {
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle()
val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle()
val context = LocalContext.current
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
BackHandler(enabled = uiState.isViewingCatalog) {
opdsViewModel.navigateBack()
}
Box(modifier = Modifier.fillMaxSize()) {
if (!uiState.isViewingCatalog) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 88.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.catalogs, key = { it.id }) { catalog ->
OpdsCatalogCard(
catalog = catalog,
onClick = { opdsViewModel.openCatalog(catalog) },
onEdit = if (catalog.isDefault) null else {
{
editingCatalog = catalog
showCatalogDialog = true
}
},
onDelete = if (catalog.isDefault) null else {
{ catalogToDelete = catalog }
})
}
}
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.fab_add_catalog)) },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = {
editingCatalog = null
showCatalogDialog = true
},
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
)
}
} else {
// Screen 2: Viewing a specific feed/catalog
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
var showSearch by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
LaunchedEffect(showSearch) {
if (showSearch) {
delay(100)
searchFocusRequester.requestFocus()
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(64.dp)
.padding(horizontal = 4.dp)
) {
IconButton(onClick = {
if (showSearch) {
showSearch = false
query = ""
} else {
opdsViewModel.navigateBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
if (showSearch) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) },
modifier = Modifier.weight(1f).padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
IconButton(onClick = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
}) {
Icon(Icons.Default.Search, "Search")
}
},
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Search
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSearch = {
if (query.isNotBlank()) {
opdsViewModel.search(query)
showSearch = false
query = ""
}
})
)
} else {
Text(
text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
if (uiState.searchUrlTemplate != null) {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, "Search")
}
}
}
}
if (uiState.isLoading) {
androidx.compose.material3.LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
)
}
}
}
if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.feed_empty))
}
} else {
val facets = uiState.currentFeed?.facets ?: emptyList()
if (facets.isNotEmpty()) {
val groups = facets.groupBy { it.group }
LazyRow(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
groups.forEach { (groupName, groupFacets) ->
item(key = groupName) {
var expanded by remember { mutableStateOf(false) }
val activeFacet = groupFacets.find { it.isActive }
?: groupFacets.firstOrNull()
Box {
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
null
)
})
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
groupFacets.forEach { facet ->
DropdownMenuItem(
text = { Text(facet.title) },
onClick = {
expanded = false
opdsViewModel.openFeedUrl(facet.url)
},
trailingIcon = if (facet.isActive) {
{ Icon(Icons.Default.Check, null) }
} else null)
}
}
}
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val entries = uiState.currentFeed?.entries ?: emptyList()
itemsIndexed(
entries,
key = { index, item -> "${item.id}_$index" }) { index, entry ->
if (index == entries.lastIndex) {
LaunchedEffect(index) { opdsViewModel.loadNextPage() }
}
if (entry.isNavigation) {
OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
} else {
OpdsBookCard(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
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) 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
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 = "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<RecentFileItem>,
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(stringResource(R.string.action_read))
}
} else if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
}
}
Spacer(modifier = Modifier.height(4.dp))
if (progress != null) {
androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
} else {
androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (entry.isStreamable) {
FilledTonalButton(
onClick = onStreamClick,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_stream))
}
}
Box {
FilledTonalButton(
onClick = {
if (uniqueAcquisitions.size == 1) {
onDownloadClick(uniqueAcquisitions.first())
} else if (uniqueAcquisitions.size > 1) {
showFormatMenu = true
}
},
enabled = uniqueAcquisitions.isNotEmpty(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
if (uniqueAcquisitions.isEmpty()) {
Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_unavailable))
} else {
Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.action_download))
}
}
}
DropdownMenu(
expanded = showFormatMenu,
onDismissRequest = { showFormatMenu = false }
) {
uniqueAcquisitions.forEach { acq ->
DropdownMenuItem(
text = { Text(acq.formatName) },
onClick = {
showFormatMenu = false
onDownloadClick(acq)
}
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: 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(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)
}
}
}
}
}
if (!entry.summary.isNullOrBlank()) {
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(entry.summary) {
val preProcessed = entry.summary
.replace("<br>", "\n")
.replace("</p>", "\n\n")
Jsoup.parse(preProcessed).text().trim()
}
Text(
text = cleanSummary,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 24.sp,
modifier = Modifier.padding(bottom = 48.dp)
)
} else {
Spacer(modifier = Modifier.height(48.dp))
}
}
}
}