From bf964b3ee8327dd3ea516a3020ca4583aef66c3f Mon Sep 17 00:00:00 2001 From: Atte149 Date: Sat, 27 Jun 2026 22:21:48 +0300 Subject: [PATCH] feat: simplify reader UI + remove unused code + speed up build Reader UI simplification: - loadHiddenTools: by default hide all tools except THEME, SLIDER, TOC, BOOKMARK, READING_MODE - User sees only: font/theme controls, navigation slider, chapters, bookmarks, reading mode Speed optimization: - Removed old unused Episteme screens: HomeScreen, LibraryScreen, MainScreen, SettingsScreen, ProScreen, AppNavigation - Removed old bookshelf UI: BookshelfLibraryScreen, BookshelfViewModel (replaced by new ui/ screens) - TensorFlow deps kept (needed by debug ML source set) - Material-icons-extended added for icon availability ':app:assembleOssDebug' passes, APK deployed. --- app/build.gradle.kts | 3 +- .../bookreader/reader/AppNavigation.kt | 492 ---- .../bookreader/reader/HomeScreen.kt | 2363 ----------------- .../bookreader/reader/LibraryScreen.kt | 2109 --------------- .../bookreader/reader/MainScreen.kt | 137 - .../bookreader/reader/ProScreen.kt | 747 ------ .../bookreader/reader/SettingsScreen.kt | 549 ---- .../reader/epubreader/EpubReaderScreen.kt | 12 + .../bookshelf/BookshelfLibraryScreen.kt | 222 -- .../bookshelf/BookshelfViewModel.kt | 176 -- 10 files changed, 13 insertions(+), 6797 deletions(-) delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt delete mode 100644 app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt delete mode 100644 app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8935236..244d1f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -312,10 +312,9 @@ implementation("androidx.compose.material:material-icons-extended:1.7.8") implementation("org.zwobble.mammoth:mammoth:1.4.2") implementation("com.materialkolor:material-kolor:5.0.0-alpha07") - debugImplementation("org.tensorflow:tensorflow-lite:2.17.0") debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0") - debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0") +debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0") debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0") implementation("androidx.core:core-splashscreen:1.2.0") diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt deleted file mode 100644 index 8429f96..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt +++ /dev/null @@ -1,492 +0,0 @@ -/* - * 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 - */ -package org.dueattendant149.bookreader - -import android.os.Build -import timber.log.Timber -import androidx.activity.compose.BackHandler -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.util.UnstableApi -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen -import org.dueattendant149.bookreader.epubreader.EpubReaderScreen -import org.dueattendant149.bookreader.feedback.FeedbackScreen -import org.dueattendant149.bookreader.feedback.SupportProjectScreen -import org.dueattendant149.bookreader.pdf.PdfViewerScreen -import org.dueattendant149.bookreader.shared.ReaderFeatureSurface -import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar -import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp -import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar -import kotlinx.coroutines.delay - -object AppDestinations { - const val MAIN_ROUTE = "main" - const val PDF_VIEWER_ROUTE = "pdf_viewer" - const val EPUB_READER_ROUTE = "epub_reader" - const val PRO_SCREEN_ROUTE = "pro_screen" - const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route" - const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route" - const val FONTS_SCREEN_ROUTE = "fonts_screen_route" - const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" - const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" - const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library" - const val SERVER_SETTINGS_ROUTE = "server_settings" -} - -fun shouldInterceptAppNavBack( - currentRoute: String?, - hasPreviousBackStackEntry: Boolean, - isCurrentEntryResumed: Boolean -): Boolean { - if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false - return currentRoute != null && - currentRoute != AppDestinations.MAIN_ROUTE && - currentRoute != AppDestinations.PDF_VIEWER_ROUTE && - currentRoute != AppDestinations.EPUB_READER_ROUTE -} - -private fun NavHostController.isReadyForBackStackChange(): Boolean { - return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED -} - -private suspend fun NavHostController.awaitReadyForBackStackChange() { - while (!isReadyForBackStackChange()) { - delay(32) - } -} - -private fun NavHostController.navigateSingleTopTo(route: String) { - if (!isReadyForBackStackChange()) { - Timber.d("Skipping navigation to $route because the current entry is not resumed yet.") - return - } - - try { - navigate(route) { - launchSingleTop = true - popUpTo(graph.startDestinationId) { - saveState = false - } - } - } catch (e: IllegalStateException) { - Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.") - } -} - -private fun NavHostController.navigateToMain() { - navigateSingleTopTo(AppDestinations.MAIN_ROUTE) -} - -private fun NavHostController.navigateIfReady(route: String) { - if (currentDestination?.route == route) return - navigateSingleTopTo(route) -} - -private fun NavHostController.popBackStackIfReady(): Boolean { - if (!isReadyForBackStackChange()) { - Timber.d("Skipping popBackStack because the current entry is not resumed yet.") - return false - } - - return try { - popBackStack() - } catch (e: IllegalStateException) { - Timber.w(e, "popBackStack ignored because the back stack is mid-transition.") - false - } -} - -private suspend fun NavHostController.syncRouteTo(route: String) { - awaitReadyForBackStackChange() - if (currentDestination?.route != route) { - if (route == AppDestinations.MAIN_ROUTE) { - navigateToMain() - } else { - navigateSingleTopTo(route) - } - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) -@Composable -fun AppNavigation( - navController: NavHostController, - windowSizeClass: WindowSizeClass, - viewModel: MainViewModel -) { - Timber.d("AppNavigation composable invoked.") - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = currentBackStackEntry?.destination?.route - val ttsController = viewModel.ttsController - val ttsState by ttsController.ttsState.collectAsStateWithLifecycle() - val isOnReaderRoute = currentRoute == AppDestinations.PDF_VIEWER_ROUTE || - currentRoute == AppDestinations.EPUB_READER_ROUTE - val showTtsMiniBar = shouldShowReaderTtsMiniBar( - ttsState = ttsState, - isOnReaderRoute = isOnReaderRoute - ) - val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp( - isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE - ).dp - val shouldInterceptBack = shouldInterceptAppNavBack( - currentRoute = currentRoute, - hasPreviousBackStackEntry = navController.previousBackStackEntry != null, - isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED - ) - - LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { - if (!uiState.isLoading) { - when (uiState.selectedFileType?.readerSurfaceOnAndroid()) { - ReaderFeatureSurface.PDF_VIEWER -> { - if (uiState.selectedPdfUri != null) { - if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { - navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) - } - } - } - ReaderFeatureSurface.EPUB_READER, - ReaderFeatureSurface.TEXT_READER -> { - if (uiState.selectedEpubBook != null) { - if (currentRoute != AppDestinations.EPUB_READER_ROUTE) { - navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE) - } - } - } - null -> { - if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) { - navController.syncRouteTo(AppDestinations.MAIN_ROUTE) - } - } - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - BackHandler(enabled = shouldInterceptBack) { - navController.popBackStackIfReady() - } - - NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { - composable(AppDestinations.MAIN_ROUTE) { - Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") - if (uiState.isTemporaryExternalOpen) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else { - MainScreen( - viewModel = viewModel, - windowSizeClass = windowSizeClass, - navController = navController - ) - } - } - - // PDF Viewer Screen Composable - composable(route = AppDestinations.PDF_VIEWER_ROUTE) { - Timber.d("Navigating to PDF Viewer Screen (${AppDestinations.PDF_VIEWER_ROUTE}).") - val pdfUri = uiState.selectedPdfUri - val initialPage = uiState.initialPageInBook - val initialBookmarksJson = uiState.initialBookmarksJson - - val bookId = - uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId - - if (pdfUri != null) { - Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage") - Box(modifier = Modifier.fillMaxSize()) { - PdfViewerScreen( - pdfUri = pdfUri, - initialPage = initialPage, - initialBookmarksJson = initialBookmarksJson, - isProUser = uiState.isProUser, - onNavigateBack = { - Timber.d("Back action triggered from PDF Viewer.") - viewModel.clearSelectedFile() - }, - onSavePosition = viewModel::savePdfReadingPosition, - onBookmarksChanged = { bookmarksJson -> - if (bookId != null) { - viewModel.saveBookmarks(bookId, bookmarksJson) - } else { - Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}") - } - }, - onNavigateToPro = { - navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) - }, - viewModel = viewModel - ) - - if (uiState.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - CustomTopBanner(bannerMessage = uiState.bannerMessage) - } - } else if (uiState.isLoading) { - Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.") - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else { - Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.") - } - } - - // EPUB Reader Screen Composable - composable(route = AppDestinations.EPUB_READER_ROUTE) { - Timber.d("Navigating to EPUB Reader Screen (${AppDestinations.EPUB_READER_ROUTE}).") - val epubBook = uiState.selectedEpubBook - val isLoading = uiState.isLoading - val errorMessage = uiState.errorMessage - val initialLocator = uiState.initialLocator - val initialCfi = uiState.initialCfi - val initialBookmarksJson = uiState.initialBookmarksJson - val renderMode = uiState.renderMode - - when { - epubBook != null -> { - Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator") - val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath - val epubUri = uiState.selectedEpubUri - uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId - val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() - - Box(modifier = Modifier.fillMaxSize()) { - EpubReaderScreen( - epubBook = epubBook, - renderMode = renderMode, - initialLocator = initialLocator, - initialCfi = initialCfi, - initialBookmarksJson = initialBookmarksJson, - isProUser = uiState.isProUser, - coverImagePath = coverPath, - onNavigateBack = { - Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.") - viewModel.clearSelectedFile() - }, - onSavePosition = { locator, cfiForWebView, progress -> - Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%") - epubUri?.let { uri -> - viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress) - } - }, - onBookmarksChanged = { bookmarksJson -> - val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId - if (bookId != null) { - viewModel.saveBookmarks(bookId, bookmarksJson) - } else { - Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}") - } - }, - onNavigateToPro = { - navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) - }, - onRenderModeChange = viewModel::setRenderMode, - customFonts = customFonts, - onImportFonts = viewModel::importFonts, - viewModel = viewModel - ) - - if (uiState.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - CustomTopBanner(bannerMessage = uiState.bannerMessage) - } - } - isLoading -> { - Timber.d("EPUB Reader: Showing loading indicator.") - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - errorMessage != null -> { - Timber.e("EPUB Reader: Showing error message - $errorMessage") - Column( - modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(R.string.error_message_format, errorMessage), color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { - viewModel.clearSelectedFile() - }) { - Text(stringResource(R.string.action_go_back)) - } - } - } - else -> { - Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.") - } - } - } - composable(route = AppDestinations.PRO_SCREEN_ROUTE) { - ProScreen( - viewModel = viewModel, - onNavigateBack = { navController.popBackStackIfReady() } - ) - } - - composable(route = AppDestinations.FEEDBACK_SCREEN_ROUTE) { - FeedbackScreen( - navController = navController - ) - } - - composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) { - SupportProjectScreen( - navController = navController - ) - } - - composable(route = AppDestinations.FONTS_SCREEN_ROUTE) { - FontsScreen( - viewModel = viewModel, - onBackClick = { navController.popBackStackIfReady() } - ) - } - - composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) { - AiSettingsScreen( - onBackClick = { navController.popBackStackIfReady() } - ) - } - - composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) { - SettingsScreen( - viewModel = viewModel, - navController = navController, - onBackClick = { navController.popBackStackIfReady() } - ) - } - - composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) { - val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel() - LaunchedEffect(bookshelfViewModel) { - bookshelfViewModel.downloadedFile.collect { uri -> - Timber.d("Downloaded book $uri, opening in reader") - viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false) - } - } - BookshelfLibraryScreen( - viewModel = bookshelfViewModel, - onItemClick = { item -> - Timber.d("Bookshelf item selected: ${item.id}") - bookshelfViewModel.downloadBook(item) - }, - onOpenSettings = { - navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE) - } - ) - } - - composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) { - val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel() - org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen( - viewModel = settingsViewModel, - onBackClick = { navController.popBackStackIfReady() } - ) - } - } - - AnimatedVisibility( - visible = showTtsMiniBar, - enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), - exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = miniBarBottomPadding) - ) { - ReaderTtsMiniBar( - ttsController = ttsController, - ttsState = ttsState, - onOpenReader = { - ttsState.bookId?.let { bookId -> - viewModel.openTtsNotificationTarget( - bookId = bookId, - sourceCfi = ttsState.sourceCfi, - startOffset = ttsState.startOffsetInSource.takeIf { it >= 0 }, - chapterIndex = ttsState.chapterIndex, - pageIndex = ttsState.pageIndex - ) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt deleted file mode 100644 index 8dec1c2..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/HomeScreen.kt +++ /dev/null @@ -1,2363 +0,0 @@ -/* - * 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 - */ -// HomeScreen -@file:Suppress("DEPRECATION") - -package org.dueattendant149.bookreader - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FolderSpecial -import androidx.compose.material.icons.filled.FormatListNumbered -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.VerifiedUser -import androidx.compose.material.icons.outlined.AccountCircle -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.rememberDrawerState -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.os.LocaleListCompat -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.util.UnstableApi -import androidx.navigation.NavHostController -import coil.compose.AsyncImage -import coil.request.ImageRequest -import org.dueattendant149.bookreader.data.RecentFileItem -import kotlinx.coroutines.launch -import timber.log.Timber -import java.text.SimpleDateFormat -import java.util.Locale - -internal fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - -@UnstableApi -@androidx.annotation.OptIn(UnstableApi::class) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen( - viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController -) { - val compStart = remember { System.currentTimeMillis() } - LaunchedEffect(Unit) { - ReaderPerfLog.d("HomeScreen initial composition ${System.currentTimeMillis() - compStart}ms") - } - val context = LocalContext.current - val customTabUriHandler = remember { CustomTabUriHandler(context) } - var showCloseAllTabsDialog by remember { mutableStateOf(false) } - var showAppThemePanel by remember { mutableStateOf(false) } - - CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val recentFilesForHome = uiState.recentFiles - val openTabs = uiState.openTabs - val selectedContextItems = uiState.contextualActionItems - val isContextualModeActive = selectedContextItems.isNotEmpty() - val isHomeEmpty = recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty()) - val isLibraryEmpty = uiState.rawLibraryFiles.isEmpty() - val scope = rememberCoroutineScope() - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val snackbarHostState = remember { SnackbarHostState() } - val deviceLimitState = uiState.deviceLimitState - - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var showClearCloudDataDialog by remember { mutableStateOf(false) } - var showClearAllDataDialog by remember { mutableStateOf(false) } - var showUpgradeDialog by remember { mutableStateOf(false) } - var showSignOutConfirmDialog by remember { mutableStateOf(false) } - var showAboutDialog by remember { mutableStateOf(false) } - var showInfoDialog by remember { mutableStateOf(false) } - var itemForInfoDialog by remember { mutableStateOf(null) } - var pendingSaveOriginalItem by remember { mutableStateOf(null) } - var showBehaviorDialog by remember { mutableStateOf(false) } - var showStrictFilterDialog by remember { mutableStateOf(false) } - var showClearBookCacheDialog by remember { mutableStateOf(false) } - var showClearReflowCacheDialog by remember { mutableStateOf(false) } - var showLanguageDialog by remember { mutableStateOf(false) } - - val feedbackResult = - navController.currentBackStackEntry?.savedStateHandle?.getLiveData("banner_message") - ?.observeAsState() - - LaunchedEffect(feedbackResult) { - feedbackResult?.value?.let { message -> - viewModel.showBanner(message) - navController.currentBackStackEntry?.savedStateHandle?.remove("banner_message") - } - } - - val drivePermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - viewModel.onDrivePermissionResult(result.data) - } else { - Timber.w("Google Sign In for Drive failed with result code: ${result.resultCode}") - viewModel.onDrivePermissionFlowCancelled() - } - } - - val saveOriginalLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - val item = pendingSaveOriginalItem - pendingSaveOriginalItem = null - if (uri != null && item?.uriString != null) { - viewModel.saveOriginalFile(item.uriString.toUri(), uri) - } - } - - fun saveOriginalItem(item: RecentFileItem) { - if (!item.canExportOriginalFile()) return - pendingSaveOriginalItem = item - saveOriginalLauncher.launch(item.suggestedOriginalFileName()) - } - - fun shareOriginalItem(item: RecentFileItem) { - val uriString = item.uriString ?: return - if (!item.canExportOriginalFile()) return - scope.launch { - viewModel.shareOriginalFile( - activityContext = context, - sourceUri = uriString.toUri(), - fileType = item.type, - filename = item.suggestedOriginalFileName() - ) - } - } - - LaunchedEffect(uiState.isRequestingDrivePermission) { - if (uiState.isRequestingDrivePermission) { - val intent = viewModel.getDriveSignInIntent(context) - drivePermissionLauncher.launch(intent) - } - } - - LaunchedEffect(uiState.errorMessage) { - uiState.errorMessage?.let { message -> - snackbarHostState.showSnackbar(message) - viewModel.errorMessageShown() - } - } - - BackHandler(enabled = isContextualModeActive) { - Timber.d("System back pressed during contextual mode.") - viewModel.clearContextualAction() - } - - val pickFileLauncher = rememberFilePickerLauncher { uris -> - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - viewModel.onFilesSelected(uris) - } - - val fallbackFilePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris -> - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - viewModel.onFilesSelected(uris) - } - - val onSelectFileClick = { - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*") - try { - pickFileLauncher.launch(mimeTypes) - } catch (_: android.content.ActivityNotFoundException) { - Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.") - try { - fallbackFilePickerLauncher.launch("*/*") - } catch (_: android.content.ActivityNotFoundException) { - viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true) - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - ModalNavigationDrawer( - drawerState = drawerState, drawerContent = { - val context = LocalContext.current - AppDrawerContent( - uiState = uiState, - onSignInClick = { - scope.launch { - context.findActivity()?.let { activity -> - viewModel.signIn(activity) - } - drawerState.close() - } - }, - onSignOutClick = { - showSignOutConfirmDialog = true - }, - onSyncToggle = viewModel::setSyncEnabled, - onUpgradeClick = { - scope.launch { - drawerState.close() - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) - } - }, - onSyncUpsellClick = { - scope.launch { - showUpgradeDialog = true - } - }, - onFontsClick = { - scope.launch { - drawerState.close() - navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE) - } - }, - onAiSettingsClick = { - scope.launch { - drawerState.close() - navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) - } - }, - onSettingsClick = { - scope.launch { - drawerState.close() - navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) - } - }, - navController = navController, - onFolderSyncToggle = viewModel::setFolderSyncEnabled - ) - }) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - if (!isContextualModeActive) { - DefaultTopAppBar( - uiState = uiState, - onRenderModeChange = viewModel::setRenderMode, - onClearCache = { showClearBookCacheDialog = true }, - onClearCloudData = { showClearAllDataDialog = true }, - onAboutClick = { showAboutDialog = true }, - onDrawerClick = { - scope.launch { - drawerState.open() - } - }, - onShowDeviceManagement = viewModel::showDeviceManagementForDebug, - onFolderSyncToggle = viewModel::setFolderSyncEnabled, - onClearReflowCache = { showClearReflowCacheDialog = true }, - onRecentFilesLimitChange = viewModel::setRecentFilesLimit, - onTabsToggle = viewModel::setTabsEnabled, - onExternalFileBehaviorClick = { showBehaviorDialog = true }, - onStrictFilterToggleClick = { - if (uiState.useStrictFileFilter) { - viewModel.setStrictFileFilter(false) - } else { - showStrictFilterDialog = true - } - }, - onUsePdfFileNameAsDisplayNameToggle = { - viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName) - }, - onAppThemeClick = { showAppThemePanel = true }, - onSettingsClick = { - navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) - }, - onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, - onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, - onLanguageClick = { showLanguageDialog = true }, - onExportLogsClick = { viewModel.exportLogsToFile(context) }, - onToggleHideReaderAi = { - saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context)) - }, - onScreenCaptureProtectionChange = { enabled -> - viewModel.setScreenCaptureProtectionEnabled(enabled) - val messageRes = if (enabled) { - R.string.banner_screen_capture_protection_on - } else { - R.string.banner_screen_capture_protection_off - } - viewModel.showBanner(context.getString(messageRes)) - } - ) - } else { - ContextualTopAppBar( - selectedItemCount = selectedContextItems.size, - onNavIconClick = { viewModel.clearContextualAction() }, - onTagClick = { - viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet()) - }, - onInfoClick = { - if (selectedContextItems.size == 1) { - itemForInfoDialog = selectedContextItems.first() - showInfoDialog = true - } - }, - onSaveClick = selectedContextItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { saveOriginalItem(item) } }, - onShareClick = selectedContextItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { shareOriginalItem(item) } }, - onPinClick = { viewModel.togglePinForContextualItems(isHome = true) }, - onDeleteClick = { showDeleteConfirmDialog = true }, - onSelectAllClick = { viewModel.selectAllRecentFiles() }) - } - }) { paddingValues -> - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - if (isHomeEmpty) { - if (isLibraryEmpty) { - EmptyState( - title = stringResource(R.string.your_library_empty), - message = stringResource(R.string.your_library_empty_desc), - onSelectFileClick = onSelectFileClick, - modifier = Modifier.weight(1f), - secondaryButtonText = stringResource(R.string.setup_folder_sync), - onSecondaryClick = { viewModel.navigateToFolderSync() } - ) - } else { - EmptyState( - title = stringResource(R.string.no_recent_files), - message = stringResource(R.string.no_recent_files_desc), - onSelectFileClick = onSelectFileClick, - modifier = Modifier.weight(1f) - ) - } - } else { - RecentFilesContent( - recentFiles = recentFilesForHome, - openTabs = openTabs, - isTabsEnabled = uiState.isTabsEnabled, - selectedContextItems = selectedContextItems, - pinnedHomeBookIds = uiState.pinnedHomeBookIds, - onItemClick = { item -> viewModel.onRecentFileClicked(item) }, - onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) }, - onTabCloseClick = { bookId -> viewModel.closeTab(bookId) }, - onCloseAllTabsClick = { showCloseAllTabsDialog = true }, - onSelectFileClick = onSelectFileClick, - onNavigateToFolderSync = { viewModel.navigateToFolderSync() }, - windowSizeClass = windowSizeClass, - downloadingBookIds = uiState.downloadingBookIds, - onRefresh = { viewModel.refreshLibrary() }, - isRefreshing = uiState.isRefreshing, - isSyncEnabled = uiState.isSyncEnabled, - hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled }, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName - ) - } - } - - if (uiState.isLoading) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } - } - - // Dialogs - if (showDeleteConfirmDialog) { - DeleteConfirmationDialog(count = selectedContextItems.size, onConfirm = { - viewModel.hideItemsFromRecentsView() - showDeleteConfirmDialog = false - }, onDismiss = { showDeleteConfirmDialog = false }) - } - - if (showCloseAllTabsDialog) { - CloseAllTabsDialog( - onConfirm = { - viewModel.closeAllTabs() - showCloseAllTabsDialog = false - }, - onDismiss = { showCloseAllTabsDialog = false } - ) - } - - if (showClearCloudDataDialog) { - ClearCloudDataConfirmationDialog(onConfirm = { - viewModel.deleteAllUserData() - showClearCloudDataDialog = false - }, onDismiss = { showClearCloudDataDialog = false }) - } - - if (showUpgradeDialog) { - UpgradeDialog(onDismiss = { showUpgradeDialog = false }, onConfirm = { - showUpgradeDialog = false - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) - }) - } - - if (showClearBookCacheDialog) { - DangerousFolderActionDialog( - title = stringResource(R.string.dialog_clear_book_cache), - message = stringResource(R.string.dialog_clear_book_cache_desc), - onConfirm = { - viewModel.clearBookCache() - showClearBookCacheDialog = false - }, - onDismiss = { showClearBookCacheDialog = false } - ) - } - - HydratedFileInfoDialog( - item = itemForInfoDialog, - isVisible = showInfoDialog, - uiState = uiState, - viewModel = viewModel, - onDismiss = { - showInfoDialog = false - itemForInfoDialog = null - }, - onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } - ) - - if (showClearReflowCacheDialog) { - DangerousFolderActionDialog( - title = stringResource(R.string.dialog_clear_reflow_cache), - message = stringResource(R.string.dialog_clear_reflow_cache_desc), - onConfirm = { - viewModel.clearReflowCache() - showClearReflowCacheDialog = false - }, - onDismiss = { showClearReflowCacheDialog = false } - ) - } - if (uiState.showExternalFileSavePromptFor != null) { - ExternalFileSaveDialog( - onConfirm = { keep, dontAskAgain -> - viewModel.handleExternalFilePrompt(uiState.showExternalFileSavePromptFor!!, keep, dontAskAgain) - } - ) - } - if (showBehaviorDialog) { - ExternalFileBehaviorDialog( - currentBehavior = uiState.externalFileBehavior, - onDismiss = { showBehaviorDialog = false }, - onSelect = { viewModel.setExternalFileBehavior(it) } - ) - } - - if (showStrictFilterDialog) { - StrictFilterConfirmationDialog( - onConfirm = { - viewModel.setStrictFileFilter(true) - showStrictFilterDialog = false - }, - onDismiss = { showStrictFilterDialog = false } - ) - } - - if (showLanguageDialog) { - LanguageSelectionDialog(onDismiss = { showLanguageDialog = false }) - } - - if (showAppThemePanel) { - AppThemeBottomSheet( - uiState = uiState, - onThemeModeChanged = viewModel::setAppThemeMode, - onContrastOptionChanged = viewModel::setAppContrastOption, - onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight, - onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark, - onSeedColorChanged = viewModel::setAppSeedColor, - onCustomThemeAdded = viewModel::addCustomAppTheme, - onCustomThemeDeleted = viewModel::deleteCustomAppTheme, - onDismiss = { showAppThemePanel = false } - ) - } - } - } - if (showAboutDialog) { - AboutDialog(onDismiss = { showAboutDialog = false }) - } - if (showClearAllDataDialog) { - ClearAllDataConfirmationDialog(onConfirm = { - viewModel.deleteAllCloudAndLocalData() - showClearAllDataDialog = false - }, onDismiss = { showClearAllDataDialog = false }) - } - if (showSignOutConfirmDialog) { - SignOutConfirmationDialog(onConfirm = { - viewModel.signOut() - showSignOutConfirmDialog = false - }, onDismiss = { - showSignOutConfirmDialog = false - }) - } - if (deviceLimitState.isLimitReached) { - DeviceManagementScreen( - devices = deviceLimitState.registeredDevices, - onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) }, - isReplacing = uiState.isReplacingDevice - ) - } - CustomTopBanner(bannerMessage = uiState.bannerMessage) - - if (BuildConfig.DEBUG) { - FpsMonitor( - modifier = Modifier - .align(Alignment.TopStart) - .padding(top = 48.dp, start = 8.dp) - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun RecentFilesContent( - recentFiles: List, - openTabs: List, - isTabsEnabled: Boolean, - selectedContextItems: Collection, - pinnedHomeBookIds: Set, - onItemClick: (RecentFileItem) -> Unit, - onItemLongClick: (RecentFileItem) -> Unit, - onTabCloseClick: (String) -> Unit, - onCloseAllTabsClick: () -> Unit, - onSelectFileClick: () -> Unit, - onNavigateToFolderSync: () -> Unit, - windowSizeClass: WindowSizeClass, - downloadingBookIds: Set, - onRefresh: () -> Unit, - isRefreshing: Boolean, - isSyncEnabled: Boolean, - hasSyncedFolder: Boolean, - usePdfFileNameAsDisplayName: Boolean -) { - val canRefresh = isSyncEnabled || hasSyncedFolder - val selectedItemUris = remember(selectedContextItems) { - selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString } - } - - val content = @Composable { - Box(modifier = Modifier.fillMaxSize()) { - RecentFilesGrid( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - recentFiles = recentFiles, - openTabs = openTabs, - isTabsEnabled = isTabsEnabled, - onTabCloseClick = onTabCloseClick, - onCloseAllTabsClick = onCloseAllTabsClick, - selectedItemUris = selectedItemUris, - pinnedHomeBookIds = pinnedHomeBookIds, - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, - windowSizeClass = windowSizeClass, - contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp), - downloadingBookIds = downloadingBookIds, - usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - androidx.compose.material3.Button(onClick = onSelectFileClick) { - Text(stringResource(R.string.empty_select_file)) - } - androidx.compose.material3.Button(onClick = onNavigateToFolderSync) { - Text(stringResource(R.string.sync_folder)) - } - } - } - } - - if (canRefresh) { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.fillMaxSize() - ) { - content() - } - } else { - content() - } -} - -@Composable -private fun RecentFilesGrid( - modifier: Modifier = Modifier, - recentFiles: List, - openTabs: List, - isTabsEnabled: Boolean, - onTabCloseClick: (String) -> Unit, - onCloseAllTabsClick: () -> Unit, - pinnedHomeBookIds: Set, - selectedItemUris: Set, - onItemClick: (RecentFileItem) -> Unit, - onItemLongClick: (RecentFileItem) -> Unit, - windowSizeClass: WindowSizeClass, - contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), - downloadingBookIds: Set, - usePdfFileNameAsDisplayName: Boolean, -) { - val gridCells = when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> GridCells.Fixed(3) - WindowWidthSizeClass.Medium -> GridCells.Adaptive(minSize = 140.dp) - else -> GridCells.Adaptive(minSize = 160.dp) - } - - Column(modifier = modifier) { - if (isTabsEnabled && openTabs.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, top = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.active_tabs), - style = MaterialTheme.typography.titleLarge - ) - IconButton(onClick = onCloseAllTabsClick) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close_all_tabs), - tint = MaterialTheme.colorScheme.error - ) - } - } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 8.dp) - ) { - items(openTabs, key = { "tab_${it.bookId}" }) { tab -> - InputChip( - selected = false, - onClick = { onItemClick(tab) }, - label = { - Text( - text = tab.cardTitle(usePdfFileNameAsDisplayName), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 150.dp) - ) - }, - trailingIcon = { - IconButton( - onClick = { onTabCloseClick(tab.bookId) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close_tab), - modifier = Modifier.size(16.dp) - ) - } - }, - modifier = Modifier.testTag("HomeTab_${tab.bookId}") - ) - } - } - } - - Text( - text = stringResource(R.string.recent_files), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp, top = if (isTabsEnabled && openTabs.isNotEmpty()) 8.dp else 24.dp) - ) - - LazyVerticalGrid( - columns = gridCells, - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - items(recentFiles, key = { it.bookId }) { item -> - RecentFileCard( - item = item, - isSelected = item.uriString in selectedItemUris, - isPinned = item.bookId in pinnedHomeBookIds, - onClick = { onItemClick(item) }, - onLongClick = { onItemLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds, - usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName - ) - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RecentFileCard( - item: RecentFileItem, - isSelected: Boolean, - modifier: Modifier = Modifier, - isPinned: Boolean = false, - onClick: () -> Unit, - onLongClick: () -> Unit, - isDownloading: Boolean, - usePdfFileNameAsDisplayName: Boolean = false, -) { - val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() - val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " - - androidx.compose.material3.ElevatedCard( - modifier = modifier - .testTag("HomeRecentFileCard_${item.bookId}") - .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } - .then( - if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) - else Modifier - ) - .clip(MaterialTheme.shapes.large) - .combinedClickable(onClick = onClick, onLongClick = onLongClick), - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( - defaultElevation = if (isSelected) 4.dp else 1.dp - ) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(0.74f) - ) { - val useCompactCoverBadges = maxWidth < 128.dp - val coverBadgePadding = if (useCompactCoverBadges) 5.dp else 8.dp - - ThemedBookCover( - item = item, - contentDescription = item.displayName, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - - if (!item.coverImagePath.isNullOrBlank()) { - Box( - modifier = Modifier.fillMaxSize().background( - androidx.compose.ui.graphics.Brush.verticalGradient( - 0f to Color.Black.copy(alpha = 0.15f), - 0.3f to Color.Transparent, - 0.6f to Color.Transparent, - 1f to Color.Black.copy(alpha = 0.5f) - ) - ) - ) - } - - if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) { - FileStatusBadges( - item = item, - isPinned = isPinned, - overlay = true, - modifier = Modifier - .align(Alignment.TopStart) - .padding(10.dp) - ) - } - - if (!item.isAvailable) { - Box( - modifier = Modifier.matchParentSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - if (isDownloading) { - CircularProgressIndicator(color = Color.White) - } else { - Icon( - Icons.Filled.Info, - contentDescription = stringResource(R.string.not_available_locally), - modifier = Modifier.size(48.dp), - tint = Color.White - ) - } - } - } - - if (isSelected) { - Box( - modifier = Modifier.matchParentSize() - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected), - modifier = Modifier.size(48.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape) - .padding(8.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(coverBadgePadding), - verticalAlignment = Alignment.CenterVertically - ) { - progressPercent?.let { percent -> - CoverProgressBadge( - percent = percent, - compact = useCompactCoverBadges - ) - } - Spacer(modifier = Modifier.weight(1f)) - FileTypeBadge( - type = item.type, - overlay = true, - compact = useCompactCoverBadges - ) - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalAlignment = Alignment.Start - ) { - Text( - text = item.cardTitle(usePdfFileNameAsDisplayName), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - minLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - lineHeight = 20.sp - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = authorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - minLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() - ) - - if (!item.isAvailable) { - Spacer(modifier = Modifier.height(12.dp)) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(28.dp), - contentAlignment = Alignment.CenterStart - ) { - Surface( - shape = RoundedCornerShape(50), - color = if (isDownloading) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.errorContainer - }, - contentColor = if (isDownloading) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onErrorContainer - } - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (isDownloading) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.Filled.Info, - contentDescription = stringResource(R.string.not_available_locally), - modifier = Modifier.size(14.dp) - ) - } - Text( - text = if (isDownloading) { - stringResource(R.string.status_downloading) - } else { - stringResource(R.string.not_available_locally) - }, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) - } - } - } - } - } - } - } -} - -@Composable -private fun CoverProgressBadge( - percent: Int, - compact: Boolean, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - border = androidx.compose.foundation.BorderStroke( - 1.dp, - Color.White.copy(alpha = 0.14f) - ) - ) { - Text( - text = "$percent%", - style = if (compact) { - MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp) - } else { - MaterialTheme.typography.labelSmall - }, - fontWeight = FontWeight.Bold, - maxLines = 1, - modifier = Modifier.padding( - horizontal = if (compact) 6.dp else 10.dp, - vertical = if (compact) 3.dp else 4.dp - ) - ) - } -} - -@Suppress("unused", "KotlinConstantConditions") -@Composable -fun DefaultTopAppBar( - uiState: ReaderScreenState, - onRenderModeChange: (RenderMode) -> Unit, - onClearCache: () -> Unit, - onClearCloudData: () -> Unit, - onClearReflowCache: () -> Unit, - onDrawerClick: () -> Unit, - onAboutClick: () -> Unit, - onShowDeviceManagement: () -> Unit, - onFolderSyncToggle: (Boolean) -> Unit, - onRecentFilesLimitChange: (Int) -> Unit, - onTabsToggle: (Boolean) -> Unit, - onExternalFileBehaviorClick: () -> Unit, - onStrictFilterToggleClick: () -> Unit, - onUsePdfFileNameAsDisplayNameToggle: () -> Unit, - onAppThemeClick: () -> Unit, - onSettingsClick: () -> Unit, - onTestPanelDetectionClick: () -> Unit, - onTestSpeechBubbleDetectionClick: () -> Unit, - onLanguageClick: () -> Unit, - onExportLogsClick: () -> Unit, - onToggleHideReaderAi: () -> Unit, - onScreenCaptureProtectionChange: (Boolean) -> Unit -) { - var showOptionsMenu by remember { mutableStateOf(false) } - var showLimitMenu by remember { mutableStateOf(false) } - val context = LocalContext.current - var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) } - CustomTopAppBar(title = { }, navigationIcon = { - IconButton(onClick = onDrawerClick) { - BadgedBox( - badge = { - if (uiState.hasUnreadFeedback) { - Badge() - } - }) { - Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_open_drawer)) - } - } - }, actions = { - IconButton(onClick = onAppThemeClick) { - Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.content_desc_app_theme)) - } - // Options Menu (MoreVert) - Box { - IconButton(onClick = { showOptionsMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options)) - } - DropdownMenu( - expanded = showOptionsMenu, onDismissRequest = { showOptionsMenu = false }) { - DropdownMenuItem(text = { Text(stringResource(R.string.settings)) }, onClick = { - onSettingsClick() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(if (uiState.recentFilesLimit == 0) R.string.options_no_limit else R.string.options_files_limit, uiState.recentFilesLimit)) }, onClick = { - showLimitMenu = true - showOptionsMenu = false - }) - - HorizontalDivider() - - DropdownMenuItem(text = { Text(stringResource(R.string.about_title)) }, onClick = { - onAboutClick() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_enable_multi_tab_reading)) }, onClick = { - onTabsToggle(!uiState.isTabsEnabled) - showOptionsMenu = false - }, trailingIcon = { - if (uiState.isTabsEnabled) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) - } - }) - - DropdownMenuItem( - text = { Text(stringResource(R.string.options_screen_capture_protection)) }, - onClick = { - onScreenCaptureProtectionChange(!uiState.isScreenCaptureProtectionEnabled) - showOptionsMenu = false - }, - trailingIcon = { - if (uiState.isScreenCaptureProtectionEnabled) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) - } - } - ) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_external_file_behavior)) }, onClick = { - onExternalFileBehaviorClick() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_use_strict_file_filter)) }, onClick = { - onStrictFilterToggleClick() - showOptionsMenu = false - }, trailingIcon = { - if (uiState.useStrictFileFilter) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) - } - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_use_pdf_filename_display_name)) }, onClick = { - onUsePdfFileNameAsDisplayNameToggle() - showOptionsMenu = false - }, trailingIcon = { - if (uiState.usePdfFileNameAsDisplayName) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) - } - }) - - HorizontalDivider() - - DropdownMenuItem(text = { Text(stringResource(R.string.options_language)) }, onClick = { - onLanguageClick() - showOptionsMenu = false - }) - - if (!BuildConfig.IS_OFFLINE) { - DropdownMenuItem( - text = { Text(stringResource(if (hideReaderAiFeatures) R.string.options_show_ai_in_reader else R.string.options_hide_ai_in_reader)) }, - onClick = { - onToggleHideReaderAi() - hideReaderAiFeatures = !hideReaderAiFeatures - showOptionsMenu = false - }, - trailingIcon = { - if (hideReaderAiFeatures) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) - } - } - ) - } - - HorizontalDivider() - DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { - onClearCache() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_reflow_cache)) }, onClick = { - onClearReflowCache() - showOptionsMenu = false - }) - - if (BuildConfig.DEBUG) { - HorizontalDivider() - DropdownMenuItem(text = { Text(stringResource(R.string.options_test_panel_ml_detection)) }, onClick = { - onTestPanelDetectionClick() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_test_speech_bubble_ml_detection)) }, onClick = { - onTestSpeechBubbleDetectionClick() - showOptionsMenu = false - }) - - DropdownMenuItem(text = { Text(stringResource(R.string.options_export_logs_last_lines, 5000)) }, onClick = { - onExportLogsClick() - showOptionsMenu = false - }) - } - - if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") { - HorizontalDivider() - DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = { - onShowDeviceManagement() - showOptionsMenu = false - }) - DropdownMenuItem(text = { Text(stringResource(R.string.debug_clear_cloud_local_data)) }, - onClick = { - onClearCloudData() - showOptionsMenu = false - }) - } - } - } - - // Recent Files Limit Submenu - if (showLimitMenu) { - DropdownMenu( - expanded = showLimitMenu, onDismissRequest = { showLimitMenu = false } - ) { - val limitOptions = listOf(0, 10, 20, 50, 100) - limitOptions.forEach { limit -> - DropdownMenuItem( - text = { Text(if (limit == 0) stringResource(R.string.options_no_limit) else stringResource(R.string.options_files_limit, limit)) }, - onClick = { - onRecentFilesLimitChange(limit) - showLimitMenu = false - }, - trailingIcon = if (uiState.recentFilesLimit == limit) { - { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } - } else null - ) - } - } - } - }) -} - -@Suppress("KotlinConstantConditions") -@Composable -private fun AppDrawerContent( - uiState: ReaderScreenState, - onSignInClick: () -> Unit, - onSignOutClick: () -> Unit, - onSyncToggle: (Boolean) -> Unit, - onUpgradeClick: () -> Unit, - onSyncUpsellClick: () -> Unit, - onFontsClick: () -> Unit, - onAiSettingsClick: () -> Unit, - onSettingsClick: () -> Unit, - navController: NavHostController, - onFolderSyncToggle: (Boolean) -> Unit -) { - val isOss = BuildConfig.FLAVOR == "oss" - - ModalDrawerSheet { - Column(modifier = Modifier.fillMaxHeight()) { - if (!isOss) { - if (uiState.currentUser != null) { - // Signed-in: Show user info at the top - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val photoUrl = uiState.currentUser.photoUrl - if (photoUrl != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(photoUrl) - .crossfade(true).build(), - contentDescription = stringResource(R.string.content_desc_profile_picture), - modifier = Modifier - .size(80.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - } else { - Icon( - imageVector = Icons.Outlined.AccountCircle, - contentDescription = stringResource(R.string.content_desc_profile), - modifier = Modifier.size(80.dp) - ) - } - uiState.currentUser.displayName?.let { name -> - Text(text = name, style = MaterialTheme.typography.titleMedium) - } - uiState.currentUser.email?.let { email -> - Text(text = email, style = MaterialTheme.typography.bodyMedium) - } - if (BuildConfig.FLAVOR == "pro") { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = CircleShape, - modifier = Modifier.padding(top = 8.dp) - ) { - Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.credits_tab), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer) - Spacer(modifier = Modifier.width(4.dp)) - Text(safeStringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) - } - } - } - } - } else { - // Signed-out: Show Sign In button at the top - Spacer(modifier = Modifier.height(8.dp)) - NavigationDrawerItem( - icon = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_sign_in)) }, - selected = false, - onClick = onSignInClick, - modifier = Modifier.padding(horizontal = 12.dp) - ) - - // LegalText - LegalText( - prefixText = stringResource(R.string.drawer_by_signing_in), - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - textAlign = TextAlign.Start - ) - } - - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) - Spacer(modifier = Modifier.height(16.dp)) - - NavigationDrawerItem( - icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) }, - label = { - val text = if (uiState.isProUser) stringResource(R.string.drawer_pro_unlocked) else stringResource(R.string.drawer_upgrade_pro) - Text(text) - }, - selected = false, - onClick = onUpgradeClick, - modifier = Modifier.padding(horizontal = 12.dp) - ) - - // Sync Toggle Item - if (uiState.currentUser != null) { - NavigationDrawerItem( - icon = { Icon(painterResource(id = R.drawable.sync), contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_sync_library)) }, - badge = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (!uiState.isProUser) { - Icon( - imageVector = Icons.Default.VerifiedUser, - contentDescription = stringResource(R.string.content_desc_pro_feature), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Switch( - checked = uiState.isSyncEnabled, onCheckedChange = { - if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick() - }, enabled = uiState.isProUser - ) - } - }, selected = false, onClick = { - if (uiState.isProUser) { - onSyncToggle(!uiState.isSyncEnabled) - } else { - onSyncUpsellClick() - } - }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - if (uiState.currentUser != null && uiState.isSyncEnabled) { - NavigationDrawerItem( - icon = { Icon(imageVector = Icons.Default.FolderSpecial, contentDescription = null) }, - label = { - Column { - Text(stringResource(R.string.drawer_backup_local_folders)) - Text( - stringResource(R.string.drawer_backup_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - badge = { - Switch( - checked = uiState.isFolderSyncEnabled, - onCheckedChange = { onFolderSyncToggle(it) } - ) - }, - selected = false, - onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - } else { - // OSS Header - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AsyncImage( - model = R.mipmap.ic_launcher, - contentDescription = stringResource(R.string.content_desc_app_icon), - modifier = Modifier.size(64.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = stringResource(R.string.app_name_oss), style = MaterialTheme.typography.titleMedium) - } - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) - } - - NavigationDrawerItem( - icon = { Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text(stringResource(R.string.settings)) }, - selected = false, - onClick = onSettingsClick, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - - NavigationDrawerItem( - icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_custom_fonts)) }, - selected = false, - onClick = onFontsClick, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - - if (isOss && !BuildConfig.IS_OFFLINE) { - NavigationDrawerItem( - icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) }, - label = { Text(stringResource(R.string.ai_settings_title)) }, - selected = false, - onClick = onAiSettingsClick, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - - if (isOss) { - NavigationDrawerItem( - icon = { Icon(Icons.Outlined.FavoriteBorder, contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_support_project)) }, - selected = false, - onClick = { navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - - NavigationDrawerItem( - icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_help_feedback)) }, - selected = false, - onClick = { navController.navigate("feedback_screen_route") }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - - if (!isOss) { - if (uiState.currentUser != null) { - NavigationDrawerItem( - icon = { Icon(painterResource(id = R.drawable.logout), contentDescription = null) }, - label = { Text(stringResource(R.string.drawer_sign_out)) }, - selected = false, - onClick = onSignOutClick, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // legal links - if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) { - val uriHandler = LocalUriHandler.current - val baseStyle = MaterialTheme.typography.labelMedium - var scaledTextStyle by remember { mutableStateOf(baseStyle) } - - Text( - text = stringResource(R.string.legal_footer_combined), - style = scaledTextStyle, - maxLines = 1, - softWrap = false, - onTextLayout = { - if (it.didOverflowWidth) { - scaledTextStyle = - scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.95) - } - }, - modifier = Modifier.height(0.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, start = 8.dp, end = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.legal_privacy_policy), - style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.clickable { uriHandler.openUri(PRIVACY_POLICY_URL) }, - softWrap = false - ) - Text(" โ€ข ", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)) - Text( - text = stringResource(R.string.legal_terms_of_service), - style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.clickable { uriHandler.openUri(TERMS_URL) }, - softWrap = false - ) - Text(" โ€ข ", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)) - Text( - text = stringResource(R.string.legal_licenses), - style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.clickable { uriHandler.openUri(LICENSES_URL) }, - softWrap = false - ) - } - } - } - } -} - -@Composable -fun UpgradeDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) }, - title = { Text(stringResource(R.string.dialog_unlock_pro)) }, - text = { Text(stringResource(R.string.dialog_unlock_pro_desc)) }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_upgrade)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - }) -} - -@Composable -fun SignOutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_confirm_sign_out)) }, - text = { Text(stringResource(R.string.dialog_confirm_sign_out_desc)) }, - confirmButton = { - TextButton( - onClick = onConfirm, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.drawer_sign_out)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - }) -} - -@Composable -fun DeviceManagementScreen( - devices: List, onRemoveDevice: (String) -> Unit, isReplacing: Boolean -) { - val dateFormatter = remember { - SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()) - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background.copy(alpha = 0.98f) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.device_limit_reached), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.device_limit_reached_desc), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - - if (isReplacing) { - CircularProgressIndicator() - } else { - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(devices, key = { it.deviceId }) { device -> - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.PhoneAndroid, contentDescription = stringResource(R.string.content_desc_device)) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(device.deviceName, fontWeight = FontWeight.SemiBold) - device.lastSeen?.let { - Text( - stringResource(R.string.last_seen, dateFormatter.format(it)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - TextButton(onClick = { onRemoveDevice(device.deviceId) }) { - Text(stringResource(R.string.action_remove)) - } - } - } - } - } - } - } - } -} - -@Composable -fun ClearAllDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.Info, contentDescription = null) }, - title = { Text(stringResource(R.string.dialog_destructive_action)) }, - text = { Text(stringResource(R.string.dialog_destructive_action_desc)) }, - confirmButton = { - TextButton( - onClick = onConfirm, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - }) -} - -@Composable -fun FpsMonitor(modifier: Modifier = Modifier) { - var fps by remember { mutableLongStateOf(0L) } - var lastFrameTime by remember { mutableLongStateOf(0L) } - var frameCount by remember { mutableLongStateOf(0L) } - - LaunchedEffect(Unit) { - while (true) { - withFrameNanos { currentFrameTime -> - frameCount++ - if (currentFrameTime - lastFrameTime >= 1_000_000_000L) { - fps = frameCount - frameCount = 0 - lastFrameTime = currentFrameTime - } - } - } - } - - Text( - text = stringResource(R.string.debug_fps, fps), - color = Color.Green, - style = MaterialTheme.typography.labelLarge, - modifier = modifier - .background(Color.Black.copy(alpha = 0.5f)) - .padding(4.dp) - ) -} - -@Composable -fun DangerousFolderActionDialog( - title: String, - message: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - title = { - Text( - text = title, - color = MaterialTheme.colorScheme.error - ) - }, - text = { Text(message) }, - confirmButton = { - TextButton( - onClick = onConfirm, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(R.string.action_confirm_clear)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} - -@Composable -fun ExternalFileSaveDialog( - onConfirm: (keep: Boolean, dontAskAgain: Boolean) -> Unit -) { - var dontAsk by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = { }, - properties = androidx.compose.ui.window.DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), - title = { Text(stringResource(R.string.external_file_prompt_title)) }, - text = { - Column { - Text(stringResource(R.string.external_file_prompt_desc)) - Spacer(modifier = Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { dontAsk = !dontAsk } - ) { - Checkbox(checked = dontAsk, onCheckedChange = { dontAsk = it }) - Text(stringResource(R.string.external_file_dont_ask)) - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(true, dontAsk) }) { - Text(stringResource(R.string.external_file_keep)) - } - }, - dismissButton = { - TextButton( - onClick = { onConfirm(false, dontAsk) }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.external_file_delete)) - } - } - ) -} - -@Composable -fun ExternalFileBehaviorDialog( - currentBehavior: String, - onDismiss: () -> Unit, - onSelect: (String) -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.options_external_file_behavior)) }, - text = { - Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) { - val options = listOf( - Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc), - Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc), - Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc), - Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc) - ) - options.forEach { (value, labelRes, descriptionRes) -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(value); onDismiss() } - .padding(vertical = 12.dp) - ) { - RadioButton(selected = currentBehavior == value, onClick = null) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(labelRes)) - Text( - text = stringResource(descriptionRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -@Composable -fun CloseAllTabsDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_close_all_tabs)) }, - text = { Text(stringResource(R.string.dialog_close_all_tabs_desc)) }, - confirmButton = { - TextButton( - onClick = onConfirm, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.action_close)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -@Composable -fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_strict_file_filter_title)) }, - text = { Text(stringResource(R.string.dialog_strict_file_filter_desc)) }, - confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_enable)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppThemeBottomSheet( - uiState: ReaderScreenState, - onThemeModeChanged: (AppThemeMode) -> Unit, - onContrastOptionChanged: (AppContrastOption) -> Unit, - onTextDimFactorLightChanged: (Float) -> Unit, - onTextDimFactorDarkChanged: (Float) -> Unit, - onSeedColorChanged: (Color?) -> Unit, - onCustomThemeAdded: (CustomAppTheme) -> Unit, - onCustomThemeDeleted: (String) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true) - var showCreateDialog by remember { mutableStateOf(false) } - - androidx.compose.material3.ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentWindowInsets = { WindowInsets.navigationBars } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 24.dp) - ) { - Text( - text = stringResource(R.string.app_theme_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - Text(stringResource(R.string.app_theme_appearance), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) { - AppThemeMode.entries.forEach { mode -> - val isSelected = uiState.appThemeMode == mode - Box( - modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp)) - .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) - .clickable { onThemeModeChanged(mode) }, - contentAlignment = Alignment.Center - ) { - Text(stringResource(mode.labelRes), color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) - } - } - } - - Spacer(Modifier.height(24.dp)) - - Text(stringResource(R.string.app_theme_contrast), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) { - AppContrastOption.entries.forEach { option -> - val isSelected = uiState.appContrastOption == option - Box( - modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp)) - .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) - .clickable { onContrastOptionChanged(option) }, - contentAlignment = Alignment.Center - ) { - Text(stringResource(option.labelRes), color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) - } - } - } - - Spacer(Modifier.height(24.dp)) - - if (uiState.appThemeMode == AppThemeMode.SYSTEM) { - Text(stringResource(R.string.app_theme_text_brightness_light), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - androidx.compose.material3.Slider( - value = uiState.appTextDimFactorLight, - onValueChange = onTextDimFactorLightChanged, - valueRange = 0.3f..1.0f, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp) - ) - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) - } - - Spacer(Modifier.height(16.dp)) - - Text(stringResource(R.string.app_theme_text_brightness_dark), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - androidx.compose.material3.Slider( - value = uiState.appTextDimFactorDark, - onValueChange = onTextDimFactorDarkChanged, - valueRange = 0.3f..1.0f, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp) - ) - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) - } - } else { - Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - androidx.compose.material3.Slider( - value = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight, - onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged, - valueRange = 0.3f..1.0f, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp) - ) - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) - } - } - - Spacer(Modifier.height(24.dp)) - - Text(stringResource(R.string.app_theme_color_scheme), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - val presets = listOf( - R.string.app_theme_preset_ocean to Color(0xFF00668B), - R.string.app_theme_preset_mint to Color(0xFF006C4C), - R.string.app_theme_preset_rose to Color(0xFF9C4146), - R.string.app_theme_preset_sepia to Color(0xFF705D49), - R.string.app_theme_preset_amethyst to Color(0xFF9B59B6), - R.string.app_theme_preset_amber to Color(0xFFFFC107), - R.string.app_theme_preset_sapphire to Color(0xFF0F52BA) - ) - - LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - item { - ThemeSwatch( - color = MaterialTheme.colorScheme.primary, - isSelected = uiState.appSeedColor == null, - label = stringResource(R.string.app_theme_dynamic), - onClick = { onSeedColorChanged(null) } - ) - } - items(presets.size) { i -> - val (labelRes, color) = presets[i] - ThemeSwatch( - color = color, - isSelected = uiState.appSeedColor == color, - label = stringResource(labelRes), - onClick = { onSeedColorChanged(color) } - ) - } - } - - Spacer(Modifier.height(24.dp)) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(24.dp)) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.content_desc_add_custom_theme), tint = MaterialTheme.colorScheme.primary) - } - } - Spacer(Modifier.height(8.dp)) - - if (uiState.customAppThemes.isEmpty()) { - Text(stringResource(R.string.theme_no_custom), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - items(uiState.customAppThemes) { theme -> - ThemeSwatch( - color = theme.seedColor, - isSelected = uiState.appSeedColor == theme.seedColor, - label = theme.name, - onClick = { onSeedColorChanged(theme.seedColor) }, - onDelete = { onCustomThemeDeleted(theme.id) } - ) - } - } - } - } - } - - if (showCreateDialog) { - CreateAppThemeDialog( - onDismiss = { showCreateDialog = false }, - onSave = { name, color -> - onCustomThemeAdded(CustomAppTheme(id = System.currentTimeMillis().toString(), name = name, seedColor = color)) - showCreateDialog = false - } - ) - } -} - -@Composable -fun ThemeSwatch( - color: Color, - isSelected: Boolean, - label: String, - onClick: () -> Unit, - onDelete: (() -> Unit)? = null -) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier - .size(56.dp) - .background(color, CircleShape) - .border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape) - .clickable { onClick() }, - contentAlignment = Alignment.Center - ) { - if (isSelected) { - Icon(Icons.Default.Check, contentDescription = null, tint = if (color.luminance() > 0.5f) Color.Black else Color.White) - } - } - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 64.dp)) - if (onDelete != null) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_delete), modifier = Modifier.size(16.dp).clickable { onDelete() }, tint = MaterialTheme.colorScheme.error) - } - } - } -} - -@SuppressLint("UnrememberedMutableState") -@Composable -fun CreateAppThemeDialog( - initialColor: Color = Color(0xFF6750A4), - onDismiss: () -> Unit, - onSave: (String, Color) -> Unit -) { - var name by remember { mutableStateOf("") } - val context = LocalContext.current - - val initialHsv = remember(initialColor) { - val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv) - hsv - } - - var hue by androidx.compose.runtime.mutableFloatStateOf(initialHsv[0]) - var saturation by androidx.compose.runtime.mutableFloatStateOf(initialHsv[1]) - var value by androidx.compose.runtime.mutableFloatStateOf(initialHsv[2]) - - val currentColor by remember { - androidx.compose.runtime.derivedStateOf { - val hsv = floatArrayOf(hue, saturation, value) - Color(android.graphics.Color.HSVToColor(255, hsv)) - } - } - - fun updateFromColor(color: Color) { - val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(color.toArgb(), hsv) - hue = hsv[0] - saturation = hsv[1] - value = hsv[2] - } - - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), - color = Color(0xFF2C2C2C), - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(16.dp) - ) { - Column( - modifier = Modifier - .padding(20.dp) - .verticalScroll(androidx.compose.foundation.rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.app_theme_create_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) - - Spacer(Modifier.height(16.dp)) - - androidx.compose.material3.OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(stringResource(R.string.theme_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedBorderColor = currentColor, - focusedLabelColor = currentColor, - ) - ) - - Spacer(Modifier.height(20.dp)) - - SpectrumBox( - hue = hue, - saturation = saturation, - currentColor = currentColor, - onHueSatChanged = { h, s -> hue = h; saturation = s }, - modifier = Modifier.fillMaxWidth().height(220.dp) - ) - - Spacer(Modifier.height(20.dp)) - - BrightnessSlider( - hue = hue, - saturation = saturation, - value = value, - onValueChanged = { value = it }, - modifier = Modifier.fillMaxWidth().height(24.dp).clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) - ) - - Spacer(Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - ColorComparePill( - oldColor = initialColor, - newColor = currentColor, - modifier = Modifier.width(64.dp).height(36.dp) - ) - - Column( - modifier = Modifier.weight(1.6f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.theme_color_hex), color = Color.Gray, fontSize = 12.sp, maxLines = 1) - Spacer(Modifier.height(4.dp)) - HexInput(color = currentColor, onHexChanged = { updateFromColor(it) }) - } - - Row( - modifier = Modifier.weight(2.4f), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - RgbInputColumn(label = stringResource(R.string.color_r), value = currentColor.red, - onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) }, - modifier = Modifier.weight(1f) - ) - RgbInputColumn(label = stringResource(R.string.color_g), value = currentColor.green, - onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) }, - modifier = Modifier.weight(1f) - ) - RgbInputColumn(label = stringResource(R.string.color_b), value = currentColor.blue, - onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) }, - modifier = Modifier.weight(1f) - ) - } - } - - Spacer(Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel), color = Color.Gray) - } - Spacer(Modifier.width(8.dp)) - androidx.compose.material3.Button( - onClick = { onSave(name.ifBlank { context.getString(R.string.app_theme_custom_default_name) }, currentColor) }, - colors = ButtonDefaults.buttonColors(containerColor = currentColor) - ) { - Text(stringResource(R.string.action_save), color = if (currentColor.luminance() > 0.5f) Color.Black else Color.White, fontWeight = FontWeight.Bold) - } - } - } - } - } -} - -@Composable -fun LanguageSelectionDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - val currentLocales = AppCompatDelegate.getApplicationLocales() - val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.toLanguageTag() else null - var languageSearchQuery by remember { mutableStateOf("") } - val languageRows = appLanguageSelectionOptions - .map { language -> language to stringResource(language.labelRes) } - .filter { (language, label) -> - language.matchesLanguageSearch(label = label, query = languageSearchQuery) - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.options_language)) }, - text = { - Column { - androidx.compose.material3.OutlinedTextField( - value = languageSearchQuery, - onValueChange = { languageSearchQuery = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(R.string.action_search)) }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - trailingIcon = { - if (languageSearchQuery.isNotBlank()) { - IconButton(onClick = { languageSearchQuery = "" }) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.action_clear) - ) - } - } - } - ) - Spacer(modifier = Modifier.height(12.dp)) - if (languageRows.isEmpty()) { - Text( - text = stringResource(R.string.search_no_results_simple), - modifier = Modifier.padding(vertical = 12.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 360.dp) - ) { - items( - items = languageRows, - key = { (language, _) -> language.tag ?: "system" } - ) { (language, label) -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val locales = language.tag?.let { tag -> - LocaleListCompat.forLanguageTags(tag) - } ?: LocaleListCompat.getEmptyLocaleList() - AppCompatDelegate.setApplicationLocales(locales) - onDismiss() - context.findActivity()?.recreate() - } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton(selected = currentTag == language.tag, onClick = null) - Spacer(modifier = Modifier.width(16.dp)) - Text(label) - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt deleted file mode 100644 index 4ed89c0..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt +++ /dev/null @@ -1,2109 +0,0 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ -// LibraryScreen.kt -@file:Suppress("KotlinConstantConditions") - -package org.dueattendant149.bookreader - -import android.annotation.SuppressLint -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerDefaults -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderSpecial -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.media3.common.util.UnstableApi -import androidx.navigation.NavHostController -import coil.ImageLoader -import coil.compose.AsyncImage -import coil.decode.SvgDecoder -import org.dueattendant149.bookreader.data.RecentFileItem -import org.dueattendant149.bookreader.data.TagEntity -import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import org.jsoup.Jsoup -import timber.log.Timber -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -private fun getBookCountString(count: Int): String { - return pluralStringResource(id = R.plurals.book_count, count, count) -} - -@UnstableApi -@SuppressLint("LocalContextGetResourceValueCall") -@Composable -fun LibraryScreen( - viewModel: MainViewModel, - navController: NavHostController, -) { - val compStart = remember { System.currentTimeMillis() } - LaunchedEffect(Unit) { - ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms") - } - val context = LocalContext.current - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val selectedItems = uiState.contextualActionItems - val isContextualModeActive = selectedItems.isNotEmpty() - val selectedShelves = uiState.contextualActionShelfIds - val isShelfContextualModeActive = selectedShelves.isNotEmpty() - val sortOrder = uiState.sortOrder - val shelves = uiState.shelves - val rawLibraryFiles = uiState.rawLibraryFiles - val tabTitles = remember { - buildList { - add(context.getString(R.string.tab_all_books)) - add(context.getString(R.string.tab_shelves)) - add(context.getString(R.string.tab_folders)) - } - } - val pagerState = rememberPagerState( - initialPage = uiState.libraryScreenStartPage, - pageCount = { tabTitles.size } - ) - - val containsFolderItems = selectedItems.any { it.sourceFolderUri != null } - - val scope = rememberCoroutineScope() - var showFilterSheet by remember { mutableStateOf(false) } - - val isSearchActive = uiState.isSearchActive - val searchQuery = uiState.searchQuery - - val pickFolderLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree() - ) { uri -> - uri?.let { - viewModel.addSyncedFolder(it) - } - } - - val onSelectSyncFolderClick = { - try { - pickFolderLauncher.launch(null) - } catch (_: android.content.ActivityNotFoundException) { - viewModel.showBanner(context.getString(R.string.error_folder_selection_unsupported), isError = true) - } - } - - val pickFileLauncher = rememberFilePickerLauncher { uris -> - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - viewModel.onFilesSelected(uris) - } - - val fallbackFilePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris -> - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - viewModel.onFilesSelected(uris) - } - - val onSelectFileClick = { - if (isContextualModeActive) { - viewModel.clearContextualAction() - } - val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*") - try { - pickFileLauncher.launch(mimeTypes) - } catch (_: android.content.ActivityNotFoundException) { - Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.") - try { - fallbackFilePickerLauncher.launch("*/*") - } catch (_: android.content.ActivityNotFoundException) { - viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true) - } - } - } - - LaunchedEffect(pagerState) { - androidx.compose.runtime.snapshotFlow { pagerState.settledPage } - .drop(1) - .distinctUntilChanged() - .collect { page -> - viewModel.setLibraryScreenPage(page) - } - } - - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var showDeleteShelvesDialog by remember { mutableStateOf(false) } - var showInfoDialog by remember { mutableStateOf(false) } - var itemForInfoDialog by remember { mutableStateOf(null) } - var pendingSaveOriginalItem by remember { mutableStateOf(null) } - - val saveOriginalLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - val item = pendingSaveOriginalItem - pendingSaveOriginalItem = null - if (uri != null && item?.uriString != null) { - viewModel.saveOriginalFile(item.uriString.toUri(), uri) - } - } - - fun saveOriginalItem(item: RecentFileItem) { - if (!item.canExportOriginalFile()) return - pendingSaveOriginalItem = item - saveOriginalLauncher.launch(item.suggestedOriginalFileName()) - } - - fun shareOriginalItem(item: RecentFileItem) { - val uriString = item.uriString ?: return - if (!item.canExportOriginalFile()) return - scope.launch { - viewModel.shareOriginalFile( - activityContext = context, - sourceUri = uriString.toUri(), - fileType = item.type, - filename = item.suggestedOriginalFileName() - ) - } - } - - BackHandler(enabled = isContextualModeActive) { - viewModel.clearContextualAction() - } - - BackHandler(enabled = isShelfContextualModeActive) { - viewModel.clearShelfContextualAction() - } - - BackHandler(enabled = isSearchActive) { - viewModel.setSearchActive(false) - } - - Box(modifier = Modifier.fillMaxSize()) { - LibraryScreenContent( - tabTitles = tabTitles, - recentFiles = uiState.allRecentFiles, - rawLibraryFiles = rawLibraryFiles, - shelves = shelves, - selectedItems = selectedItems, - selectedShelves = selectedShelves, - sortOrder = sortOrder, - libraryFilters = uiState.libraryFilters, - allTags = uiState.allTags, - pinnedLibraryBookIds = uiState.pinnedLibraryBookIds, - pagerState = pagerState, - scope = scope, - searchQuery = searchQuery, - isSearchActive = isSearchActive, - onSearchQueryChange = viewModel::onSearchQueryChange, - onSearchActiveChange = viewModel::setSearchActive, - onSortOrderChange = viewModel::setSortOrder, - onFilterClick = { showFilterSheet = true }, - onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) }, - onRemoveFilter = { viewModel.updateLibraryFilters(it) }, - onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) }, - onPinClick = { viewModel.togglePinForContextualItems(isHome = false) }, - onClearSelection = { viewModel.clearContextualAction() }, - onItemClick = viewModel::onRecentFileClicked, - onItemLongClick = viewModel::onRecentItemLongPress, - onInfoClick = { - if (selectedItems.size == 1) { - itemForInfoDialog = selectedItems.first() - showInfoDialog = true - } - }, - onSaveClick = selectedItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { saveOriginalItem(item) } }, - onShareClick = selectedItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { shareOriginalItem(item) } }, - onDeleteClick = { showDeleteConfirmDialog = true }, - onSelectAllClick = { viewModel.selectAllLibraryFiles() }, - onShelfClick = viewModel::onShelfClick, - onShelfLongClick = viewModel::onShelfLongPress, - onClearShelfSelection = viewModel::clearShelfContextualAction, - onDeleteShelves = { showDeleteShelvesDialog = true }, - onNewShelfClick = viewModel::showCreateShelfDialog, - onSelectFileClick = onSelectFileClick, - onScanNowClick = viewModel::scanSyncedFolder, - onSyncMetadataClick = viewModel::syncFolderMetadata, - onSelectSyncFolderClick = onSelectSyncFolderClick, - onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) }, - syncedFolders = uiState.syncedFolders, - onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, - onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled, - onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders, - downloadingBookIds = uiState.downloadingBookIds, - lastFolderScanTime = uiState.lastFolderScanTime, - isLoading = uiState.isLoading, - isRefreshing = uiState.isRefreshing, - onOpdsBookDownloaded = { uri, title -> - viewModel.showBanner(context.getString(R.string.banner_downloaded, title)) - viewModel.onFileSelected(uri, isFromRecent = false) - }, - onStreamOpdsBook = { _, _ -> }, - onDeleteCatalogStreams = { }, - onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }, - onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) }, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName - ) - - - if (uiState.showCreateShelfDialog) { - CreateShelfDialog( - onConfirm = viewModel::createShelf, - onDismiss = viewModel::dismissCreateShelfDialog - ) - } - - if (showDeleteConfirmDialog) { - DeleteConfirmationDialog( - count = selectedItems.size, - onConfirm = { - viewModel.deleteContextualItemsPermanently() - showDeleteConfirmDialog = false - }, - onDismiss = { showDeleteConfirmDialog = false }, - isPermanentDelete = true, - containsFolderItems = containsFolderItems - ) - } - - if (showFilterSheet) { - LibraryFilterSheet( - filters = uiState.libraryFilters, - allTags = uiState.allTags, - syncedFolders = uiState.syncedFolders, - onApply = { viewModel.updateLibraryFilters(it) }, - onDismiss = { showFilterSheet = false } - ) - } - - if (showDeleteShelvesDialog) { - DeleteShelvesConfirmationDialog( - count = selectedShelves.size, - onConfirm = { - viewModel.deleteSelectedShelves() - showDeleteShelvesDialog = false - }, - onDismiss = { showDeleteShelvesDialog = false } - ) - } - - HydratedFileInfoDialog( - item = itemForInfoDialog, - isVisible = showInfoDialog, - uiState = uiState, - viewModel = viewModel, - onDismiss = { - showInfoDialog = false - itemForInfoDialog = null - }, - onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } - ) - CustomTopBanner(bannerMessage = uiState.bannerMessage) - } -} - -@UnstableApi -@Composable -fun ShelfScreen( - viewModel: MainViewModel, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val selectedItems = uiState.contextualActionItems - val viewingShelfId = uiState.viewingShelfId - val isAddingBooks = uiState.isAddingBooksToShelf - val shelves = uiState.shelves - val sortOrder = uiState.sortOrder - val showRenameDialogFor = uiState.showRenameShelfDialogFor - val showDeleteDialogFor = uiState.showDeleteShelfDialogFor - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var showRemoveFromShelfDialog by remember { mutableStateOf(false) } - var showInfoDialog by remember { mutableStateOf(false) } - var itemForInfoDialog by remember { mutableStateOf(null) } - var pendingSaveOriginalItem by remember { mutableStateOf(null) } - - val saveOriginalLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - val item = pendingSaveOriginalItem - pendingSaveOriginalItem = null - if (uri != null && item?.uriString != null) { - viewModel.saveOriginalFile(item.uriString.toUri(), uri) - } - } - - fun saveOriginalItem(item: RecentFileItem) { - if (!item.canExportOriginalFile()) return - pendingSaveOriginalItem = item - saveOriginalLauncher.launch(item.suggestedOriginalFileName()) - } - - fun shareOriginalItem(item: RecentFileItem) { - val uriString = item.uriString ?: return - if (!item.canExportOriginalFile()) return - scope.launch { - viewModel.shareOriginalFile( - activityContext = context, - sourceUri = uriString.toUri(), - fileType = item.type, - filename = item.suggestedOriginalFileName() - ) - } - } - - BackHandler(enabled = true) { - when { - selectedItems.isNotEmpty() -> viewModel.clearContextualAction() - isAddingBooks -> viewModel.dismissAddBooksToShelf() - else -> viewModel.navigateBackFromShelf() - } - } - - val currentShelf = shelves.find { it.id == viewingShelfId } - val childShelves = remember(shelves, currentShelf) { - currentShelf?.childShelfIds?.mapNotNull { childId -> shelves.find { it.id == childId } } ?: emptyList() - } - - Box(modifier = Modifier.fillMaxSize()) { - if (viewingShelfId != null && currentShelf != null) { - if (isAddingBooks) { - AddBooksModeScreen( - shelfName = currentShelf.name, - availableBooks = uiState.booksAvailableForAdding, - selectedBookUris = uiState.booksSelectedForAdding, - currentSource = uiState.addBooksSource, - sortOrder = sortOrder, - onSortOrderChange = viewModel::setSortOrder, - onSourceChange = viewModel::setAddBooksSource, - onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) }, - onBack = viewModel::dismissAddBooksToShelf, - onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) }, - downloadingBookIds = uiState.downloadingBookIds, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName - ) - } else { - ShelfDetailScreen( - shelf = currentShelf, - childShelves = childShelves, - selectedItems = selectedItems, - sortOrder = sortOrder, - onSortOrderChange = viewModel::setSortOrder, - onBack = viewModel::navigateBackFromShelf, - onAddBooksClick = viewModel::showAddBooksToShelf, - onChildShelfClick = viewModel::onShelfClick, - onBookClick = viewModel::onRecentFileClicked, - onBookLongClick = viewModel::onRecentItemLongPress, - onClearSelection = viewModel::clearContextualAction, - onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) }, - onInfoClick = { - if (selectedItems.size == 1) { - itemForInfoDialog = selectedItems.first() - showInfoDialog = true - } - }, - onSaveClick = selectedItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { saveOriginalItem(item) } }, - onShareClick = selectedItems.singleOrNull() - ?.takeIf { it.canExportOriginalFile() } - ?.let { item -> { shareOriginalItem(item) } }, - onDeleteClick = { showRemoveFromShelfDialog = true }, - onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, - onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, - downloadingBookIds = uiState.downloadingBookIds, - usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName - ) - } - } - - if (showRenameDialogFor != null) { - val shelfToRename = shelves.find { it.id == showRenameDialogFor } - if (shelfToRename != null) { - RenameShelfDialog( - initialName = shelfToRename.name, - onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) }, - onDismiss = viewModel::dismissRenameShelfDialog - ) - } - } - - if (showDeleteDialogFor != null) { - DeleteShelfConfirmationDialog( - shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "", - onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) }, - onDismiss = viewModel::dismissDeleteShelfDialog - ) - } - - if (showRemoveFromShelfDialog) { - RemoveFromShelfConfirmationDialog( - count = selectedItems.size, - shelfName = currentShelf?.name ?: "", - onConfirm = { - viewModel.removeContextualItemsFromShelf() - showRemoveFromShelfDialog = false - }, - onDismiss = { showRemoveFromShelfDialog = false } - ) - } - - HydratedFileInfoDialog( - item = itemForInfoDialog, - isVisible = showInfoDialog, - uiState = uiState, - viewModel = viewModel, - onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, - onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) } - ) - CustomTopBanner(bannerMessage = uiState.bannerMessage) - } -} - -@Suppress("unused") -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LibraryScreenContent( - tabTitles: List, - recentFiles: List, - rawLibraryFiles: List, - shelves: List, - selectedItems: Set, - selectedShelves: Set, - sortOrder: SortOrder, - libraryFilters: LibraryFilters, - allTags: List, - pinnedLibraryBookIds: Set, - pagerState: PagerState, - scope: CoroutineScope, - searchQuery: String, - isSearchActive: Boolean, - onSearchQueryChange: (String) -> Unit, - onSearchActiveChange: (Boolean) -> Unit, - onSortOrderChange: (SortOrder) -> Unit, - onFilterClick: () -> Unit, - onClearFilters: () -> Unit, - onRemoveFilter: (LibraryFilters) -> Unit, - onTagClick: () -> Unit, - onPinClick: () -> Unit, - onClearSelection: () -> Unit, - onItemClick: (RecentFileItem) -> Unit, - onItemLongClick: (RecentFileItem) -> Unit, - onInfoClick: () -> Unit, - onSaveClick: (() -> Unit)?, - onShareClick: (() -> Unit)?, - onDeleteClick: () -> Unit, - onSelectAllClick: () -> Unit, - onShelfClick: (Shelf) -> Unit, - onShelfLongClick: (Shelf) -> Unit, - onClearShelfSelection: () -> Unit, - onDeleteShelves: () -> Unit, - onNewShelfClick: () -> Unit, - onSelectFileClick: () -> Unit, - onScanNowClick: () -> Unit, - onSyncMetadataClick: () -> Unit, - onSelectSyncFolderClick: () -> Unit, - onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, - onDisconnectSyncFolderClick: () -> Unit, - downloadingBookIds: Set, - lastFolderScanTime: Long?, - isLoading: Boolean, - isRefreshing: Boolean, - syncedFolders: List, - onRemoveFolderClick: (SyncedFolder) -> Unit, - onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, - onOpdsBookDownloaded: (Uri, String) -> Unit, - onStreamOpdsBook: (Any, Any?) -> Unit, - onDeleteCatalogStreams: (String) -> Unit, - onSettingsClick: () -> Unit, - onBookshelfClick: () -> Unit, - usePdfFileNameAsDisplayName: Boolean, -) { - val isBookContextualModeActive = selectedItems.isNotEmpty() - val isShelfContextualModeActive = selectedShelves.isNotEmpty() - var showSortMenu by remember { mutableStateOf(false) } - val searchFocusRequester = remember { FocusRequester() } - val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } } - - var textFieldValue by remember(isSearchActive) { - mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) - } - - LaunchedEffect(searchQuery) { - if (textFieldValue.text != searchQuery) { - textFieldValue = textFieldValue.copy( - text = searchQuery, - selection = TextRange(searchQuery.length) - ) - } - } - - LaunchedEffect(isSearchActive) { - if (isSearchActive) { - searchFocusRequester.requestFocus() - } - } - - Scaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - Column { - if (isBookContextualModeActive) { - ContextualTopAppBar( - selectedItemCount = selectedItems.size, - onNavIconClick = onClearSelection, - onTagClick = onTagClick, - onPinClick = onPinClick, - onInfoClick = onInfoClick, - onSaveClick = onSaveClick, - onShareClick = onShareClick, - onDeleteClick = onDeleteClick, - onSelectAllClick = onSelectAllClick - ) - } else if (isShelfContextualModeActive && pagerState.currentPage == 1) { - ContextualTopAppBar( - selectedItemCount = selectedShelves.size, - onNavIconClick = onClearShelfSelection, - onDeleteClick = onDeleteShelves - ) - } else if (isSearchActive) { - Surface( - shadowElevation = 4.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(64.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { onSearchActiveChange(false) }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_close_search)) - } - OutlinedTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - onSearchQueryChange(it.text) - }, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - modifier = Modifier - .weight(1f) - .padding(vertical = 4.dp) - .focusRequester(searchFocusRequester) - .testTag("LibrarySearchTextField"), - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChange("") }) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_clear_query)) - } - } - } - ) - } - } - } else { - CustomTopAppBar(title = { Text(stringResource(R.string.library_title)) }, - actions = { - if (pagerState.currentPage == 0) { - IconButton(onClick = onFilterClick) { - Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter)) - } - Box { - TextButton( - onClick = { showSortMenu = true }, - modifier = Modifier.testTag("LibrarySortButton") - ) { - Icon( - painter = painterResource(id = R.drawable.sort), - contentDescription = stringResource(R.string.content_desc_sort), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(sortOrder.labelRes)) - } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false } - ) { - SortOrder.entries.forEach { order -> - DropdownMenuItem( - text = { Text(stringResource(order.labelRes)) }, - onClick = { - onSortOrderChange(order) - showSortMenu = false - }, - trailingIcon = { - if (order == sortOrder) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - } - } - ) - } - } - } - IconButton(onClick = { onSearchActiveChange(true) }) { - Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) - } - } - IconButton(onClick = onBookshelfClick) { - Icon(Icons.Default.Cloud, contentDescription = "Server library") - } - IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) - } - } - ) - TabRow(selectedTabIndex = pagerState.currentPage) { - tabTitles.forEachIndexed { index, title -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - ReaderPerfLog.d("LibraryPager click page=$index title=$title") - if (pagerState.currentPage != index) { - scope.launch { - val start = ReaderPerfLog.nowNanos() - pagerState.animateScrollToPage(index) - ReaderPerfLog.d( - "LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms" - ) - } - } - }, - text = { Text(title) } - ) - } - } - androidx.compose.animation.AnimatedVisibility( - visible = libraryFilters.isActive && pagerState.currentPage == 0 - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 16.dp, vertical = 8.dp) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (libraryFilters.fileTypes.isNotEmpty()) { - AssistChip( - onClick = { onRemoveFilter(libraryFilters.copy(fileTypes = emptySet())) }, - label = { Text(stringResource(R.string.filter_types, libraryFilters.fileTypes.joinToString { it.name })) }, - trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) } - ) - } - if (libraryFilters.sourceFolders.isNotEmpty()) { - AssistChip( - onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) }, - label = { Text(stringResource(R.string.filter_folders, libraryFilters.sourceFolders.size)) }, - trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) } - ) - } - if (libraryFilters.readStatus != ReadStatusFilter.ALL) { - AssistChip( - onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) }, - label = { Text(stringResource(R.string.filter_status, stringResource(libraryFilters.readStatus.labelRes))) }, - trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) } - ) - } - if (libraryFilters.tagIds.isNotEmpty()) { - val selectedTags = allTags.filter { it.id in libraryFilters.tagIds } - val tagLabel = when { - selectedTags.isEmpty() -> pluralStringResource(R.plurals.tag_count, libraryFilters.tagIds.size, libraryFilters.tagIds.size) - selectedTags.size <= 2 -> selectedTags.joinToString { it.name } - else -> pluralStringResource(R.plurals.tag_count, selectedTags.size, selectedTags.size) - } - AssistChip( - onClick = { onRemoveFilter(libraryFilters.copy(tagIds = emptySet())) }, - label = { Text(stringResource(R.string.filter_tags, tagLabel)) }, - trailingIcon = { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear), modifier = Modifier.size(16.dp)) } - ) - } - } - } - } - } - }, - floatingActionButton = { - if (!isBookContextualModeActive && !isShelfContextualModeActive) { - when (pagerState.currentPage) { - 0 -> { - if (recentFiles.isNotEmpty()) { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.fab_add_file)) }, - icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_add_file)) }, - onClick = onSelectFileClick, - modifier = Modifier.padding(16.dp) - ) - } - } - 1 -> { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.fab_new_shelf)) }, - icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) }, - onClick = onNewShelfClick, - modifier = Modifier - .padding(16.dp) - .testTag("LibraryNewShelfFab") - ) - } - } - } - } - ) { paddingValues -> - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - snapPositionalThreshold = 0.25f - ), - beyondViewportPageCount = 0, - key = { it } - ) { page -> - when (page) { - 0 -> { - if (recentFiles.isEmpty() && searchQuery.isNotEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.no_results_found, searchQuery)) - } - } else if (recentFiles.isEmpty()) { - EmptyState( - title = stringResource(R.string.your_library_empty), - message = stringResource(R.string.library_empty_desc), - onSelectFileClick = onSelectFileClick, - modifier = Modifier.fillMaxSize() - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(recentFiles, key = { it.bookId }) { item -> - LibraryListItem( - item = item, - isSelected = item.bookId in selectedBookIds, - isPinned = item.bookId in pinnedLibraryBookIds, - onItemClick = { onItemClick(item) }, - onItemLongClick = { onItemLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds, - usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName - ) - } - } - } - } - 1 -> { - ShelvesScreen( - shelves = shelves, - onShelfClick = onShelfClick, - onShelfLongClick = onShelfLongClick, - selectedShelves = selectedShelves - ) - } - } - } - } -} - -@Composable -private fun ShelvesScreen( - shelves: List, - onShelfClick: (Shelf) -> Unit, - onShelfLongClick: (Shelf) -> Unit, - selectedShelves: Set, -) { - val tagShelves = remember(shelves) { shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } } - val visibleShelves = remember(shelves) { - shelves.filter { shelf -> - when { - shelf.type == ShelfType.TAG -> false - shelf.type == ShelfType.FOLDER -> shelf.parentShelfId == null - else -> true - } - } - } - - LazyColumn( - modifier = Modifier - .fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (tagShelves.isNotEmpty() && selectedShelves.isEmpty()) { - item { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - text = stringResource(R.string.section_browse_by_tag), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - tagShelves.forEach { shelf -> - FilterChip( - selected = false, - onClick = { onShelfClick(shelf) }, - label = { Text(shelf.name) }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.tag), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - ) - } - } - } - } - } - - items(visibleShelves, key = { it.id }) { shelf -> - ShelfListItem( - shelf = shelf, - isSelected = shelf.id in selectedShelves, - onItemClick = { onShelfClick(shelf) }, - onItemLongClick = { onShelfLongClick(shelf) } - ) - } - } -} - -@Composable -private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) { - var text by remember { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.create_new_shelf)) }, - text = { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - placeholder = { Text(stringResource(R.string.shelf_name_hint)) }, - singleLine = true, - modifier = Modifier.focusRequester(focusRequester) - ) - }, - confirmButton = { - TextButton( - onClick = { onConfirm(text) }, - enabled = text.isNotBlank() - ) { - Text(stringResource(R.string.action_create)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - - LaunchedEffect(Unit) { - delay(100) - focusRequester.requestFocus() - } -} - -@Composable -private fun ShelfDetailScreen( - shelf: Shelf, - childShelves: List, - selectedItems: Set, - sortOrder: SortOrder, - onSortOrderChange: (SortOrder) -> Unit, - onBack: () -> Unit, - onAddBooksClick: () -> Unit, - onChildShelfClick: (Shelf) -> Unit, - onBookClick: (RecentFileItem) -> Unit, - onBookLongClick: (RecentFileItem) -> Unit, - onClearSelection: () -> Unit, - onTagClick: () -> Unit, - onInfoClick: () -> Unit, - onSaveClick: (() -> Unit)?, - onShareClick: (() -> Unit)?, - onDeleteClick: () -> Unit, - onRenameShelf: () -> Unit, - onDeleteShelf: () -> Unit, - downloadingBookIds: Set, - usePdfFileNameAsDisplayName: Boolean, -) { - val isContextualModeActive = selectedItems.isNotEmpty() - val isFolderShelf = shelf.type == ShelfType.FOLDER - var showSortMenu by remember { mutableStateOf(false) } - var showMoreMenu by remember { mutableStateOf(false) } - var isSearchActive by remember(shelf.id) { mutableStateOf(false) } - var searchQuery by remember(shelf.id) { mutableStateOf("") } - val searchFocusRequester = remember { FocusRequester() } - var searchFieldValue by remember(isSearchActive, shelf.id) { - mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) - } - val normalizedQuery = searchQuery.trim() - val filteredChildShelves = remember(childShelves, normalizedQuery) { - if (normalizedQuery.isBlank()) { - childShelves - } else { - childShelves.filter { childShelf -> - childShelf.name.contains(normalizedQuery, ignoreCase = true) || - childShelf.books.any { item -> - item.displayName.contains(normalizedQuery, ignoreCase = true) || - item.title?.contains(normalizedQuery, ignoreCase = true) == true || - item.author?.contains(normalizedQuery, ignoreCase = true) == true - } - } - } - } - val filteredDirectBooks = remember(shelf.directBooks, normalizedQuery) { - if (normalizedQuery.isBlank()) { - shelf.directBooks - } else { - shelf.directBooks.filter { item -> - item.displayName.contains(normalizedQuery, ignoreCase = true) || - item.title?.contains(normalizedQuery, ignoreCase = true) == true || - item.author?.contains(normalizedQuery, ignoreCase = true) == true || - item.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) } - } - } - } - - LaunchedEffect(searchQuery) { - if (searchFieldValue.text != searchQuery) { - searchFieldValue = searchFieldValue.copy( - text = searchQuery, - selection = TextRange(searchQuery.length) - ) - } - } - - LaunchedEffect(isSearchActive) { - if (isSearchActive) { - searchFocusRequester.requestFocus() - } - } - - fun clearShelfSearchQuery() { - searchQuery = "" - searchFieldValue = TextFieldValue("", TextRange.Zero) - } - - fun closeShelfSearch() { - isSearchActive = false - clearShelfSearchQuery() - } - - BackHandler(enabled = isSearchActive) { - closeShelfSearch() - } - - Scaffold( - modifier = Modifier, - topBar = { - if (isContextualModeActive) { - ContextualTopAppBar( - selectedItemCount = selectedItems.size, - onNavIconClick = onClearSelection, - onTagClick = onTagClick, - onInfoClick = onInfoClick, - onSaveClick = onSaveClick, - onShareClick = onShareClick, - onDeleteClick = onDeleteClick - ) - } else if (isSearchActive) { - Surface( - shadowElevation = 4.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(64.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { closeShelfSearch() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_close_search)) - } - OutlinedTextField( - value = searchFieldValue, - onValueChange = { - searchFieldValue = it - searchQuery = it.text - }, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - modifier = Modifier - .weight(1f) - .padding(vertical = 4.dp) - .focusRequester(searchFocusRequester) - .testTag("ShelfSearchTextField"), - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { clearShelfSearchQuery() }) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_clear_query)) - } - } - } - ) - } - } - } else { - CustomTopAppBar( - title = { - Column { - Text( - text = shelf.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when { - isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 -> - stringResource( - R.string.folder_subtitle_folder_book_counts, - pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount), - getBookCountString(shelf.directBookCount) - ) - isFolderShelf && shelf.childShelfCount > 0 -> - pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount) - isFolderShelf -> getBookCountString(shelf.directBookCount) - else -> getBookCountString(shelf.bookCount) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) - } - }, - actions = { - Box { - TextButton( - onClick = { showSortMenu = true }, - modifier = Modifier.testTag("ShelfSortButton") - ) { - Icon( - painter = painterResource(id = R.drawable.sort), - contentDescription = stringResource(R.string.content_desc_sort), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(sortOrder.labelRes)) - } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false } - ) { - SortOrder.entries.forEach { order -> - DropdownMenuItem( - text = { Text(stringResource(order.labelRes)) }, - onClick = { - onSortOrderChange(order) - showSortMenu = false - }, - trailingIcon = { - if (order == sortOrder) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected) - ) - } - } - ) - } - } - } - - IconButton(onClick = { isSearchActive = true }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.content_desc_search_shelf) - ) - } - - if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") { - Box { - IconButton(onClick = { showMoreMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.content_desc_more_options) - ) - } - DropdownMenu( - expanded = showMoreMenu, - onDismissRequest = { showMoreMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_rename_shelf)) }, - onClick = { - onRenameShelf() - showMoreMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_delete_shelf)) }, - onClick = { - onDeleteShelf() - showMoreMenu = false - } - ) - } - } - } - } - ) - } - }, - floatingActionButton = { - if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) { - ExtendedFloatingActionButton( - onClick = onAddBooksClick, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.fab_add_books)) } - ) - } - }, - content = { paddingValues -> - if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text( - text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource( - R.string.no_results_found, - normalizedQuery - ), - style = MaterialTheme.typography.bodyLarge - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (filteredChildShelves.isNotEmpty()) { - if (isFolderShelf) { - item { - Text( - text = stringResource(R.string.section_folders), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - items(filteredChildShelves, key = { it.id }) { childShelf -> - ShelfListItem( - shelf = childShelf, - isSelected = false, - onItemClick = { onChildShelfClick(childShelf) }, - onItemLongClick = {}, - showHierarchyIndent = false - ) - } - } - if (filteredDirectBooks.isNotEmpty() && isFolderShelf && filteredChildShelves.isNotEmpty()) { - item { - Spacer(modifier = Modifier.height(4.dp)) - } - item { - Text( - text = stringResource(R.string.section_files), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - items(filteredDirectBooks, key = { it.bookId }) { item -> - LibraryListItem( - item = item, - isSelected = selectedItems.any { it.bookId == item.bookId }, - onItemClick = { onBookClick(item) }, - onItemLongClick = { onBookLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds, - usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName - ) - } - } - } - } - ) -} - -@Composable -private fun AddBooksModeScreen( - shelfName: String, - availableBooks: List, - selectedBookUris: Set, - currentSource: AddBooksSource, - sortOrder: SortOrder, - onSortOrderChange: (SortOrder) -> Unit, - onSourceChange: (AddBooksSource) -> Unit, - onBookClick: (RecentFileItem) -> Unit, - onBack: () -> Unit, - onAddSelectedBooks: () -> Unit, - downloadingBookIds: Set, - usePdfFileNameAsDisplayName: Boolean, -) { - var showSortMenu by remember { mutableStateOf(false) } - - Scaffold( - modifier = Modifier, - topBar = { - Column { - CustomTopAppBar( - title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) - } - }, - actions = { - Box { - TextButton( - onClick = { showSortMenu = true }, - modifier = Modifier.testTag("AddBooksSortButton") - ) { - Icon( - painter = painterResource(id = R.drawable.sort), - contentDescription = stringResource(R.string.content_desc_sort), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(sortOrder.labelRes)) - } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false } - ) { - SortOrder.entries.forEach { order -> - DropdownMenuItem( - text = { Text(stringResource(order.labelRes)) }, - onClick = { - onSortOrderChange(order) - showSortMenu = false - }, - trailingIcon = { - if (order == sortOrder) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) - } - } - ) - } - } - } - } - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - AddBooksSource.entries.forEach { source -> - FilterChip( - selected = source == currentSource, - onClick = { onSourceChange(source) }, - label = { Text(stringResource(source.labelRes)) } - ) - } - } - } - }, - floatingActionButton = { - if (selectedBookUris.isNotEmpty()) { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.fab_add_count, selectedBookUris.size)) }, - icon = { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.fab_add_books)) }, - onClick = onAddSelectedBooks - ) - } - }, - content = { paddingValues -> - if (availableBooks.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text( - text = if (currentSource == AddBooksSource.UNSHELVED) { - stringResource(R.string.no_unshelved_books) - } else { - stringResource(R.string.all_books_in_shelf) - }, - style = MaterialTheme.typography.bodyLarge - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(availableBooks, key = { it.bookId }) { item -> - val isSelected = item.bookId in selectedBookUris - LibraryListItem( - item = item, - isSelected = isSelected, - onItemClick = { onBookClick(item) }, - onItemLongClick = { onBookClick(item) }, - isDownloading = item.bookId in downloadingBookIds, - usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName - ) - } - } - } - } - ) -} - -@Composable -private fun ShelfCover(shelf: Shelf) { - val booksForCovers = shelf.books.take(4).reversed() - val coverWidth = 52.dp - val coverHeight = 75.dp - val horizontalOffset = 12.dp - val maxWidth = coverWidth + (horizontalOffset * (4 - 1)) - - Box( - modifier = Modifier - .width(maxWidth) - .height(coverHeight), - contentAlignment = Alignment.CenterStart - ) { - if (booksForCovers.size <= 1) { - val topBook = shelf.topBook - if (topBook != null) { - ThemedBookCover( - item = topBook, - contentDescription = stringResource(R.string.content_desc_shelf_cover, shelf.name), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(width = coverWidth, height = coverHeight) - .clip(MaterialTheme.shapes.small) - ) - } else { - EmptyShelfCover( - shelfName = shelf.name, - modifier = Modifier - .size(width = coverWidth, height = coverHeight) - .clip(MaterialTheme.shapes.small) - ) - } - } else { - Box( - modifier = Modifier - .width(coverWidth + (horizontalOffset * (booksForCovers.size - 1))) - .height(coverHeight) - ) { - booksForCovers.forEachIndexed { index, book -> - Surface( - shape = MaterialTheme.shapes.small, - shadowElevation = 4.dp, - modifier = Modifier - .size(width = coverWidth, height = coverHeight) - .align(Alignment.CenterEnd) - .offset(x = -horizontalOffset * index) - ) { - ThemedBookCover( - item = book, - contentDescription = null, - contentScale = ContentScale.Crop - ) - } - } - } - } - } -} - -@Composable -private fun EmptyShelfCover( - shelfName: String, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .background( - androidx.compose.ui.graphics.Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) - ) - .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center - ) { - Text( - text = shelfName.takeIf { it.isNotBlank() } ?: stringResource(R.string.tab_shelves), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(8.dp) - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ShelfListItem( - shelf: Shelf, - isSelected: Boolean, - onItemClick: () -> Unit, - onItemLongClick: () -> Unit, - showHierarchyIndent: Boolean = true, -) { - val folderIndent = if (showHierarchyIndent && shelf.type == ShelfType.FOLDER) (shelf.depth * 14).dp else 0.dp - - androidx.compose.material3.ElevatedCard( - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( - defaultElevation = if (isSelected) 4.dp else 1.dp - ), - modifier = Modifier - .fillMaxWidth() - .testTag("ShelfItem_${shelf.id}") - .then( - if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) - else Modifier - ) - .clip(MaterialTheme.shapes.large) - .combinedClickable( - onClick = onItemClick, - onLongClick = { - if (shelf.name != "Unshelved") { - onItemLongClick() - } - } - ) - ) { - Row( - modifier = Modifier.padding(start = 12.dp + folderIndent, end = 12.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ShelfCover(shelf = shelf) - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val icon = when (shelf.type) { - ShelfType.SMART -> Icons.Default.Star - ShelfType.TAG -> Icons.AutoMirrored.Filled.LibraryBooks - ShelfType.FOLDER -> Icons.Default.Folder - ShelfType.SERIES -> Icons.AutoMirrored.Filled.LibraryBooks - ShelfType.MANUAL -> Icons.AutoMirrored.Filled.List - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = shelf.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = getBookCountString(shelf.bookCount), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun LibraryListItem( - item: RecentFileItem, - isSelected: Boolean, - isPinned: Boolean = false, - onItemClick: () -> Unit, - onItemLongClick: () -> Unit, - isDownloading: Boolean, - usePdfFileNameAsDisplayName: Boolean = false, -) { - androidx.compose.material3.ElevatedCard( - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation( - defaultElevation = if (isSelected) 4.dp else 1.dp - ), - modifier = Modifier - .fillMaxWidth() - .testTag("LibraryBookItem_${item.bookId}") - .graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f } - .then( - if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large) - else Modifier - ) - .clip(MaterialTheme.shapes.large) - .combinedClickable( - onClick = onItemClick, - onLongClick = onItemLongClick - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp) - .height(132.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(0.7f) - .clip(MaterialTheme.shapes.medium) - .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium) - ) { - ThemedBookCover( - item = item, - contentDescription = item.displayName, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - if (isSelected) { - Box( - modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), modifier = Modifier.size(36.dp).background(MaterialTheme.colorScheme.primary, CircleShape).padding(6.dp), tint = MaterialTheme.colorScheme.onPrimary) - } - } - } - - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.cardTitle(usePdfFileNameAsDisplayName), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - minLines = 2, - overflow = TextOverflow.Ellipsis, - lineHeight = 20.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = item.cardAuthor(), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - minLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) { - FileStatusBadges( - item = item, - isPinned = isPinned - ) - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FileTypeBadge(type = item.type, overlay = false) - - if (item.tags.isNotEmpty()) { - BookTagChipsRow( - tags = item.tags, - compact = true, - modifier = Modifier.weight(1f, fill = false) - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - - if (!item.isAvailable) { - Surface( - shape = RoundedCornerShape(50), - color = if (isDownloading) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.errorContainer - }, - contentColor = if (isDownloading) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onErrorContainer - } - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (isDownloading) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.Filled.Info, - contentDescription = stringResource(R.string.not_available_locally), - modifier = Modifier.size(14.dp) - ) - } - Text( - text = if (isDownloading) { - stringResource(R.string.status_downloading) - } else { - stringResource(R.string.not_available_locally) - }, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) - } - } - } - } - - Spacer(modifier = Modifier.weight(1f)) - - ReadingProgressSection( - progressPercentage = item.progressPercentage, - compact = true, - modifier = Modifier.fillMaxWidth() - ) - } - } - } -} - -@Composable -private fun RenameShelfDialog( - initialName: String, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit -) { - var textFieldValue by remember { - mutableStateOf( - TextFieldValue( - text = initialName, - selection = TextRange(initialName.length) - ) - ) - } - val focusRequester = remember { FocusRequester() } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.menu_rename_shelf)) }, - text = { - OutlinedTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - placeholder = { Text(stringResource(R.string.shelf_name_hint)) }, - singleLine = true, - modifier = Modifier.focusRequester(focusRequester) - ) - }, - confirmButton = { - TextButton( - onClick = { onConfirm(textFieldValue.text) }, - enabled = textFieldValue.text.isNotBlank() && textFieldValue.text != initialName - ) { - Text(stringResource(R.string.action_rename)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - - LaunchedEffect(Unit) { - delay(100) - focusRequester.requestFocus() - } -} - -@Composable -private fun DeleteShelfConfirmationDialog( - shelfName: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_delete_shelf)) }, - text = { Text(stringResource(R.string.dialog_delete_shelf_desc, shelfName)) }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -@Composable -private fun RemoveFromShelfConfirmationDialog( - count: Int, - shelfName: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_remove_from_shelf)) }, - text = { Text(pluralStringResource(R.plurals.dialog_remove_from_shelf_desc, count, count, shelfName)) }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_remove)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -@Composable -private fun DeleteShelvesConfirmationDialog( - count: Int, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - val shelfStr = pluralStringResource(id = R.plurals.shelf_count, count) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_delete_shelves, shelfStr)) }, - text = { Text(stringResource(R.string.dialog_delete_shelves_desc, count, shelfStr)) }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun LibraryFilterSheet( - filters: LibraryFilters, - allTags: List, - syncedFolders: List, - onApply: (LibraryFilters) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var currentFilters by remember { mutableStateOf(filters) } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text(stringResource(R.string.filter_library), style = MaterialTheme.typography.titleLarge) - - Text(stringResource(R.string.filter_file_type), style = MaterialTheme.typography.titleMedium) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ANDROID_READABLE_FILE_TYPES.forEach { type -> - FilterChip( - selected = type in currentFilters.fileTypes, - onClick = { - val newSet = if (type in currentFilters.fileTypes) currentFilters.fileTypes - type else currentFilters.fileTypes + type - currentFilters = currentFilters.copy(fileTypes = newSet) - }, - label = { Text(type.name) } - ) - } - } - - Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = "IN_APP_STORAGE" in currentFilters.sourceFolders, - onClick = { - val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE" - currentFilters = currentFilters.copy(sourceFolders = newSet) - }, - label = { Text(stringResource(R.string.filter_in_app_storage)) } - ) - syncedFolders.forEach { folder -> - FilterChip( - selected = folder.uriString in currentFilters.sourceFolders, - onClick = { - val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString - currentFilters = currentFilters.copy(sourceFolders = newSet) - }, - label = { Text(folder.name) } - ) - } - } - - Text(stringResource(R.string.filter_read_status), style = MaterialTheme.typography.titleMedium) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ReadStatusFilter.entries.forEach { status -> - FilterChip( - selected = currentFilters.readStatus == status, - onClick = { currentFilters = currentFilters.copy(readStatus = status) }, - label = { Text(stringResource(status.labelRes)) } - ) - } - } - - if (allTags.isNotEmpty()) { - Text(stringResource(R.string.section_tags), style = MaterialTheme.typography.titleMedium) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - allTags.forEach { tag -> - val selected = tag.id in currentFilters.tagIds - FilterChip( - selected = selected, - onClick = { - val newSet = if (selected) { - currentFilters.tagIds - tag.id - } else { - currentFilters.tagIds + tag.id - } - currentFilters = currentFilters.copy(tagIds = newSet) - }, - label = { Text(tag.name) }, - leadingIcon = { - Box( - modifier = Modifier - .size(10.dp) - .background( - Color(tag.color ?: 0xFF64B5F6.toInt()), - CircleShape - ) - ) - } - ) - } - } - } - - Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { currentFilters = LibraryFilters() }) { - Text(stringResource(R.string.clear_all)) - } - Spacer(modifier = Modifier.width(8.dp)) - androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) { - Text(stringResource(R.string.action_apply)) - } - } - Spacer(modifier = Modifier.height(32.dp)) - } - } -} - diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt deleted file mode 100644 index fda939f..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/MainScreen.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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 - */ -// MainScreen.kt -package org.dueattendant149.bookreader - -import androidx.activity.ComponentActivity -import androidx.activity.enableEdgeToEdge -import androidx.annotation.OptIn -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.util.UnstableApi -import androidx.navigation.NavHostController - -sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) { - object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home) - object Library : BottomBarScreen("library", R.string.nav_library, R.drawable.library_books) -} - -private val bottomBarItems = listOf( - BottomBarScreen.Home, - BottomBarScreen.Library, -) - -@OptIn(UnstableApi::class) -@Composable -fun MainScreen( - viewModel: MainViewModel, - windowSizeClass: WindowSizeClass, - navController: NavHostController -) { - val context = LocalContext.current - - SideEffect { - val activity = context as? ComponentActivity - activity?.enableEdgeToEdge() - } - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val viewingShelfName = uiState.viewingShelfId - - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) { - if (viewingShelfName != null) { - ShelfScreen(viewModel = viewModel) - } else { - val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex) - - Scaffold( - contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), - bottomBar = { - NavigationBar { - bottomBarItems.forEachIndexed { index, screen -> - NavigationBarItem( - icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) }, - label = { Text(stringResource(screen.stringResId)) }, - selected = selectedPage == index, - onClick = { - ReaderPerfLog.d("MainPager click page=$index route=${screen.route}") - if (selectedPage != index) { - val animStart = ReaderPerfLog.nowNanos() - viewModel.setMainScreenPage(index) - ReaderPerfLog.d( - "MainPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(animStart)}ms" - ) - } - } - ) - } - } - } - ) { innerPadding -> - androidx.compose.foundation.layout.Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - when (selectedPage) { - 0 -> HomeScreen( - viewModel = viewModel, - windowSizeClass = windowSizeClass, - navController = navController - ) - 1 -> LibraryScreen( - viewModel = viewModel, - navController = navController - ) - } - } - } - } - - if (uiState.showTagSelectionDialogFor.isNotEmpty()) { - TagSelectionBottomSheet( - allTags = uiState.allTags, - selectedBookIds = uiState.showTagSelectionDialogFor, - booksWithTags = uiState.rawLibraryFiles, - onCreateAndAssign = { name -> - viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor) - }, - onToggleTag = { tagId, assign -> - viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign) - }, - onDismiss = viewModel::closeTagSelection - ) - } - } -} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt deleted file mode 100644 index fd8d1b5..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/ProScreen.kt +++ /dev/null @@ -1,747 +0,0 @@ -/* - * 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 - */ -package org.dueattendant149.bookreader - -import android.app.Activity -import timber.log.Timber -import android.widget.Toast -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -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.Info -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookreader.data.ProductDetailsEntity -import kotlinx.coroutines.launch -import java.text.NumberFormat -import java.util.Currency - -@Suppress("KotlinConstantConditions") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun ProScreen( - viewModel: MainViewModel, - onNavigateBack: () -> Unit -) { - val context = LocalContext.current - val proUpgradeState by viewModel.proUpgradeState.collectAsState() - val uiState by viewModel.uiState.collectAsState() - var showExistingPurchaseDialog by remember { mutableStateOf(false) } - var showSignInRequiredDialog by remember { mutableStateOf(false) } - - // Removed Free Tab, so tabCount is max 2 - val tabCount = if (BuildConfig.FLAVOR == "pro") 2 else 1 - val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabCount }) - var selectedTabIndex by remember { mutableIntStateOf(0) } - val scope = rememberCoroutineScope() - - LaunchedEffect(pagerState.currentPage) { - selectedTabIndex = pagerState.currentPage - } - - LaunchedEffect(selectedTabIndex) { - scope.launch { - pagerState.animateScrollToPage(selectedTabIndex) - } - } - - // Default to the Credits tab if they already own Pro - LaunchedEffect(uiState.isProUser) { - if (uiState.isProUser && BuildConfig.FLAVOR == "pro") { - selectedTabIndex = 1 - } - } - - LaunchedEffect(proUpgradeState.error) { - proUpgradeState.error?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - viewModel.clearBillingError() - } - } - - if (showExistingPurchaseDialog) { - ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false }) - } - - if (showSignInRequiredDialog) { - SignInRequiredDialog( - onSignInClick = { - scope.launch { - context.findActivity()?.let { activity -> - viewModel.signIn(activity) - } - } - showSignInRequiredDialog = false - }, - onDismiss = { showSignInRequiredDialog = false } - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding(), start = 16.dp, end = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Spacer(modifier = Modifier.height(16.dp)) - - // Tabs - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraLarge) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - indicator = {}, - divider = {} - ) { - Tab( - selected = selectedTabIndex == 0, - onClick = { selectedTabIndex = 0 }, - modifier = Modifier - .height(56.dp) - .clip(CircleShape) - .background( - if (selectedTabIndex == 0) MaterialTheme.colorScheme.surface else Color.Transparent - ) - .border( - width = if (selectedTabIndex == 0) 2.dp else 0.dp, - color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent, - shape = CircleShape - ), - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.crown), - contentDescription = stringResource(R.string.drawer_pro_unlocked), - modifier = Modifier.size(16.dp), - tint = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(4.dp)) - AutoSizeText(stringResource(R.string.drawer_pro_unlocked), - style = LocalTextStyle.current.copy( - color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold - ) - ) - } - }, - selectedContentColor = MaterialTheme.colorScheme.primary, - unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (BuildConfig.FLAVOR == "pro") { - Tab( - selected = selectedTabIndex == 1, - onClick = { selectedTabIndex = 1 }, - modifier = Modifier - .height(56.dp) - .clip(CircleShape) - .background(if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent) - .border( - width = if (selectedTabIndex == 1) 2.dp else 0.dp, - color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent, - shape = CircleShape - ), - text = { - AutoSizeText(stringResource(R.string.credits_tab), - style = LocalTextStyle.current.copy( - color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold - ) - ) - }, - selectedContentColor = MaterialTheme.colorScheme.primary, - unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - userScrollEnabled = true - ) { page -> - when (page) { - 0 -> { - ProTierCard( - isProUser = uiState.isProUser, - isUserSignedIn = uiState.currentUser != null, - proUpgradeState = proUpgradeState, - onUpgradeClick = { - (context as? Activity)?.let { - viewModel.launchPurchaseFlow(it) - } - }, - onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true }, - onSignInRequiredClick = { showSignInRequiredDialog = true }) - } - - 1 -> { - if (BuildConfig.FLAVOR == "pro") { - CreditTierCard( - credits = uiState.credits, - creditProducts = proUpgradeState.creditProducts, - isVerifying = proUpgradeState.isVerifying, - isUserSignedIn = uiState.currentUser != null, - onSignInRequiredClick = { showSignInRequiredDialog = true }, - onBuyCredits = { productId -> - (context as? Activity)?.let { viewModel.launchPurchaseFlow(it, productId) } - }) - } - } - } - } - } - } -} - -@Composable -private fun ProTierCard( - isProUser: Boolean, - isUserSignedIn: Boolean, - proUpgradeState: ProUpgradeState, - onUpgradeClick: () -> Unit, - onShowExistingPurchaseDialog: () -> Unit, - onSignInRequiredClick: () -> Unit -) { - val productDetails = proUpgradeState.productDetails - val billingClientReady = proUpgradeState.billingClientReady - val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasAccountConflict - - var originalFormattedPrice by remember { mutableStateOf("$9.99") } - - LaunchedEffect(productDetails) { - productDetails?.let { details -> - val priceAmountMicros = details.priceAmountMicros - val priceCurrencyCode = details.currencyCode - - val originalPriceMicros = priceAmountMicros * 2 - - val currencyFormatter = NumberFormat.getCurrencyInstance().apply { - try { - currency = Currency.getInstance(priceCurrencyCode) - } catch (e: IllegalArgumentException) { - Timber.e(e, "Invalid currency code: $priceCurrencyCode") - } - } - originalFormattedPrice = currencyFormatter.format(originalPriceMicros / 1_000_000.0) - } - } - - Card( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.crown), - contentDescription = stringResource(R.string.drawer_pro_unlocked), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.drawer_pro_unlocked), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - } - Spacer(modifier = Modifier.height(8.dp)) - - if (!isProUser) { - val formattedPrice = productDetails?.formattedPrice - - if (formattedPrice != null) { - Text( - text = buildAnnotatedString { - withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(originalFormattedPrice) - } - append(" " + stringResource(R.string.pro_sale_off)) }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formattedPrice, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp), - fontWeight = FontWeight.Bold - ) - } else { - Text(stringResource(R.string.loading_price), - style = MaterialTheme.typography.displaySmall.copy(fontSize = 32.sp), - fontWeight = FontWeight.Bold - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.one_time_payment), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) { - Text(stringResource(R.string.lifetime_access), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) - } - - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - Text(stringResource(R.string.pro_includes), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.cloud_sync, text = stringResource(R.string.feature_cloud_sync)) - Text(stringResource(R.string.feature_cloud_sync_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.summarize, text = stringResource(R.string.feature_summarize)) - Text(stringResource(R.string.feature_summarize_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.dictionary, text = stringResource(R.string.feature_smart_dict)) - Text(stringResource(R.string.feature_smart_dict_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.chat_bubble, text = stringResource(R.string.feature_priority)) - Text(stringResource(R.string.feature_priority_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - - if (isProUser) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background( - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), - MaterialTheme.shapes.medium - ) - ) { - Icon( - painter = painterResource(id = R.drawable.crown), - contentDescription = stringResource(R.string.pro_unlocked), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.pro_unlocked), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - } - } else { - when { - !isUserSignedIn -> { - Button( - onClick = onSignInRequiredClick, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.medium, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - AutoSizeText(stringResource(R.string.sign_in_required), style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold)) - } - } - proUpgradeState.isVerifying -> { - Button( - onClick = {}, - enabled = false, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.medium, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = LocalContentColor.current - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.verifying_purchase)) - } - } - productDetails != null -> { - Button( - onClick = { - Timber.d("Upgrade button clicked.") - onUpgradeClick() - }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.medium, - enabled = billingClientReady - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.crown), - contentDescription = stringResource(R.string.drawer_pro_unlocked), - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - AutoSizeText(stringResource(R.string.get_lifetime_access), style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold)) - } - } - } - !billingClientReady -> { - Box(modifier = Modifier.height(48.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - else -> { - Text(stringResource(R.string.upgrade_unavailable), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.height(48.dp) - ) - } - } - - // Text area below button - Spacer(modifier = Modifier.height(16.dp)) // Increased spacing - when { - !isUserSignedIn -> { - Text(stringResource(R.string.sign_in_to_purchase), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - proUpgradeState.isVerifying -> { - Text(stringResource(R.string.verifying_purchase_desc), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - } - localPurchaseExistsForOtherAccount -> { - TextButton(onClick = onShowExistingPurchaseDialog) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.info), - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - AutoSizeText(stringResource(R.string.existing_purchase_found)) - } - } - else -> { - LegalText(prefixText = stringResource(R.string.legal_by_purchasing)) - } - } - } - } - } -} - -@Composable -private fun FeatureListItem(@androidx.annotation.DrawableRes iconRes: Int? = null, icon: ImageVector? = null, text: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (iconRes != null) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } else if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Text(text = text, style = MaterialTheme.typography.bodyLarge) - } -} - -@Composable -fun ExistingPurchaseDialog(onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.Info, contentDescription = null) }, - title = { Text(stringResource(R.string.existing_purchase_found)) }, - text = { Text(stringResource(R.string.dialog_existing_purchase_desc)) }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_ok)) } - } - ) -} - -@Composable -fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(painter = painterResource(id = R.drawable.crown), contentDescription = null) }, - title = { Text(stringResource(R.string.sign_in_required)) }, - text = { Text(stringResource(R.string.dialog_sign_in_required_desc)) }, - confirmButton = { - TextButton(onClick = onSignInClick) { Text(stringResource(R.string.drawer_sign_in)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_not_now)) } - } - ) -} - -@Composable -private fun CreditTierCard( - credits: Int, - creditProducts: List, - isVerifying: Boolean, - isUserSignedIn: Boolean, - onSignInRequiredClick: () -> Unit, - onBuyCredits: (String) -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.credits_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "$credits", - style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Text(stringResource(R.string.credits_available), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - - Spacer(modifier = Modifier.height(24.dp)) - - if (isVerifying) { - CircularProgressIndicator(modifier = Modifier.padding(16.dp)) - Text(stringResource(R.string.verifying_purchase), style = MaterialTheme.typography.bodySmall) - } else if (creditProducts.isEmpty()) { - Text(stringResource(R.string.loading_price), modifier = Modifier.padding(16.dp)) - } else { - creditProducts.forEach { product -> - OutlinedCard( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - onClick = { - if (isUserSignedIn) onBuyCredits(product.productId) - else onSignInRequiredClick() - }, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { - Text(product.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) - if (product.description.isNotBlank()) { - Text(product.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - Button( - onClick = { - if (isUserSignedIn) onBuyCredits(product.productId) - else onSignInRequiredClick() - }, - modifier = Modifier.wrapContentWidth() - ) { - Text(product.formattedPrice) - } - } - } - } - } - - if (!isUserSignedIn) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - stringResource(R.string.sign_in_to_purchase_credits), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) - Spacer(modifier = Modifier.height(16.dp)) - - Text( - stringResource(R.string.credits_estimated_cost_breakdown), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.Start) - ) - Spacer(modifier = Modifier.height(16.dp)) - - CostBreakdownItem( - iconRes = R.drawable.text_to_speech, - title = stringResource(R.string.credits_cloud_tts_title), - description = stringResource(R.string.credits_cloud_tts_desc) - ) - CostBreakdownItem( - iconRes = R.drawable.summarize, - title = stringResource(R.string.credits_ai_summaries_title), - description = stringResource(R.string.credits_ai_summaries_desc) - ) - Spacer(modifier = Modifier.height(24.dp)) - } - } -} - -@Composable -private fun CostBreakdownItem( - @androidx.annotation.DrawableRes iconRes: Int, - title: String, - description: String -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp).padding(top = 2.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text(text = title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 18.sp - ) - } - } -} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt deleted file mode 100644 index 8aa395c..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/SettingsScreen.kt +++ /dev/null @@ -1,549 +0,0 @@ -package org.dueattendant149.bookreader - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import android.content.Context -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.util.UnstableApi -import androidx.navigation.NavHostController -import org.dueattendant149.bookreader.data.CustomFontEntity -import org.dueattendant149.bookreader.epubreader.FormatSettings as AndroidFormatSettings -import org.dueattendant149.bookreader.epubreader.ReaderFont as AndroidReaderFont -import org.dueattendant149.bookreader.epubreader.ReaderTextAlign as AndroidReaderTextAlign -import org.dueattendant149.bookreader.epubreader.loadFormatSettings -import org.dueattendant149.bookreader.epubreader.loadPageInfoMode -import org.dueattendant149.bookreader.epubreader.loadPageInfoPosition -import org.dueattendant149.bookreader.epubreader.loadPullToTurn -import org.dueattendant149.bookreader.epubreader.loadPullToTurnMultiplier -import org.dueattendant149.bookreader.epubreader.loadSystemUiMode -import org.dueattendant149.bookreader.epubreader.savePageInfoMode -import org.dueattendant149.bookreader.epubreader.savePageInfoPosition -import org.dueattendant149.bookreader.epubreader.savePullToTurn -import org.dueattendant149.bookreader.epubreader.savePullToTurnMultiplier -import org.dueattendant149.bookreader.epubreader.saveReaderSettings -import org.dueattendant149.bookreader.epubreader.saveSystemUiMode -import org.dueattendant149.bookreader.pdf.savePdfSystemUiMode -import org.dueattendant149.bookreader.pdf.savePdfThemeId -import org.dueattendant149.bookreader.pdf.savePdfVerticalPageGapVisible -import org.dueattendant149.bookreader.pdf.savePdfPageNumberOverlayVisible -import org.dueattendant149.bookreader.pdf.loadPdfSystemUiMode -import org.dueattendant149.bookreader.pdf.loadPdfThemeId -import org.dueattendant149.bookreader.pdf.loadPdfVerticalPageGapVisible -import org.dueattendant149.bookreader.pdf.loadPdfPageNumberOverlayVisible -import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.SharedSettingsAction -import org.dueattendant149.bookreader.shared.SharedSettingsDestination -import org.dueattendant149.bookreader.shared.parentDestination -import org.dueattendant149.bookreader.shared.toReaderSettingsFontFamily -import org.dueattendant149.bookreader.shared.toSharedReaderTextAlign -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign -import org.dueattendant149.bookreader.shared.readerThemeById -import org.dueattendant149.bookreader.shared.sharedSettingsHubModel -import org.dueattendant149.bookreader.shared.toReaderSettings -import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub -import org.dueattendant149.bookreader.tts.loadTtsMode -import kotlinx.coroutines.launch -import kotlin.math.max -import kotlin.math.roundToInt - -private const val ANDROID_SETTINGS_GLOBAL_BOOK_ID = "__global_reader_defaults__" - -@androidx.annotation.OptIn(UnstableApi::class) -@kotlin.OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen( - viewModel: MainViewModel, - navController: NavHostController, - onBackClick: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() - val ttsState by viewModel.ttsController.ttsState.collectAsStateWithLifecycle() - - var query by remember { mutableStateOf("") } - var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } - var showAppThemePanel by remember { mutableStateOf(false) } - var showBehaviorDialog by remember { mutableStateOf(false) } - var showStrictFilterDialog by remember { mutableStateOf(false) } - var showClearBookCacheDialog by remember { mutableStateOf(false) } - var showClearReflowCacheDialog by remember { mutableStateOf(false) } - var showClearAllDataDialog by remember { mutableStateOf(false) } - var showLanguageDialog by remember { mutableStateOf(false) } - var showAboutDialog by remember { mutableStateOf(false) } - var showSignOutConfirmDialog by remember { mutableStateOf(false) } - var showUpgradeDialog by remember { mutableStateOf(false) } - var showRecentLimitDialog by remember { mutableStateOf(false) } - var showTtsSettingsSheet by remember { mutableStateOf(false) } - var hideReaderAi by remember { mutableStateOf(loadHideReaderAiFeatures(context)) } - var epubReaderDefaults by remember(context, uiState.renderMode) { - mutableStateOf(loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode)) - } - var pdfReaderDefaults by remember(context) { - mutableStateOf(loadAndroidPdfReaderDefaultSettings(context)) - } - var ttsReplacementPreferences by remember(context) { - mutableStateOf(loadTtsReplacementPreferences(context)) - } - var ttsMode by remember(context) { mutableStateOf(loadTtsMode(context)) } - - LaunchedEffect(uiState.renderMode) { - epubReaderDefaults = loadAndroidEpubReaderDefaultSettings(context, uiState.renderMode) - } - - val sharedFonts = remember(customFonts) { - customFonts.toSharedCustomFontItems() - } - - val settingsModel = sharedSettingsHubModel( - androidSettingsHubInput( - uiState = uiState, - hideReaderAi = hideReaderAi - ) - ) - val settingsPage = settingsModel.page(settingsDestination) - - fun navigateBackFromSettings() { - if (query.isNotBlank()) { - query = "" - return - } - val parent = settingsDestination.parentDestination() - if (parent != null) { - settingsDestination = parent - } else { - onBackClick() - } - } - - BackHandler(enabled = query.isNotBlank() || settingsDestination != SharedSettingsDestination.ROOT) { - navigateBackFromSettings() - } - - Scaffold( - modifier = modifier, - topBar = { - CustomTopAppBar( - title = { Text(settingsPage.title) }, - navigationIcon = { - IconButton(onClick = ::navigateBackFromSettings) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) - } - } - ) - }, - contentWindowInsets = WindowInsets.navigationBars - ) { padding -> - SharedSettingsHub( - model = settingsModel, - query = query, - onQueryChange = { query = it }, - readerDefaultSettings = epubReaderDefaults, - onReaderDefaultSettingsChange = { settings -> - epubReaderDefaults = settings - saveAndroidEpubReaderDefaultSettings(context, settings) - viewModel.setRenderMode(settings.toAndroidRenderMode()) - }, - pdfReaderDefaultSettings = pdfReaderDefaults, - onPdfReaderDefaultSettingsChange = { settings -> - pdfReaderDefaults = settings - saveAndroidPdfReaderDefaultSettings(context, settings) - }, - ttsReplacementPreferences = ttsReplacementPreferences, - onTtsReplacementPreferencesChange = { preferences -> - ttsReplacementPreferences = preferences - saveTtsReplacementPreferences(context, preferences) - }, - customFonts = sharedFonts, - showTopBar = false, - destination = settingsDestination, - onDestinationChange = { settingsDestination = it }, - contentPadding = padding, - modifier = Modifier.fillMaxSize(), - onAction = { action -> - when (action) { - SharedSettingsAction.APP_THEME -> showAppThemePanel = true - SharedSettingsAction.LANGUAGE -> showLanguageDialog = true - SharedSettingsAction.TABS_TOGGLE -> viewModel.setTabsEnabled(!uiState.isTabsEnabled) - SharedSettingsAction.RECENT_LIMIT -> showRecentLimitDialog = true - SharedSettingsAction.STRICT_FILE_FILTER -> { - if (uiState.useStrictFileFilter) { - viewModel.setStrictFileFilter(false) - } else { - showStrictFilterDialog = true - } - } - SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true - SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME -> { - viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName) - } - SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> { - val next = !uiState.isScreenCaptureProtectionEnabled - viewModel.setScreenCaptureProtectionEnabled(next) - val messageRes = if (next) { - R.string.banner_screen_capture_protection_on - } else { - R.string.banner_screen_capture_protection_off - } - viewModel.showBanner(context.getString(messageRes)) - } - SharedSettingsAction.CUSTOM_FONTS -> navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE) - SharedSettingsAction.SIGN_IN -> { - scope.launch { - context.findActivity()?.let { activity -> viewModel.signIn(activity) } - } - } - SharedSettingsAction.SIGN_OUT -> showSignOutConfirmDialog = true - SharedSettingsAction.CLOUD_SYNC -> { - if (uiState.isProUser) { - viewModel.setSyncEnabled(!uiState.isSyncEnabled) - } else { - showUpgradeDialog = true - } - } - SharedSettingsAction.FOLDER_SYNC -> viewModel.setFolderSyncEnabled(!uiState.isFolderSyncEnabled) - SharedSettingsAction.DEVICE_MANAGEMENT -> viewModel.showDeviceManagementForDebug() - SharedSettingsAction.AI_SETTINGS -> navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) - SharedSettingsAction.HIDE_READER_AI -> { - val nextHidden = !hideReaderAi - saveHideReaderAiFeatures(context, nextHidden) - hideReaderAi = nextHidden - } - SharedSettingsAction.TTS_SETTINGS -> showTtsSettingsSheet = true - SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true - SharedSettingsAction.CLEAR_REFLOW_CACHE -> showClearReflowCacheDialog = true - SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> showClearAllDataDialog = true - SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context) - SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context) - SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context) - SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner(context.getString(R.string.debug_actions_existing_menus)) - SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE) - SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) - SharedSettingsAction.ABOUT -> showAboutDialog = true - SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner(context.getString(R.string.pdf_specific_settings_existing_reader)) - SharedSettingsAction.TEXT_READER_DEFAULTS, - SharedSettingsAction.READER_TOOLBAR, - SharedSettingsAction.TTS_REPLACEMENTS, - SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit - } - } - ) - } - - if (showRecentLimitDialog) { - RecentLimitDialog( - currentLimit = uiState.recentFilesLimit, - onSelect = { limit -> - viewModel.setRecentFilesLimit(limit) - showRecentLimitDialog = false - }, - onDismiss = { showRecentLimitDialog = false } - ) - } - - if (showBehaviorDialog) { - ExternalFileBehaviorDialog( - currentBehavior = uiState.externalFileBehavior, - onDismiss = { showBehaviorDialog = false }, - onSelect = { viewModel.setExternalFileBehavior(it) } - ) - } - - if (showStrictFilterDialog) { - StrictFilterConfirmationDialog( - onConfirm = { - viewModel.setStrictFileFilter(true) - showStrictFilterDialog = false - }, - onDismiss = { showStrictFilterDialog = false } - ) - } - - if (showClearBookCacheDialog) { - DangerousFolderActionDialog( - title = context.getString(R.string.dialog_clear_book_cache), - message = context.getString(R.string.dialog_clear_book_cache_desc), - onConfirm = { - viewModel.clearBookCache() - showClearBookCacheDialog = false - }, - onDismiss = { showClearBookCacheDialog = false } - ) - } - - if (showClearReflowCacheDialog) { - DangerousFolderActionDialog( - title = context.getString(R.string.dialog_clear_reflow_cache), - message = context.getString(R.string.dialog_clear_reflow_cache_desc), - onConfirm = { - viewModel.clearReflowCache() - showClearReflowCacheDialog = false - }, - onDismiss = { showClearReflowCacheDialog = false } - ) - } - - if (showClearAllDataDialog) { - ClearAllDataConfirmationDialog( - onConfirm = { - viewModel.deleteAllCloudAndLocalData() - showClearAllDataDialog = false - }, - onDismiss = { showClearAllDataDialog = false } - ) - } - - if (showLanguageDialog) { - LanguageSelectionDialog(onDismiss = { showLanguageDialog = false }) - } - - if (showAppThemePanel) { - AppThemeBottomSheet( - uiState = uiState, - onThemeModeChanged = viewModel::setAppThemeMode, - onContrastOptionChanged = viewModel::setAppContrastOption, - onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight, - onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark, - onSeedColorChanged = viewModel::setAppSeedColor, - onCustomThemeAdded = viewModel::addCustomAppTheme, - onCustomThemeDeleted = viewModel::deleteCustomAppTheme, - onDismiss = { showAppThemePanel = false } - ) - } - - if (showAboutDialog) { - AboutDialog(onDismiss = { showAboutDialog = false }) - } - - if (showSignOutConfirmDialog) { - SignOutConfirmationDialog( - onConfirm = { - viewModel.signOut() - showSignOutConfirmDialog = false - }, - onDismiss = { showSignOutConfirmDialog = false } - ) - } - - if (showUpgradeDialog) { - UpgradeDialog( - onConfirm = { - showUpgradeDialog = false - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) - }, - onDismiss = { showUpgradeDialog = false } - ) - } - - if (showTtsSettingsSheet) { - TtsSettingsSheet( - isVisible = true, - onDismiss = { showTtsSettingsSheet = false }, - currentMode = ttsMode, - onModeChange = { mode -> - ttsMode = mode - viewModel.ttsController.changeTtsMode(mode.name) - }, - currentSpeakerId = ttsState.speakerId, - onSpeakerChange = viewModel.ttsController::changeSpeaker, - isTtsActive = ttsState.isPlaying, - getAuthToken = { viewModel.getAuthToken() }, - bookTitle = context.getString(R.string.reader_defaults) - ) - } - - if (uiState.deviceLimitState.isLimitReached) { - DeviceManagementScreen( - devices = uiState.deviceLimitState.registeredDevices, - onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) }, - isReplacing = uiState.isReplacingDevice - ) - } -} - -@Composable -private fun RecentLimitDialog( - currentLimit: Int, - onSelect: (Int) -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.options_recent_limit)) }, - text = { - androidx.compose.foundation.layout.Column { - listOf(0, 10, 20, 50, 100).forEach { limit -> - TextButton(onClick = { onSelect(limit) }) { - val label = if (limit == 0) { - stringResource(R.string.options_no_limit) - } else { - stringResource(R.string.options_files_limit, limit) - } - Text(if (currentLimit == limit) stringResource(R.string.option_selected_format, label) else label) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - } - ) -} - -private fun loadAndroidEpubReaderDefaultSettings( - context: Context, - renderMode: RenderMode -): ReaderSettings { - val format = loadFormatSettings(context, ANDROID_SETTINGS_GLOBAL_BOOK_ID, isLocal = false) - val horizontalMargin = (48f * format.horizontalMargin).roundToInt().coerceIn(0, 160) - val verticalMargin = (48f * format.verticalMargin).roundToInt().coerceIn(0, 160) - val base = ReaderSettings( - fontSize = (18f * format.fontSize).roundToInt().coerceIn(12, 42), - lineSpacing = (1.45f * format.lineHeight).coerceIn(1.0f, 2.8f), - margin = max(horizontalMargin, verticalMargin), - readingMode = renderMode.toSharedReaderReadingMode(), - textAlign = format.textAlign.toSharedReaderTextAlign(), - fontFamily = format.toSharedFontFamilyName(), - paragraphSpacing = format.paragraphGap.coerceIn(0.5f, 2.5f), - imageScale = format.imageSize.coerceIn(0.5f, 2.0f), - horizontalMargin = horizontalMargin, - verticalMargin = verticalMargin, - themeId = loadReaderThemeId(context), - textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), - customFontPath = format.customPath?.takeIf { it.isNotBlank() }, - systemUiMode = loadSystemUiMode(context), - pageInfoMode = loadPageInfoMode(context), - pageInfoPosition = loadPageInfoPosition(context), - seamlessChapterNavigation = loadPullToTurn(context), - chapterTurnDragMultiplier = loadPullToTurnMultiplier(context) - ) - return readerThemeById(base.themeId)?.toReaderSettings(base) ?: base -} - -private fun loadAndroidPdfReaderDefaultSettings( - context: Context -): ReaderSettings { - val base = ReaderSettings( - themeId = loadPdfThemeId(context), - textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), - systemUiMode = loadPdfSystemUiMode(context), - pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context), - pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context) - ) - return BuiltInPdfReaderThemes.firstOrNull { it.id == base.themeId }?.toReaderSettings(base) ?: base -} - -private fun saveAndroidEpubReaderDefaultSettings( - context: Context, - settings: ReaderSettings -) { - saveReaderSettings( - context = context, - fontSize = (settings.fontSize / 18f).coerceIn(0.65f, 2.4f), - lineHeight = (settings.lineSpacing / 1.45f).coerceIn(0.7f, 2.0f), - paragraphGap = settings.paragraphSpacing.coerceIn(0.5f, 2.5f), - imageSize = settings.imageScale.coerceIn(0.5f, 2.0f), - horizontalMargin = (settings.resolvedHorizontalMargin / 48f).coerceIn(0f, 3.4f), - verticalMargin = (settings.resolvedVerticalMargin / 48f).coerceIn(0f, 3.4f), - fontFamily = settings.toAndroidReaderFont(), - customFontPath = settings.customFontPath, - textAlign = settings.textAlign.toAndroidTextAlign() - ) - saveSystemUiMode(context, settings.systemUiMode) - savePageInfoMode(context, settings.pageInfoMode) - savePageInfoPosition(context, settings.pageInfoPosition) - savePullToTurn(context, settings.seamlessChapterNavigation) - savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier) - saveReaderThemeId(context, settings.themeId ?: "system") - saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f)) -} - -private fun saveAndroidPdfReaderDefaultSettings( - context: Context, - settings: ReaderSettings -) { - savePdfSystemUiMode(context, settings.systemUiMode) - savePdfThemeId(context, settings.themeId ?: "no_theme") - savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible) - savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible) - saveGlobalTextureTransparency(context, 1f - settings.textureAlpha.coerceIn(0f, 1f)) -} - -private fun List.toSharedCustomFontItems(): List { - return filterNot { it.isDeleted } - .sortedBy { it.displayName.lowercase() } - .map { font -> - CustomFontItem( - id = font.id, - displayName = font.displayName, - fileName = font.fileName, - fileExtension = font.fileExtension, - path = font.path, - timestamp = font.timestamp, - isDeleted = font.isDeleted - ) - } -} - -private fun AndroidFormatSettings.toSharedFontFamilyName(): String { - return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() } - ?: font.toReaderSettingsFontFamily() -} - -private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont { - return when (fontFamily) { - "Serif" -> AndroidReaderFont.LORA - "Sans" -> AndroidReaderFont.LATO - "Mono" -> AndroidReaderFont.ROBOTO_MONO - else -> AndroidReaderFont.ORIGINAL - } -} - -private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign { - return when (this) { - SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY - SharedReaderTextAlign.RIGHT -> AndroidReaderTextAlign.RIGHT - SharedReaderTextAlign.CENTER, - SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT - } -} - -private fun RenderMode.toSharedReaderReadingMode(): ReaderReadingMode { - return when (this) { - RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED - RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL - } -} - -private fun ReaderSettings.toAndroidRenderMode(): RenderMode { - return when (readingMode) { - ReaderReadingMode.PAGINATED -> RenderMode.PAGINATED - ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL - } -} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt index cfbfd58..5bab49c 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/epubreader/EpubReaderScreen.kt @@ -368,6 +368,18 @@ private fun loadHiddenTools(context: Context): Set { } return migratedHiddenTools } + + // If user hasn't customized tools yet, hide everything except the essentials: + // THEME, SLIDER, TOC, BOOKMARK, READING_MODE + if (savedHiddenTools.isEmpty()) { + val minimalHidden = ReaderTool.entries + .filter { it.name !in listOf("THEME", "SLIDER", "TOC", "BOOKMARK", "READING_MODE") } + .map { it.name } + .toSet() + prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, minimalHidden) } + return minimalHidden + } + return savedHiddenTools } diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt deleted file mode 100644 index aa8b896..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt +++ /dev/null @@ -1,222 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse -import timber.log.Timber - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BookshelfLibraryScreen( - viewModel: BookshelfViewModel, - onItemClick: (UnifiedItemResponse) -> Unit, - onOpenSettings: () -> Unit = {}, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - if (uiState.libraries.isEmpty()) { - viewModel.loadLibraries() - } - } - - LaunchedEffect(viewModel) { - viewModel.downloadedFile.collect { uri -> - Timber.d("Downloaded book to $uri, opening in reader") - } - } - - Scaffold( - topBar = { - androidx.compose.material3.CenterAlignedTopAppBar( - title = { Text("Server Library") }, - actions = { - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = "Server settings") - } - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - if (uiState.isLoading && uiState.libraries.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return@Scaffold - } - - uiState.error?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(16.dp) - ) - } - - val selected = uiState.selectedLibrary - if (selected == null) { - Text( - text = "Select a library", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - LazyColumn( - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(uiState.libraries) { library -> - LibraryCard( - library = library, - onClick = { viewModel.selectLibrary(library) } - ) - } - } - } else { - Text( - text = selected.name, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - if (uiState.isLoading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - if (uiState.downloadingItem != null) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - LazyColumn( - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxSize() - ) { - items(uiState.items) { item -> - ItemCard( - item = item, - isDownloading = uiState.downloadingItem?.id == item.id, - onClick = { - viewModel.downloadBook(item) - onItemClick(item) - } - ) - } - } - } - } - } -} - -@Composable -private fun LibraryCard( - library: LibraryResponse, - onClick: () -> Unit, -) { - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = library.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${library.itemCount} items ยท ${library.mediaType}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - } -} - -@Composable -private fun ItemCard( - item: UnifiedItemResponse, - isDownloading: Boolean, - onClick: () -> Unit, -) { - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (item.author.isNotBlank()) { - Text( - text = item.author, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - Text( - text = item.type, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - if (isDownloading) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) - Text( - "Downloading...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt deleted file mode 100644 index 072d823..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt +++ /dev/null @@ -1,176 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import android.app.Application -import android.net.Uri -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class BookshelfViewModel - @Inject - constructor( - application: Application, - private val repository: BookshelfApiRepository, - ) : AndroidViewModel(application) { - - private val _uiState = MutableStateFlow(BookshelfUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _downloadedFile = MutableSharedFlow(extraBufferCapacity = 1) - val downloadedFile: SharedFlow = _downloadedFile.asSharedFlow() - - fun loadLibraries() { - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.getLibraries() - .onSuccess { libraries -> - _uiState.value = _uiState.value.copy( - libraries = libraries, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Failed to load libraries" - ) - } - } - } - - fun selectLibrary(library: LibraryResponse) { - _uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList()) - loadItems(library.id) - } - - fun loadItems(libraryId: String) { - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.getLibraryItems(libraryId) - .onSuccess { response -> - _uiState.value = _uiState.value.copy( - items = response.items, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Failed to load items" - ) - } - } - } - - fun search(libraryId: String, query: String) { - if (query.isBlank()) { - loadItems(libraryId) - return - } - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.searchLibrary(libraryId, query) - .onSuccess { searchItems -> - val mapped = searchItems.map { bookItem -> - UnifiedItemResponse( - id = bookItem.id, - title = bookItem.title, - author = bookItem.author, - type = bookItem.mediaType, - mediaType = bookItem.mediaType, - libraryId = bookItem.libraryId, - coverUrl = bookItem.coverUrl - ) - } - _uiState.value = _uiState.value.copy( - items = mapped, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Search failed" - ) - } - } - } - - fun downloadBook(item: UnifiedItemResponse) { - _uiState.value = _uiState.value.copy(downloadingItem = item) - viewModelScope.launch { - repository.downloadEbook(item.id) - .onSuccess { body -> - val file = saveToCache(item, body) - if (file != null) { - _downloadedFile.tryEmit(Uri.fromFile(file)) - } - _uiState.value = _uiState.value.copy(downloadingItem = null) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - downloadingItem = null, - error = error.message ?: "Download failed" - ) - } - } - } - - private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? { - return runCatching { - val cacheDir = getApplication().cacheDir - val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() } - val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60) - val ext = guessExtension(item) - val file = File(dir, "${safeName}_${item.id}.$ext") - file.outputStream().use { out -> body.byteStream().copyTo(out) } - file - }.getOrElse { - _uiState.value = _uiState.value.copy( - downloadingItem = null, - error = "Failed to save file: ${it.message}" - ) - null - } - } - - private fun guessExtension(item: UnifiedItemResponse): String = when { - item.mediaType.contains("epub", ignoreCase = true) -> "epub" - item.mediaType.contains("pdf", ignoreCase = true) -> "pdf" - item.mediaType.contains("fb2", ignoreCase = true) -> "fb2" - item.mediaType.contains("mobi", ignoreCase = true) -> "mobi" - item.mediaType.contains("azw3", ignoreCase = true) -> "azw3" - item.mediaType.contains("docx", ignoreCase = true) -> "docx" - item.mediaType.contains("odt", ignoreCase = true) -> "odt" - item.mediaType.contains("txt", ignoreCase = true) -> "txt" - item.mediaType.contains("md", ignoreCase = true) -> "md" - else -> "epub" - } - } - - -data class BookshelfUiState( - val libraries: List = emptyList(), - val selectedLibrary: LibraryResponse? = null, - val items: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null, - val downloadingItem: UnifiedItemResponse? = null, -) \ No newline at end of file