* 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)
2081 lines
91 KiB
Kotlin
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)) }
|
|
}
|
|
)
|
|
}
|