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,10 +312,9 @@ implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||||
implementation("org.zwobble.mammoth:mammoth:1.4.2")
|
implementation("org.zwobble.mammoth:mammoth:1.4.2")
|
||||||
|
|
||||||
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
||||||
|
|
||||||
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
|
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
|
||||||
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.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")
|
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
|
||||||
|
|
||||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
implementation("androidx.core:core-splashscreen:1.2.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
|
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
|
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