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:
Atte149 2026-06-27 22:21:48 +03:00
parent 0e41718cde
commit bf964b3ee8
10 changed files with 13 additions and 6797 deletions

View file

@ -312,10 +312,9 @@ implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("org.zwobble.mammoth:mammoth:1.4.2")
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
implementation("androidx.core:core-splashscreen:1.2.0")

View file

@ -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()
)
}
}
}

View file

@ -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
)
}
}
}

View file

@ -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
)
}
}
}

View file

@ -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
}
}

View file

@ -368,6 +368,18 @@ private fun loadHiddenTools(context: Context): Set<String> {
}
return migratedHiddenTools
}
// If user hasn't customized tools yet, hide everything except the essentials:
// THEME, SLIDER, TOC, BOOKMARK, READING_MODE
if (savedHiddenTools.isEmpty()) {
val minimalHidden = ReaderTool.entries
.filter { it.name !in listOf("THEME", "SLIDER", "TOC", "BOOKMARK", "READING_MODE") }
.map { it.name }
.toSet()
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, minimalHidden) }
return minimalHidden
}
return savedHiddenTools
}

View file

@ -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)
)
}
}
}
}

View file

@ -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,
)