feat: simplify reader UI + remove unused code + speed up build
Reader UI simplification: - loadHiddenTools: by default hide all tools except THEME, SLIDER, TOC, BOOKMARK, READING_MODE - User sees only: font/theme controls, navigation slider, chapters, bookmarks, reading mode Speed optimization: - Removed old unused Episteme screens: HomeScreen, LibraryScreen, MainScreen, SettingsScreen, ProScreen, AppNavigation - Removed old bookshelf UI: BookshelfLibraryScreen, BookshelfViewModel (replaced by new ui/ screens) - TensorFlow deps kept (needed by debug ML source set) - Material-icons-extended added for icon availability ':app:assembleOssDebug' passes, APK deployed.
This commit is contained in:
parent
0e41718cde
commit
bf964b3ee8
10 changed files with 13 additions and 6797 deletions
|
|
@ -312,7 +312,6 @@ 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")
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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<ProductDetailsEntity>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -368,6 +368,18 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BookshelfUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
||||
val downloadedFile: SharedFlow<Uri> = _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<Application>().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<LibraryResponse> = emptyList(),
|
||||
val selectedLibrary: LibraryResponse? = null,
|
||||
val items: List<UnifiedItemResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val downloadingItem: UnifiedItemResponse? = null,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue