* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
500 lines
21 KiB
Kotlin
500 lines
21 KiB
Kotlin
// FontsScreen.kt
|
|
@file:Suppress("KotlinConstantConditions")
|
|
|
|
package com.aryan.reader
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
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.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.material.icons.filled.Check
|
|
import androidx.compose.material.icons.filled.CloudDownload
|
|
import androidx.compose.material.icons.filled.Delete
|
|
import androidx.compose.material.icons.filled.Search
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ModalBottomSheet
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.material3.rememberModalBottomSheetState
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.Font
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.aryan.reader.shared.CustomFontItem
|
|
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
|
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
|
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
|
import com.aryan.reader.data.CustomFontEntity
|
|
import java.io.File
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun FontsScreen(
|
|
viewModel: MainViewModel,
|
|
onBackClick: () -> Unit
|
|
) {
|
|
val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle()
|
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
val context = LocalContext.current
|
|
|
|
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
|
|
|
|
// Dialog state
|
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
|
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
|
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
|
|
|
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
|
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
|
}
|
|
|
|
val fontMimeTypes = arrayOf(
|
|
"font/ttf", "font/otf", "font/woff2",
|
|
"application/x-font-ttf", "application/x-font-otf",
|
|
"application/font-woff2", "application/vnd.ms-opentype",
|
|
"application/x-font-opentype"
|
|
)
|
|
|
|
Scaffold(
|
|
modifier = Modifier.statusBarsPadding(),
|
|
topBar = {
|
|
CustomTopAppBar(
|
|
title = { Text(stringResource(R.string.custom_fonts)) },
|
|
navigationIcon = {
|
|
IconButton(onClick = onBackClick) {
|
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
|
}
|
|
}
|
|
)
|
|
},
|
|
floatingActionButton = {
|
|
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
|
Column(
|
|
horizontalAlignment = Alignment.End,
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
if (showGoogleFontsOption) {
|
|
ExtendedFloatingActionButton(
|
|
onClick = { showGoogleFontsSheet = true },
|
|
icon = { Icon(Icons.Default.CloudDownload, contentDescription = null) },
|
|
text = { Text(stringResource(R.string.google_fonts)) },
|
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
|
)
|
|
}
|
|
|
|
ExtendedFloatingActionButton(
|
|
onClick = { pickFontLauncher.launch(fontMimeTypes) },
|
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
|
text = { Text(stringResource(R.string.import_font)) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
) { padding ->
|
|
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
|
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
|
Column(modifier = Modifier.fillMaxSize()) {
|
|
SharedFontSettingsTabs(
|
|
selectedSection = selectedSection,
|
|
onSectionChange = { selectedSection = it },
|
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
|
)
|
|
|
|
when (selectedSection) {
|
|
SharedFontSettingsSection.READER_FONTS -> {
|
|
if (fonts.isEmpty()) {
|
|
val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null
|
|
val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null
|
|
|
|
EmptyState(
|
|
title = stringResource(R.string.no_custom_fonts),
|
|
message = stringResource(R.string.import_fonts_desc),
|
|
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
|
|
modifier = Modifier.weight(1f),
|
|
secondaryButtonText = secondaryText,
|
|
onSecondaryClick = secondaryClick
|
|
)
|
|
} else {
|
|
LazyColumn(
|
|
modifier = Modifier.weight(1f),
|
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
items(fonts, key = { it.id }) { font ->
|
|
FontListItem(
|
|
font = font,
|
|
onDelete = {
|
|
fontToDelete = font
|
|
showDeleteDialog = true
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SharedFontSettingsSection.APP_TEXT -> {
|
|
SharedAppFontSelector(
|
|
preference = uiState.appFontPreference,
|
|
customFonts = sharedFonts,
|
|
onPreferenceChange = viewModel::setAppFontPreference,
|
|
fontFamilyForPreview = { font ->
|
|
val file = File(font.path)
|
|
if (file.isFile) {
|
|
runCatching { FontFamily(Font(file)) }.getOrNull()
|
|
} else {
|
|
null
|
|
}
|
|
},
|
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (uiState.isLoading) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
|
|
) {
|
|
Box(contentAlignment = Alignment.Center) {
|
|
CircularProgressIndicator()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showDeleteDialog && fontToDelete != null) {
|
|
DeleteFontConfirmationDialog(
|
|
fontName = fontToDelete!!.displayName,
|
|
onConfirm = {
|
|
fontToDelete?.let { viewModel.deleteFont(it.id) }
|
|
showDeleteDialog = false
|
|
fontToDelete = null
|
|
},
|
|
onDismiss = {
|
|
showDeleteDialog = false
|
|
fontToDelete = null
|
|
}
|
|
)
|
|
}
|
|
|
|
if (showGoogleFontsSheet) {
|
|
GoogleFontsBottomSheet(
|
|
onDismiss = { showGoogleFontsSheet = false },
|
|
existingFonts = fonts,
|
|
getFullFontList = { viewModel.loadGoogleFontsList(context) },
|
|
onDownloadFont = { fontName, onComplete ->
|
|
viewModel.downloadGoogleFont(fontName, onComplete)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun GoogleFontsBottomSheet(
|
|
onDismiss: () -> Unit,
|
|
existingFonts: List<CustomFontEntity>,
|
|
getFullFontList: () -> List<String>,
|
|
onDownloadFont: (String, () -> Unit) -> Unit
|
|
) {
|
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
var searchQuery by remember { mutableStateOf("") }
|
|
var downloadingFontName by remember { mutableStateOf<String?>(null) }
|
|
|
|
// Curated presets to show when search is empty
|
|
val popularPresets = remember {
|
|
listOf(
|
|
"Merriweather", "Open Sans", "Playfair Display", "Montserrat", "Oswald", "Raleway", "Nunito",
|
|
"Poppins", "Ubuntu", "Fira Sans", "Quicksand", "Crimson Text",
|
|
"Literata", "EB Garamond", "Libre Baskerville", "Inter", "Work Sans"
|
|
)
|
|
}
|
|
|
|
// Lazy evaluation of the full list only when typing
|
|
val displayList = remember(searchQuery) {
|
|
if (searchQuery.isBlank()) {
|
|
popularPresets
|
|
} else {
|
|
val allFonts = getFullFontList()
|
|
allFonts.filter { it.contains(searchQuery, ignoreCase = true) }.take(50) // Limit to 50 for performance
|
|
}
|
|
}
|
|
|
|
ModalBottomSheet(
|
|
onDismissRequest = onDismiss,
|
|
sheetState = sheetState,
|
|
containerColor = MaterialTheme.colorScheme.surface
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.action_browse_google_fonts),
|
|
style = MaterialTheme.typography.titleLarge,
|
|
fontWeight = FontWeight.Bold,
|
|
modifier = Modifier.padding(bottom = 12.dp)
|
|
)
|
|
|
|
OutlinedTextField(
|
|
value = searchQuery,
|
|
onValueChange = { searchQuery = it },
|
|
modifier = Modifier.fillMaxWidth(),
|
|
placeholder = { Text(stringResource(R.string.google_fonts_search_placeholder)) },
|
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
|
singleLine = true,
|
|
shape = RoundedCornerShape(12.dp)
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
LazyColumn(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
contentPadding = PaddingValues(bottom = 24.dp),
|
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
) {
|
|
if (searchQuery.isBlank()) {
|
|
item {
|
|
Text(
|
|
text = stringResource(R.string.google_fonts_popular_choices),
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.padding(vertical = 4.dp)
|
|
)
|
|
}
|
|
} else if (displayList.isEmpty()) {
|
|
item {
|
|
Text(
|
|
text = stringResource(R.string.google_fonts_no_matches, searchQuery),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(16.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
items(displayList) { fontName ->
|
|
val isDownloaded = remember(existingFonts, fontName) {
|
|
existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
|
|
}
|
|
val isDownloading = downloadingFontName == fontName
|
|
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.clickable(enabled = !isDownloaded && !isDownloading) {
|
|
downloadingFontName = fontName
|
|
onDownloadFont(fontName) {
|
|
if (downloadingFontName == fontName) {
|
|
downloadingFontName = null
|
|
}
|
|
}
|
|
}
|
|
.background(
|
|
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
|
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
)
|
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
text = fontName,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
|
|
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
)
|
|
|
|
Box(modifier = Modifier.padding(start = 12.dp)) {
|
|
when {
|
|
isDownloaded -> {
|
|
Icon(
|
|
Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_already_downloaded),
|
|
tint = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
isDownloading -> {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(20.dp),
|
|
strokeWidth = 2.dp,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
else -> {
|
|
Icon(
|
|
Icons.Default.CloudDownload,
|
|
contentDescription = stringResource(R.string.action_download),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Existing unchanged components
|
|
@Composable
|
|
fun FontListItem(
|
|
font: CustomFontEntity,
|
|
onDelete: () -> Unit
|
|
) {
|
|
val customTypeface = remember(font.path) {
|
|
try {
|
|
FontFamily(Font(File(font.path)))
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
Card(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = font.displayName,
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
|
|
Icon(
|
|
Icons.Default.Delete,
|
|
contentDescription = stringResource(R.string.action_delete),
|
|
tint = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
|
|
.padding(12.dp)
|
|
) {
|
|
if (customTypeface != null) {
|
|
Text(
|
|
text = stringResource(R.string.font_preview_text),
|
|
fontFamily = customTypeface,
|
|
fontSize = 18.sp,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
} else {
|
|
Text(
|
|
text = stringResource(R.string.font_preview_error),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = font.fileExtension.uppercase(),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.outline
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
|
return filterNot { it.isDeleted }
|
|
.sortedBy { it.displayName.lowercase() }
|
|
.map { font ->
|
|
CustomFontItem(
|
|
id = font.id,
|
|
displayName = font.displayName,
|
|
fileName = font.fileName,
|
|
fileExtension = font.fileExtension,
|
|
path = font.path,
|
|
timestamp = font.timestamp,
|
|
isDeleted = font.isDeleted
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DeleteFontConfirmationDialog(
|
|
fontName: String,
|
|
onConfirm: () -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text(stringResource(R.string.dialog_delete_font)) },
|
|
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
|
|
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)) }
|
|
}
|
|
)
|
|
}
|