book-reader/app/src/main/java/com/aryan/reader/HomeScreen.kt
Aryan d16bdd70d4
V1.0.46 oss (#253)
* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
2026-04-29 16:37:53 +05:30

2081 lines
91 KiB
Kotlin

/*
* 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
*/
// HomeScreen
@file:Suppress("DEPRECATION")
package com.aryan.reader
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
internal fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController
) {
val context = LocalContext.current
val customTabUriHandler = remember { CustomTabUriHandler(context) }
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
var showAppThemePanel by remember { mutableStateOf(false) }
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
val openTabs = uiState.openTabs
val selectedContextItems = uiState.contextualActionItems
val isContextualModeActive = selectedContextItems.isNotEmpty()
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val snackbarHostState = remember { SnackbarHostState() }
val deviceLimitState = uiState.deviceLimitState
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showClearCloudDataDialog by remember { mutableStateOf(false) }
var showClearAllDataDialog by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
var showSignOutConfirmDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
var showLanguageDialog by remember { mutableStateOf(false) }
val feedbackResult =
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("banner_message")
?.observeAsState()
LaunchedEffect(feedbackResult) {
feedbackResult?.value?.let { message ->
viewModel.showBanner(message)
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("banner_message")
}
}
val drivePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
viewModel.onDrivePermissionResult(result.data)
} else {
Timber.w("Google Sign In for Drive failed with result code: ${result.resultCode}")
viewModel.onDrivePermissionFlowCancelled()
}
}
LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context)
drivePermissionLauncher.launch(intent)
}
}
LaunchedEffect(uiState.bannerMessage) {
uiState.bannerMessage?.let { msg ->
if (!msg.isPersistent) {
delay(3000L)
viewModel.bannerMessageShown()
}
}
}
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { message ->
snackbarHostState.showSnackbar(message)
viewModel.errorMessageShown()
}
}
BackHandler(enabled = isContextualModeActive) {
Timber.d("System back pressed during contextual mode.")
viewModel.clearContextualAction()
}
val pickFileLauncher = rememberFilePickerLauncher { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
viewModel.onFilesSelected(uris)
}
val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
viewModel.onFilesSelected(uris)
}
val onSelectFileClick = {
if (isContextualModeActive) {
viewModel.clearContextualAction()
}
val mimeTypes = if (uiState.useStrictFileFilter) MainViewModel.SUPPORTED_MIME_TYPES else arrayOf("*/*")
try {
pickFileLauncher.launch(mimeTypes)
} catch (_: android.content.ActivityNotFoundException) {
Timber.w("OpenDocument picker failed. Falling back to GetMultipleContents.")
try {
fallbackFilePickerLauncher.launch("*/*")
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
ModalNavigationDrawer(
drawerState = drawerState, drawerContent = {
val context = LocalContext.current
AppDrawerContent(
uiState = uiState,
onSignInClick = {
scope.launch {
context.findActivity()?.let { activity ->
viewModel.signIn(activity)
}
drawerState.close()
}
},
onSignOutClick = {
showSignOutConfirmDialog = true
},
onSyncToggle = viewModel::setSyncEnabled,
onUpgradeClick = {
scope.launch {
drawerState.close()
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
}
},
onSyncUpsellClick = {
scope.launch {
showUpgradeDialog = true
}
},
onFontsClick = {
scope.launch {
drawerState.close()
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
}
},
navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
}) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
if (!isContextualModeActive) {
DefaultTopAppBar(
uiState = uiState,
onRenderModeChange = viewModel::setRenderMode,
onClearCache = { showClearBookCacheDialog = true },
onClearCloudData = { showClearAllDataDialog = true },
onAboutClick = { showAboutDialog = true },
onDrawerClick = {
scope.launch {
drawerState.open()
}
},
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
onTabsToggle = viewModel::setTabsEnabled,
onExternalFileBehaviorClick = { showBehaviorDialog = true },
onStrictFilterToggleClick = {
if (uiState.useStrictFileFilter) {
viewModel.setStrictFileFilter(false)
} else {
showStrictFilterDialog = true
}
},
onAppThemeClick = { showAppThemePanel = true },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true },
onExportLogsClick = { viewModel.exportLogsToFile(context) }
)
} else {
ContextualTopAppBar(
selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() },
onTagClick = {
viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet())
},
onInfoClick = {
if (selectedContextItems.size == 1) {
itemForInfoDialog = selectedContextItems.first()
showInfoDialog = true
}
},
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() })
}
}) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) {
if (uiState.recentFiles.isEmpty()) {
EmptyState(
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f),
secondaryButtonText = stringResource(R.string.setup_folder_sync),
onSecondaryClick = { viewModel.navigateToFolderSync() }
)
} else {
EmptyState(
title = stringResource(R.string.no_recent_files),
message = stringResource(R.string.no_recent_files_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
)
}
} else {
RecentFilesContent(
recentFiles = recentFilesForHome,
openTabs = openTabs,
isTabsEnabled = uiState.isTabsEnabled,
selectedContextItems = selectedContextItems,
pinnedHomeBookIds = uiState.pinnedHomeBookIds,
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
onTabCloseClick = { bookId -> viewModel.closeTab(bookId) },
onCloseAllTabsClick = { showCloseAllTabsDialog = true },
onSelectFileClick = onSelectFileClick,
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
windowSizeClass = windowSizeClass,
downloadingBookIds = uiState.downloadingBookIds,
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.isNotEmpty()
)
}
}
if (uiState.isLoading) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
// Dialogs
if (showDeleteConfirmDialog) {
DeleteConfirmationDialog(count = selectedContextItems.size, onConfirm = {
viewModel.hideItemsFromRecentsView()
showDeleteConfirmDialog = false
}, onDismiss = { showDeleteConfirmDialog = false })
}
if (showCloseAllTabsDialog) {
CloseAllTabsDialog(
onConfirm = {
viewModel.closeAllTabs()
showCloseAllTabsDialog = false
},
onDismiss = { showCloseAllTabsDialog = false }
)
}
if (showClearCloudDataDialog) {
ClearCloudDataConfirmationDialog(onConfirm = {
viewModel.deleteAllUserData()
showClearCloudDataDialog = false
}, onDismiss = { showClearCloudDataDialog = false })
}
if (showUpgradeDialog) {
UpgradeDialog(onDismiss = { showUpgradeDialog = false }, onConfirm = {
showUpgradeDialog = false
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
})
}
if (showClearBookCacheDialog) {
DangerousFolderActionDialog(
title = stringResource(R.string.dialog_clear_book_cache),
message = stringResource(R.string.dialog_clear_book_cache_desc),
onConfirm = {
viewModel.clearBookCache()
showClearBookCacheDialog = false
},
onDismiss = { showClearBookCacheDialog = false }
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
title = stringResource(R.string.dialog_clear_reflow_cache),
message = stringResource(R.string.dialog_clear_reflow_cache_desc),
onConfirm = {
viewModel.clearReflowCache()
showClearReflowCacheDialog = false
},
onDismiss = { showClearReflowCacheDialog = false }
)
}
if (uiState.showExternalFileSavePromptFor != null) {
ExternalFileSaveDialog(
onConfirm = { keep, dontAskAgain ->
viewModel.handleExternalFilePrompt(uiState.showExternalFileSavePromptFor!!, keep, dontAskAgain)
}
)
}
if (showBehaviorDialog) {
ExternalFileBehaviorDialog(
currentBehavior = uiState.externalFileBehavior,
onDismiss = { showBehaviorDialog = false },
onSelect = { viewModel.setExternalFileBehavior(it) }
)
}
if (showStrictFilterDialog) {
StrictFilterConfirmationDialog(
onConfirm = {
viewModel.setStrictFileFilter(true)
showStrictFilterDialog = false
},
onDismiss = { showStrictFilterDialog = false }
)
}
if (showLanguageDialog) {
LanguageSelectionDialog(onDismiss = { showLanguageDialog = false })
}
if (showAppThemePanel) {
AppThemeBottomSheet(
uiState = uiState,
onThemeModeChanged = viewModel::setAppThemeMode,
onContrastOptionChanged = viewModel::setAppContrastOption,
onTextDimFactorChanged = viewModel::setAppTextDimFactor,
onSeedColorChanged = viewModel::setAppSeedColor,
onCustomThemeAdded = viewModel::addCustomAppTheme,
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
onDismiss = { showAppThemePanel = false }
)
}
}
}
if (showAboutDialog) {
AboutDialog(onDismiss = { showAboutDialog = false })
}
if (showClearAllDataDialog) {
ClearAllDataConfirmationDialog(onConfirm = {
viewModel.deleteAllCloudAndLocalData()
showClearAllDataDialog = false
}, onDismiss = { showClearAllDataDialog = false })
}
if (showSignOutConfirmDialog) {
SignOutConfirmationDialog(onConfirm = {
viewModel.signOut()
showSignOutConfirmDialog = false
}, onDismiss = {
showSignOutConfirmDialog = false
})
}
if (deviceLimitState.isLimitReached) {
DeviceManagementScreen(
devices = deviceLimitState.registeredDevices,
onRemoveDevice = { deviceId -> viewModel.replaceDevice(deviceId) },
isReplacing = uiState.isReplacingDevice
)
}
CustomTopBanner(bannerMessage = uiState.bannerMessage)
if (BuildConfig.DEBUG) {
FpsMonitor(
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 48.dp, start = 8.dp)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecentFilesContent(
recentFiles: List<RecentFileItem>,
openTabs: List<RecentFileItem>,
isTabsEnabled: Boolean,
selectedContextItems: Collection<RecentFileItem>,
pinnedHomeBookIds: Set<String>,
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onTabCloseClick: (String) -> Unit,
onCloseAllTabsClick: () -> Unit,
onSelectFileClick: () -> Unit,
onNavigateToFolderSync: () -> Unit,
windowSizeClass: WindowSizeClass,
downloadingBookIds: Set<String>,
onRefresh: () -> Unit,
isRefreshing: Boolean,
isSyncEnabled: Boolean,
hasSyncedFolder: Boolean
) {
val canRefresh = isSyncEnabled || hasSyncedFolder
val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) {
RecentFilesGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
recentFiles = recentFiles,
openTabs = openTabs,
isTabsEnabled = isTabsEnabled,
onTabCloseClick = onTabCloseClick,
onCloseAllTabsClick = onCloseAllTabsClick,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
pinnedHomeBookIds = pinnedHomeBookIds,
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.Button(onClick = onSelectFileClick) {
Text(stringResource(R.string.empty_select_file))
}
androidx.compose.material3.Button(onClick = onNavigateToFolderSync) {
Text(stringResource(R.string.sync_folder))
}
}
}
}
if (canRefresh) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
content()
}
} else {
content()
}
}
@Composable
private fun RecentFilesGrid(
modifier: Modifier = Modifier,
recentFiles: List<RecentFileItem>,
openTabs: List<RecentFileItem>,
isTabsEnabled: Boolean,
onTabCloseClick: (String) -> Unit,
onCloseAllTabsClick: () -> Unit,
pinnedHomeBookIds: Set<String>,
selectedItemUris: Set<String>,
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
windowSizeClass: WindowSizeClass,
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
downloadingBookIds: Set<String>,
) {
val gridCells = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> GridCells.Fixed(3)
WindowWidthSizeClass.Medium -> GridCells.Adaptive(minSize = 140.dp)
else -> GridCells.Adaptive(minSize = 160.dp)
}
Column(modifier = modifier) {
if (isTabsEnabled && openTabs.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.active_tabs),
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = onCloseAllTabsClick) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close_all_tabs),
tint = MaterialTheme.colorScheme.error
)
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 8.dp)
) {
items(openTabs, key = { "tab_${it.bookId}" }) { tab ->
InputChip(
selected = false,
onClick = { onItemClick(tab) },
label = {
Text(
text = tab.customName ?: tab.title ?: tab.displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 150.dp)
)
},
trailingIcon = {
IconButton(
onClick = { onTabCloseClick(tab.bookId) },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close_tab),
modifier = Modifier.size(16.dp)
)
}
}
)
}
}
}
Text(
text = stringResource(R.string.recent_files),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp, top = if (isTabsEnabled && openTabs.isNotEmpty()) 8.dp else 24.dp)
)
LazyVerticalGrid(
columns = gridCells,
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
items(recentFiles, key = { it.bookId }) { item ->
RecentFileCard(
item = item,
isSelected = item.uriString in selectedItemUris,
isPinned = item.bookId in pinnedHomeBookIds,
onClick = { onItemClick(item) },
onLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RecentFileCard(
item: RecentFileItem,
isSelected: Boolean,
modifier: Modifier = Modifier,
isPinned: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit,
isDownloading: Boolean,
) {
val context = LocalContext.current
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
}
val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder
}
androidx.compose.material3.ElevatedCard(
modifier = modifier
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
)
.clip(MaterialTheme.shapes.large)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
elevation = androidx.compose.material3.CardDefaults.elevatedCardElevation(
defaultElevation = if (isSelected) 6.dp else 2.dp
)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.74f)
) {
AsyncImage(
model = ImageRequest.Builder(context).data(imageModel).error(placeholder)
.fallback(placeholder).crossfade(true).build(),
contentDescription = item.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier.fillMaxSize().background(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.15f),
0.3f to Color.Transparent,
0.6f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.5f)
)
)
)
if (item.sourceFolderUri != null || item.isOpdsStream() || isPinned) {
FileStatusBadges(
item = item,
isPinned = isPinned,
overlay = true,
modifier = Modifier
.align(Alignment.TopStart)
.padding(10.dp)
)
}
if (!item.isAvailable) {
Box(
modifier = Modifier.matchParentSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
if (isDownloading) {
CircularProgressIndicator(color = Color.White)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(48.dp),
tint = Color.White
)
}
}
}
if (isSelected) {
Box(
modifier = Modifier.matchParentSize()
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(48.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(8.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) {
FileTypeBadge(type = item.type, overlay = true)
}
progressPercent?.let { percent ->
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp),
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color.White.copy(alpha = 0.14f)
)
) {
Text(
text = "$percent%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = item.cardTitle(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = authorText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
minLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
if (!item.isAvailable) {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
contentAlignment = Alignment.CenterStart
) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
}
}
@Suppress("unused", "KotlinConstantConditions")
@Composable
fun DefaultTopAppBar(
uiState: ReaderScreenState,
onRenderModeChange: (RenderMode) -> Unit,
onClearCache: () -> Unit,
onClearCloudData: () -> Unit,
onClearReflowCache: () -> Unit,
onDrawerClick: () -> Unit,
onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit,
onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit,
onExportLogsClick: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
CustomTopAppBar(title = { }, navigationIcon = {
IconButton(onClick = onDrawerClick) {
BadgedBox(
badge = {
if (uiState.hasUnreadFeedback) {
Badge()
}
}) {
Icon(Icons.Default.Menu, contentDescription = "Open Drawer")
}
}
}, actions = {
Box {
IconButton(onClick = onAppThemeClick) {
Icon(painterResource(id = R.drawable.palette), contentDescription = "App Theme")
}
}
// Recent Files Limit Menu
Box {
IconButton(onClick = { showLimitMenu = true }) {
Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.options_recent_limit))
}
DropdownMenu(
expanded = showLimitMenu, onDismissRequest = { showLimitMenu = false }
) {
val limitOptions = listOf(0, 10, 20, 50, 100)
limitOptions.forEach { limit ->
DropdownMenuItem(
text = { Text(if (limit == 0) stringResource(R.string.options_no_limit) else stringResource(R.string.options_files_limit, limit)) },
onClick = {
onRecentFilesLimitChange(limit)
showLimitMenu = false
},
trailingIcon = if (uiState.recentFilesLimit == limit) {
{ Icon(Icons.Default.Check, contentDescription = "Selected") }
} else null
)
}
}
}
// Options Menu (MoreVert)
Box {
IconButton(onClick = { showOptionsMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
DropdownMenu(
expanded = showOptionsMenu, onDismissRequest = { showOptionsMenu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.about_title)) }, onClick = {
onAboutClick()
showOptionsMenu = false
})
HorizontalDivider()
DropdownMenuItem(text = { Text("Enable Multi-Tab Reading") }, onClick = {
onTabsToggle(!uiState.isTabsEnabled)
showOptionsMenu = false
}, trailingIcon = {
if (uiState.isTabsEnabled) {
Icon(Icons.Default.Check, contentDescription = "Enabled")
}
})
DropdownMenuItem(text = { Text(stringResource(R.string.options_external_file_behavior)) }, onClick = {
onExternalFileBehaviorClick()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text("Use Strict File Filter") }, onClick = {
onStrictFilterToggleClick()
showOptionsMenu = false
}, trailingIcon = {
if (uiState.useStrictFileFilter) {
Icon(Icons.Default.Check, contentDescription = "Enabled")
}
})
HorizontalDivider()
DropdownMenuItem(text = { Text("Language") }, onClick = {
onLanguageClick()
showOptionsMenu = false
})
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_reflow_cache)) }, onClick = {
onClearReflowCache()
showOptionsMenu = false
})
if (BuildConfig.DEBUG) {
HorizontalDivider()
DropdownMenuItem(text = { Text("Test Panel ML Detection") }, onClick = {
onTestPanelDetectionClick()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text("Test Speech Bubble ML Detection") }, onClick = {
onTestSpeechBubbleDetectionClick()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text("Export Logs (Last 5000 lines)") }, onClick = {
onExportLogsClick()
showOptionsMenu = false
})
}
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
onShowDeviceManagement()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text(stringResource(R.string.debug_clear_cloud_local_data)) },
onClick = {
onClearCloudData()
showOptionsMenu = false
})
}
}
}
})
}
@Suppress("KotlinConstantConditions")
@Composable
private fun AppDrawerContent(
uiState: ReaderScreenState,
onSignInClick: () -> Unit,
onSignOutClick: () -> Unit,
onSyncToggle: (Boolean) -> Unit,
onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit,
navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) {
val isOss = BuildConfig.FLAVOR == "oss"
ModalDrawerSheet {
Column(modifier = Modifier.fillMaxHeight()) {
if (!isOss) {
if (uiState.currentUser != null) {
// Signed-in: Show user info at the top
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val photoUrl = uiState.currentUser.photoUrl
if (photoUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(photoUrl)
.crossfade(true).build(),
contentDescription = "Profile picture",
modifier = Modifier
.size(80.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Icon(
imageVector = Icons.Outlined.AccountCircle,
contentDescription = "Profile",
modifier = Modifier.size(80.dp)
)
}
uiState.currentUser.displayName?.let { name ->
Text(text = name, style = MaterialTheme.typography.titleMedium)
}
uiState.currentUser.email?.let { email ->
Text(text = email, style = MaterialTheme.typography.bodyMedium)
}
if (BuildConfig.FLAVOR == "pro") {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = CircleShape,
modifier = Modifier.padding(top = 8.dp)
) {
Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.FormatListNumbered, contentDescription = "Credits", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer)
Spacer(modifier = Modifier.width(4.dp))
Text("${uiState.credits} Credits", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
}
} else {
// Signed-out: Show Sign In button at the top
Spacer(modifier = Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sign_in)) },
selected = false,
onClick = onSignInClick,
modifier = Modifier.padding(horizontal = 12.dp)
)
// LegalText
LegalText(
prefixText = stringResource(R.string.drawer_by_signing_in),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
textAlign = TextAlign.Start
)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Spacer(modifier = Modifier.height(16.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) },
label = {
val text = if (uiState.isProUser) stringResource(R.string.drawer_pro_unlocked) else stringResource(R.string.drawer_upgrade_pro)
Text(text)
},
selected = false,
onClick = onUpgradeClick,
modifier = Modifier.padding(horizontal = 12.dp)
)
// Sync Toggle Item
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.sync), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sync_library)) },
badge = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!uiState.isProUser) {
Icon(
imageVector = Icons.Default.VerifiedUser,
contentDescription = "Pro Feature",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
}
Switch(
checked = uiState.isSyncEnabled, onCheckedChange = {
if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick()
}, enabled = uiState.isProUser
)
}
}, selected = false, onClick = {
if (uiState.isProUser) {
onSyncToggle(!uiState.isSyncEnabled)
} else {
onSyncUpsellClick()
}
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (uiState.currentUser != null && uiState.isSyncEnabled) {
NavigationDrawerItem(
icon = { Icon(imageVector = Icons.Default.FolderSpecial, contentDescription = null) },
label = {
Column {
Text(stringResource(R.string.drawer_backup_local_folders))
Text(
stringResource(R.string.drawer_backup_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
badge = {
Switch(
checked = uiState.isFolderSyncEnabled,
onCheckedChange = { onFolderSyncToggle(it) }
)
},
selected = false,
onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
} else {
// OSS Header
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AsyncImage(
model = R.mipmap.ic_launcher,
contentDescription = "App Icon",
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(R.string.app_name_oss), style = MaterialTheme.typography.titleMedium)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
}
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
selected = false,
onClick = onFontsClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_help_feedback)) },
selected = false,
onClick = { navController.navigate("feedback_screen_route") },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (!isOss) {
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.logout), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sign_out)) },
selected = false,
onClick = onSignOutClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// legal links
if (uiState.currentUser != null && !isOss) {
val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
Text(
text = stringResource(R.string.legal_footer_combined),
style = scaledTextStyle,
maxLines = 1,
softWrap = false,
onTextLayout = {
if (it.didOverflowWidth) {
scaledTextStyle =
scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.95)
}
},
modifier = Modifier.height(0.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.legal_privacy_policy),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(PRIVACY_POLICY_URL) },
softWrap = false
)
Text("", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant))
Text(
text = stringResource(R.string.legal_terms_of_service),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(TERMS_URL) },
softWrap = false
)
Text("", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant))
Text(
text = stringResource(R.string.legal_licenses),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(LICENSES_URL) },
softWrap = false
)
}
}
}
}
}
@Composable
fun UpgradeDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) },
title = { Text(stringResource(R.string.dialog_unlock_pro)) },
text = { Text(stringResource(R.string.dialog_unlock_pro_desc)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_upgrade)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@Composable
fun SignOutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_confirm_sign_out)) },
text = { Text(stringResource(R.string.dialog_confirm_sign_out_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.drawer_sign_out))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@Composable
fun DeviceManagementScreen(
devices: List<DeviceItem>, onRemoveDevice: (String) -> Unit, isReplacing: Boolean
) {
val dateFormatter = remember {
SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault())
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.98f)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.device_limit_reached),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.device_limit_reached_desc),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
if (isReplacing) {
CircularProgressIndicator()
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(devices, key = { it.deviceId }) { device ->
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.PhoneAndroid, contentDescription = "Device")
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(device.deviceName, fontWeight = FontWeight.SemiBold)
device.lastSeen?.let {
Text(
stringResource(R.string.last_seen, dateFormatter.format(it)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
TextButton(onClick = { onRemoveDevice(device.deviceId) }) {
Text(stringResource(R.string.action_remove))
}
}
}
}
}
}
}
}
}
@Composable
fun ClearAllDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text(stringResource(R.string.dialog_destructive_action)) },
text = { Text(stringResource(R.string.dialog_destructive_action_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@Composable
fun FpsMonitor(modifier: Modifier = Modifier) {
var fps by remember { mutableLongStateOf(0L) }
var lastFrameTime by remember { mutableLongStateOf(0L) }
var frameCount by remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
while (true) {
withFrameNanos { currentFrameTime ->
frameCount++
if (currentFrameTime - lastFrameTime >= 1_000_000_000L) {
fps = frameCount
frameCount = 0
lastFrameTime = currentFrameTime
}
}
}
}
Text(
text = stringResource(R.string.debug_fps, fps),
color = Color.Green,
style = MaterialTheme.typography.labelLarge,
modifier = modifier
.background(Color.Black.copy(alpha = 0.5f))
.padding(4.dp)
)
}
@Composable
fun DangerousFolderActionDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = {
Text(
text = title,
color = MaterialTheme.colorScheme.error
)
},
text = { Text(message) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.action_confirm_clear))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
@Composable
fun ExternalFileSaveDialog(
onConfirm: (keep: Boolean, dontAskAgain: Boolean) -> Unit
) {
var dontAsk by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
title = { Text(stringResource(R.string.external_file_prompt_title)) },
text = {
Column {
Text(stringResource(R.string.external_file_prompt_desc))
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { dontAsk = !dontAsk }
) {
Checkbox(checked = dontAsk, onCheckedChange = { dontAsk = it })
Text(stringResource(R.string.external_file_dont_ask))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(true, dontAsk) }) {
Text(stringResource(R.string.external_file_keep))
}
},
dismissButton = {
TextButton(
onClick = { onConfirm(false, dontAsk) },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.external_file_delete))
}
}
)
}
@Composable
fun ExternalFileBehaviorDialog(
currentBehavior: String,
onDismiss: () -> Unit,
onSelect: (String) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
Column {
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
options.forEach { (value, labelRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(value); onDismiss() }
.padding(vertical = 12.dp)
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(labelRes))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
fun CloseAllTabsDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_close_all_tabs)) },
text = { Text(stringResource(R.string.dialog_close_all_tabs_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@Composable
fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Enable Strict File Filter") },
text = { Text("If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Enable") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppThemeBottomSheet(
uiState: ReaderScreenState,
onThemeModeChanged: (AppThemeMode) -> Unit,
onContrastOptionChanged: (AppContrastOption) -> Unit,
onTextDimFactorChanged: (Float) -> Unit,
onSeedColorChanged: (Color?) -> Unit,
onCustomThemeAdded: (CustomAppTheme) -> Unit,
onCustomThemeDeleted: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showCreateDialog by remember { mutableStateOf(false) }
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
) {
Text(
text = "App Theme",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Appearance", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) {
AppThemeMode.entries.forEach { mode ->
val isSelected = uiState.appThemeMode == mode
Box(
modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onThemeModeChanged(mode) },
contentAlignment = Alignment.Center
) {
Text(mode.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
}
}
}
Spacer(Modifier.height(24.dp))
Text("Contrast", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) {
AppContrastOption.entries.forEach { option ->
val isSelected = uiState.appContrastOption == option
Box(
modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onContrastOptionChanged(option) },
contentAlignment = Alignment.Center
) {
Text(option.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
}
}
}
Spacer(Modifier.height(24.dp))
Text("Text Brightness", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactor,
onValueChange = onTextDimFactorChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
Spacer(Modifier.height(24.dp))
Text("Color Scheme", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
item {
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
isSelected = uiState.appSeedColor == null,
label = "Dynamic",
onClick = { onSeedColorChanged(null) }
)
}
val presets = listOf(
"Ocean" to Color(0xFF00668B),
"Mint" to Color(0xFF006C4C),
"Rose" to Color(0xFF9C4146),
"Sepia" to Color(0xFF705D49),
"Amethyst" to Color(0xFF9B59B6),
"Amber" to Color(0xFFFFC107),
"Sapphire" to Color(0xFF0F52BA)
)
items(presets.size) { i ->
val (label, color) = presets[i]
ThemeSwatch(
color = color,
isSelected = uiState.appSeedColor == color,
label = label,
onClick = { onSeedColorChanged(color) }
)
}
}
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add Custom Theme", tint = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(8.dp))
if (uiState.customAppThemes.isEmpty()) {
Text("No custom themes yet.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(uiState.customAppThemes) { theme ->
ThemeSwatch(
color = theme.seedColor,
isSelected = uiState.appSeedColor == theme.seedColor,
label = theme.name,
onClick = { onSeedColorChanged(theme.seedColor) },
onDelete = { onCustomThemeDeleted(theme.id) }
)
}
}
}
}
}
if (showCreateDialog) {
CreateAppThemeDialog(
onDismiss = { showCreateDialog = false },
onSave = { name, color ->
onCustomThemeAdded(CustomAppTheme(id = System.currentTimeMillis().toString(), name = name, seedColor = color))
showCreateDialog = false
}
)
}
}
@Composable
fun ThemeSwatch(
color: Color,
isSelected: Boolean,
label: String,
onClick: () -> Unit,
onDelete: (() -> Unit)? = null
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(56.dp)
.background(color, CircleShape)
.border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(Icons.Default.Check, contentDescription = null, tint = if (color.luminance() > 0.5f) Color.Black else Color.White)
}
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 64.dp))
if (onDelete != null) {
Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(16.dp).clickable { onDelete() }, tint = MaterialTheme.colorScheme.error)
}
}
}
}
@SuppressLint("UnrememberedMutableState")
@Composable
fun CreateAppThemeDialog(
initialColor: Color = Color(0xFF6750A4),
onDismiss: () -> Unit,
onSave: (String, Color) -> Unit
) {
var name by remember { mutableStateOf("") }
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by androidx.compose.runtime.mutableFloatStateOf(initialHsv[0])
var saturation by androidx.compose.runtime.mutableFloatStateOf(initialHsv[1])
var value by androidx.compose.runtime.mutableFloatStateOf(initialHsv[2])
val currentColor by remember {
androidx.compose.runtime.derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
Color(android.graphics.Color.HSVToColor(255, hsv))
}
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.padding(20.dp)
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Create App Theme",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Spacer(Modifier.height(16.dp))
androidx.compose.material3.OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = currentColor,
focusedLabelColor = currentColor,
)
)
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s -> hue = h; saturation = s },
modifier = Modifier.fillMaxWidth().height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier.fillMaxWidth().height(24.dp).clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = initialColor,
newColor = currentColor,
modifier = Modifier.width(64.dp).height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("HEX", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(label = "R", value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "G", value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "B", value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Button(
onClick = { onSave(name.ifBlank { "Custom Theme" }, currentColor) },
colors = ButtonDefaults.buttonColors(containerColor = currentColor)
) {
Text("Save", color = if (currentColor.luminance() > 0.5f) Color.Black else Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@Composable
fun LanguageSelectionDialog(onDismiss: () -> Unit) {
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.language ?: "en" else "en"
val languages = listOf(
"en" to "English (Default)",
"ar" to "العربية (Arabic)",
"de" to "Deutsch (German)",
"tr" to "Türkçe (Turkish)"
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Language") },
text = {
Column {
languages.forEach { (tag, name) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(tag)
)
onDismiss()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = currentTag == tag, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Text(name)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}