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