/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ package org.dueattendant149.bookreader import android.os.Build import timber.log.Timber import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen import org.dueattendant149.bookreader.epubreader.EpubReaderScreen import org.dueattendant149.bookreader.feedback.FeedbackScreen import org.dueattendant149.bookreader.feedback.SupportProjectScreen import org.dueattendant149.bookreader.pdf.PdfViewerScreen import org.dueattendant149.bookreader.shared.ReaderFeatureSurface import org.dueattendant149.bookreader.tts.ReaderTtsMiniBar import org.dueattendant149.bookreader.tts.readerTtsMiniBarBottomPaddingDp import org.dueattendant149.bookreader.tts.shouldShowReaderTtsMiniBar import kotlinx.coroutines.delay object AppDestinations { const val MAIN_ROUTE = "main" const val PDF_VIEWER_ROUTE = "pdf_viewer" const val EPUB_READER_ROUTE = "epub_reader" const val PRO_SCREEN_ROUTE = "pro_screen" const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route" const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library" const val SERVER_SETTINGS_ROUTE = "server_settings" } fun shouldInterceptAppNavBack( currentRoute: String?, hasPreviousBackStackEntry: Boolean, isCurrentEntryResumed: Boolean ): Boolean { if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false return currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE && currentRoute != AppDestinations.PDF_VIEWER_ROUTE && currentRoute != AppDestinations.EPUB_READER_ROUTE } private fun NavHostController.isReadyForBackStackChange(): Boolean { return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED } private suspend fun NavHostController.awaitReadyForBackStackChange() { while (!isReadyForBackStackChange()) { delay(32) } } private fun NavHostController.navigateSingleTopTo(route: String) { if (!isReadyForBackStackChange()) { Timber.d("Skipping navigation to $route because the current entry is not resumed yet.") return } try { navigate(route) { launchSingleTop = true popUpTo(graph.startDestinationId) { saveState = false } } } catch (e: IllegalStateException) { Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.") } } private fun NavHostController.navigateToMain() { navigateSingleTopTo(AppDestinations.MAIN_ROUTE) } private fun NavHostController.navigateIfReady(route: String) { if (currentDestination?.route == route) return navigateSingleTopTo(route) } private fun NavHostController.popBackStackIfReady(): Boolean { if (!isReadyForBackStackChange()) { Timber.d("Skipping popBackStack because the current entry is not resumed yet.") return false } return try { popBackStack() } catch (e: IllegalStateException) { Timber.w(e, "popBackStack ignored because the back stack is mid-transition.") false } } private suspend fun NavHostController.syncRouteTo(route: String) { awaitReadyForBackStackChange() if (currentDestination?.route != route) { if (route == AppDestinations.MAIN_ROUTE) { navigateToMain() } else { navigateSingleTopTo(route) } } } @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun AppNavigation( navController: NavHostController, windowSizeClass: WindowSizeClass, viewModel: MainViewModel ) { Timber.d("AppNavigation composable invoked.") val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackStackEntry?.destination?.route val ttsController = viewModel.ttsController val ttsState by ttsController.ttsState.collectAsStateWithLifecycle() val isOnReaderRoute = currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE val showTtsMiniBar = shouldShowReaderTtsMiniBar( ttsState = ttsState, isOnReaderRoute = isOnReaderRoute ) val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp( isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE ).dp val shouldInterceptBack = shouldInterceptAppNavBack( currentRoute = currentRoute, hasPreviousBackStackEntry = navController.previousBackStackEntry != null, isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED ) LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { when (uiState.selectedFileType?.readerSurfaceOnAndroid()) { ReaderFeatureSurface.PDF_VIEWER -> { if (uiState.selectedPdfUri != null) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) } } } ReaderFeatureSurface.EPUB_READER, ReaderFeatureSurface.TEXT_READER -> { if (uiState.selectedEpubBook != null) { if (currentRoute != AppDestinations.EPUB_READER_ROUTE) { navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE) } } } null -> { if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) { navController.syncRouteTo(AppDestinations.MAIN_ROUTE) } } } } } Box(modifier = Modifier.fillMaxSize()) { BackHandler(enabled = shouldInterceptBack) { navController.popBackStackIfReady() } NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) { Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") if (uiState.isTemporaryExternalOpen) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else { MainScreen( viewModel = viewModel, windowSizeClass = windowSizeClass, navController = navController ) } } // PDF Viewer Screen Composable composable(route = AppDestinations.PDF_VIEWER_ROUTE) { Timber.d("Navigating to PDF Viewer Screen (${AppDestinations.PDF_VIEWER_ROUTE}).") val pdfUri = uiState.selectedPdfUri val initialPage = uiState.initialPageInBook val initialBookmarksJson = uiState.initialBookmarksJson val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId if (pdfUri != null) { Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage") Box(modifier = Modifier.fillMaxSize()) { PdfViewerScreen( pdfUri = pdfUri, initialPage = initialPage, initialBookmarksJson = initialBookmarksJson, isProUser = uiState.isProUser, onNavigateBack = { Timber.d("Back action triggered from PDF Viewer.") viewModel.clearSelectedFile() }, onSavePosition = viewModel::savePdfReadingPosition, onBookmarksChanged = { bookmarksJson -> if (bookId != null) { viewModel.saveBookmarks(bookId, bookmarksJson) } else { Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}") } }, onNavigateToPro = { navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) }, viewModel = viewModel ) if (uiState.isLoading) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } CustomTopBanner(bannerMessage = uiState.bannerMessage) } } else if (uiState.isLoading) { Timber.d("PDF URI is null but loading is in progress. Showing loading indicator.") Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else { Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.") } } // EPUB Reader Screen Composable composable(route = AppDestinations.EPUB_READER_ROUTE) { Timber.d("Navigating to EPUB Reader Screen (${AppDestinations.EPUB_READER_ROUTE}).") val epubBook = uiState.selectedEpubBook val isLoading = uiState.isLoading val errorMessage = uiState.errorMessage val initialLocator = uiState.initialLocator val initialCfi = uiState.initialCfi val initialBookmarksJson = uiState.initialBookmarksJson val renderMode = uiState.renderMode when { epubBook != null -> { Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator") val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath val epubUri = uiState.selectedEpubUri uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() Box(modifier = Modifier.fillMaxSize()) { EpubReaderScreen( epubBook = epubBook, renderMode = renderMode, initialLocator = initialLocator, initialCfi = initialCfi, initialBookmarksJson = initialBookmarksJson, isProUser = uiState.isProUser, coverImagePath = coverPath, onNavigateBack = { Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.") viewModel.clearSelectedFile() }, onSavePosition = { locator, cfiForWebView, progress -> Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%") epubUri?.let { uri -> viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress) } }, onBookmarksChanged = { bookmarksJson -> val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId if (bookId != null) { viewModel.saveBookmarks(bookId, bookmarksJson) } else { Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}") } }, onNavigateToPro = { navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) }, onRenderModeChange = viewModel::setRenderMode, customFonts = customFonts, onImportFonts = viewModel::importFonts, viewModel = viewModel ) if (uiState.isLoading) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } CustomTopBanner(bannerMessage = uiState.bannerMessage) } } isLoading -> { Timber.d("EPUB Reader: Showing loading indicator.") Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } errorMessage != null -> { Timber.e("EPUB Reader: Showing error message - $errorMessage") Column( modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text(stringResource(R.string.error_message_format, errorMessage), color = MaterialTheme.colorScheme.error) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { viewModel.clearSelectedFile() }) { Text(stringResource(R.string.action_go_back)) } } } else -> { Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.") } } } composable(route = AppDestinations.PRO_SCREEN_ROUTE) { ProScreen( viewModel = viewModel, onNavigateBack = { navController.popBackStackIfReady() } ) } composable(route = AppDestinations.FEEDBACK_SCREEN_ROUTE) { FeedbackScreen( navController = navController ) } composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) { SupportProjectScreen( navController = navController ) } composable(route = AppDestinations.FONTS_SCREEN_ROUTE) { FontsScreen( viewModel = viewModel, onBackClick = { navController.popBackStackIfReady() } ) } composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) { AiSettingsScreen( onBackClick = { navController.popBackStackIfReady() } ) } composable(route = AppDestinations.SETTINGS_SCREEN_ROUTE) { SettingsScreen( viewModel = viewModel, navController = navController, onBackClick = { navController.popBackStackIfReady() } ) } composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) { val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel() LaunchedEffect(bookshelfViewModel) { bookshelfViewModel.downloadedFile.collect { uri -> Timber.d("Downloaded book $uri, opening in reader") viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false) } } BookshelfLibraryScreen( viewModel = bookshelfViewModel, onItemClick = { item -> Timber.d("Bookshelf item selected: ${item.id}") bookshelfViewModel.downloadBook(item) }, onOpenSettings = { navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE) } ) } composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) { val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel() org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen( viewModel = settingsViewModel, onBackClick = { navController.popBackStackIfReady() } ) } } AnimatedVisibility( visible = showTtsMiniBar, enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(start = 16.dp, end = 16.dp, bottom = miniBarBottomPadding) ) { ReaderTtsMiniBar( ttsController = ttsController, ttsState = ttsState, onOpenReader = { ttsState.bookId?.let { bookId -> viewModel.openTtsNotificationTarget( bookId = bookId, sourceCfi = ttsState.sourceCfi, startOffset = ttsState.startOffsetInSource.takeIf { it >= 0 }, chapterIndex = ttsState.chapterIndex, pageIndex = ttsState.pageIndex ) } }, modifier = Modifier.fillMaxWidth() ) } } }