General improvements (#316)
* Added support for right text alignment in epub reader * Added tabs management to PDF navigation drawer * Implemented unified selection menu placement logic * Added reset functionality for toolbar customization * Improved highlight filtering in epub pagination * Migrated hardcoded UI strings to string resources * Extracted desktop application logic from Main.kt into modular files * Refactored Main.kt by extracting PDF and EPUB logic into specialized files * Added Spanish language support * Added system default option to app language selection
This commit is contained in:
parent
759d4b73a0
commit
056485a140
77 changed files with 9184 additions and 5947 deletions
|
|
@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -53,6 +54,10 @@ fun AiSettingsScreen(
|
|||
var pendingKey by remember { mutableStateOf("") }
|
||||
var showSaveConfirm by remember { mutableStateOf(false) }
|
||||
var providerToDelete by remember { mutableStateOf<String?>(null) }
|
||||
val providerLabels = mapOf(
|
||||
"gemini" to stringResource(R.string.provider_gemini),
|
||||
"groq" to stringResource(R.string.provider_groq),
|
||||
)
|
||||
|
||||
fun refresh() {
|
||||
settings = loadAiByokSettings(context)
|
||||
|
|
@ -67,10 +72,10 @@ fun AiSettingsScreen(
|
|||
modifier = Modifier.statusBarsPadding(),
|
||||
topBar = {
|
||||
CustomTopAppBar(
|
||||
title = { Text("AI keys and models") },
|
||||
title = { Text(stringResource(R.string.ai_settings_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -84,23 +89,23 @@ fun AiSettingsScreen(
|
|||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
|
||||
SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
|
||||
Text(stringResource(R.string.ai_settings_saved_keys), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SavedKeyRow(providerLabels.getValue("gemini"), maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
|
||||
SavedKeyRow(providerLabels.getValue("groq"), maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.ai_settings_add_or_replace_key), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = providerMenuExpanded,
|
||||
onExpandedChange = { providerMenuExpanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedProvider.replaceFirstChar { it.titlecase() },
|
||||
value = providerLabels[selectedProvider].orEmpty(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Provider") },
|
||||
label = { Text(stringResource(R.string.label_provider)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||
)
|
||||
|
|
@ -110,7 +115,7 @@ fun AiSettingsScreen(
|
|||
) {
|
||||
listOf("gemini", "groq").forEach { provider ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(provider.replaceFirstChar { it.titlecase() }) },
|
||||
text = { Text(providerLabels[provider].orEmpty()) },
|
||||
onClick = {
|
||||
selectedProvider = provider
|
||||
providerMenuExpanded = false
|
||||
|
|
@ -125,7 +130,7 @@ fun AiSettingsScreen(
|
|||
OutlinedTextField(
|
||||
value = pendingKey,
|
||||
onValueChange = { pendingKey = it },
|
||||
label = { Text("API key") },
|
||||
label = { Text(stringResource(R.string.label_api_key)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
|
@ -135,7 +140,7 @@ fun AiSettingsScreen(
|
|||
enabled = pendingKey.isNotBlank(),
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Save key")
|
||||
Text(stringResource(R.string.ai_settings_save_key))
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
|
@ -146,9 +151,9 @@ fun AiSettingsScreen(
|
|||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.ai_settings_use_one_model), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"When off, each reader AI feature uses its own selected model.",
|
||||
stringResource(R.string.ai_settings_use_one_model_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -161,35 +166,35 @@ fun AiSettingsScreen(
|
|||
|
||||
if (settings.useOneModel) {
|
||||
ModelSelector(
|
||||
title = "All AI features",
|
||||
description = "Smart dictionary, summaries, and recaps all use this model.",
|
||||
title = stringResource(R.string.ai_settings_all_features),
|
||||
description = stringResource(R.string.ai_settings_all_features_desc),
|
||||
selectedId = settings.modelForAll,
|
||||
onSelected = { updateModels(settings.copy(modelForAll = it)) }
|
||||
)
|
||||
} else {
|
||||
ModelSelector(
|
||||
title = "Smart dictionary",
|
||||
description = "Used when defining selected words or phrases.",
|
||||
title = stringResource(R.string.ai_settings_smart_dictionary),
|
||||
description = stringResource(R.string.ai_settings_smart_dictionary_desc),
|
||||
selectedId = settings.defineModel,
|
||||
onSelected = { updateModels(settings.copy(defineModel = it)) }
|
||||
)
|
||||
ModelSelector(
|
||||
title = "Summaries",
|
||||
description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.",
|
||||
title = stringResource(R.string.ai_settings_summaries),
|
||||
description = stringResource(R.string.ai_settings_summaries_desc),
|
||||
selectedId = settings.summarizeModel,
|
||||
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
|
||||
)
|
||||
ModelSelector(
|
||||
title = "Recaps",
|
||||
description = "Used for story recap generation.",
|
||||
title = stringResource(R.string.ai_settings_recaps),
|
||||
description = stringResource(R.string.ai_settings_recaps_desc),
|
||||
selectedId = settings.recapModel,
|
||||
onSelected = { updateModels(settings.copy(recapModel = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
ModelSelector(
|
||||
title = "Cloud TTS",
|
||||
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
||||
title = stringResource(R.string.credits_cloud_tts_title),
|
||||
description = stringResource(R.string.ai_settings_cloud_tts_desc, GEMINI_CLOUD_TTS_MODEL),
|
||||
selectedId = settings.ttsModel,
|
||||
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||
onSelected = { updateModels(settings.copy(ttsModel = it)) }
|
||||
|
|
@ -198,38 +203,40 @@ fun AiSettingsScreen(
|
|||
}
|
||||
|
||||
if (showSaveConfirm) {
|
||||
val providerLabel = providerLabels[selectedProvider].orEmpty()
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSaveConfirm = false },
|
||||
title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") },
|
||||
text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") },
|
||||
title = { Text(stringResource(R.string.dialog_save_provider_key, providerLabel)) },
|
||||
text = { Text(stringResource(R.string.dialog_save_key_desc)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
saveAiByokKey(context, selectedProvider, pendingKey)
|
||||
pendingKey = ""
|
||||
showSaveConfirm = false
|
||||
refresh()
|
||||
}) { Text("Save") }
|
||||
}) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") }
|
||||
TextButton(onClick = { showSaveConfirm = false }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
providerToDelete?.let { provider ->
|
||||
val providerLabel = providerLabels[provider].orEmpty()
|
||||
AlertDialog(
|
||||
onDismissRequest = { providerToDelete = null },
|
||||
title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") },
|
||||
text = { Text("Features using this provider will stop working until a new key is saved.") },
|
||||
title = { Text(stringResource(R.string.dialog_delete_provider_key, providerLabel)) },
|
||||
text = { Text(stringResource(R.string.dialog_delete_key_desc)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
deleteAiByokKey(context, provider)
|
||||
providerToDelete = null
|
||||
refresh()
|
||||
}) { Text("Delete") }
|
||||
}) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { providerToDelete = null }) { Text("Cancel") }
|
||||
TextButton(onClick = { providerToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -241,14 +248,15 @@ private fun SavedKeyRow(
|
|||
maskedKey: String,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val noKeySaved = stringResource(R.string.ai_settings_no_key_saved)
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
supportingContent = {
|
||||
Text(maskedKey.ifBlank { "No key saved" })
|
||||
Text(maskedKey.ifBlank { noKeySaved })
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete $label key")
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.content_desc_delete_provider_key, label))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -275,10 +283,10 @@ private fun ModelSelector(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selected?.label ?: "No model selected",
|
||||
value = selected?.label ?: stringResource(R.string.ai_settings_no_model_selected),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Model") },
|
||||
label = { Text(stringResource(R.string.label_model)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||
)
|
||||
|
|
@ -287,7 +295,7 @@ private fun ModelSelector(
|
|||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No model selected") },
|
||||
text = { Text(stringResource(R.string.ai_settings_no_model_selected)) },
|
||||
onClick = {
|
||||
onSelected("")
|
||||
expanded = false
|
||||
|
|
|
|||
|
|
@ -239,11 +239,19 @@ const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
|
|||
|
||||
enum class AiFeature { DEFINE, SUMMARIZE, RECAP }
|
||||
|
||||
private fun AiFeature.displayName(): String {
|
||||
private fun AiFeature.displayName(context: Context): String {
|
||||
return when (this) {
|
||||
AiFeature.DEFINE -> "Smart dictionary"
|
||||
AiFeature.SUMMARIZE -> "Summaries"
|
||||
AiFeature.RECAP -> "Recaps"
|
||||
AiFeature.DEFINE -> context.getString(R.string.ai_settings_smart_dictionary)
|
||||
AiFeature.SUMMARIZE -> context.getString(R.string.ai_settings_summaries)
|
||||
AiFeature.RECAP -> context.getString(R.string.ai_settings_recaps)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aiProviderDisplayName(context: Context, provider: String): String {
|
||||
return when (provider) {
|
||||
"gemini" -> context.getString(R.string.provider_gemini)
|
||||
"groq" -> context.getString(R.string.provider_groq)
|
||||
else -> provider.replaceFirstChar { it.titlecase(Locale.ROOT) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,7 +486,8 @@ data class CachedSummaryItem(
|
|||
)
|
||||
|
||||
class SummaryCacheManager(context: Context) {
|
||||
private val cacheDir = File(context.cacheDir, "chapter_summaries")
|
||||
private val appContext = context.applicationContext
|
||||
private val cacheDir = File(appContext.cacheDir, "chapter_summaries")
|
||||
|
||||
init {
|
||||
if (!cacheDir.exists()) {
|
||||
|
|
@ -529,7 +538,7 @@ class SummaryCacheManager(context: Context) {
|
|||
val fullText = file.readText()
|
||||
val lines = fullText.lines()
|
||||
|
||||
val title = lines.firstOrNull()?.trim() ?: "Chapter ${index + 1}"
|
||||
val title = lines.firstOrNull()?.trim() ?: appContext.getString(R.string.chapter_number_format, index + 1)
|
||||
val summaryText = if (lines.size > 1) lines.drop(1).joinToString("\n") else ""
|
||||
|
||||
Timber.d("Cache Load: Ch $index, Title: $title")
|
||||
|
|
@ -914,6 +923,7 @@ fun AiDefinitionPopup(
|
|||
}
|
||||
|
||||
val textToUse = styledContent.text
|
||||
val aiDefinitionTitle = stringResource(R.string.ai_definition_title)
|
||||
|
||||
if (textToUse.isNotBlank()) {
|
||||
Row(
|
||||
|
|
@ -937,7 +947,7 @@ fun AiDefinitionPopup(
|
|||
val token = getAuthToken()
|
||||
ttsController.start(
|
||||
chunks = chunks,
|
||||
bookTitle = "AI Definition",
|
||||
bookTitle = aiDefinitionTitle,
|
||||
chapterTitle = word,
|
||||
coverImageUri = null,
|
||||
ttsMode = loadTtsMode(context),
|
||||
|
|
@ -1178,8 +1188,8 @@ suspend fun fetchAiDefinition(
|
|||
}
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." }
|
||||
onError("${responseCode}. ${errorDetail ?: context.getString(R.string.error_unknown_server)}")
|
||||
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { context.getString(R.string.error_could_not_get_definition) }
|
||||
onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorDetail ?: context.getString(R.string.error_unknown_server)))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Network error fetching AI definition: ${e.message}")
|
||||
|
|
@ -1198,7 +1208,8 @@ fun countWords(text: String): Int {
|
|||
private fun streamGeminiAiResponse(
|
||||
connection: HttpURLConnection,
|
||||
onUpdate: (String) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
safetyError: String
|
||||
): Boolean {
|
||||
var hasReceivedData = false
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
|
|
@ -1243,7 +1254,7 @@ private fun streamGeminiAiResponse(
|
|||
hasReceivedData = true
|
||||
}
|
||||
if (jsonResponse.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "SAFETY") {
|
||||
onError("Blocked for safety reasons.")
|
||||
onError(safetyError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not parse Gemini BYOK stream object")
|
||||
|
|
@ -1363,12 +1374,12 @@ suspend fun callByokTextAi(
|
|||
val settings = loadAiByokSettings(context)
|
||||
val model = aiModelById(settings.modelIdFor(feature))
|
||||
if (model == null) {
|
||||
onError("Choose a model for ${feature.displayName()} in AI key and model settings.")
|
||||
onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context)))
|
||||
return@withContext false
|
||||
}
|
||||
val apiKey = settings.apiKeyFor(model.provider)
|
||||
if (apiKey.isBlank()) {
|
||||
onError("Add a ${model.provider.replaceFirstChar { it.titlecase(Locale.ROOT) }} API key in AI key and model settings.")
|
||||
onError(context.getString(R.string.ai_error_add_provider_key, aiProviderDisplayName(context, model.provider)))
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
|
|
@ -1422,13 +1433,13 @@ suspend fun callByokTextAi(
|
|||
val hasData = if (model.provider == "groq") {
|
||||
streamGroqAiResponse(connection, onUpdate, onError)
|
||||
} else {
|
||||
streamGeminiAiResponse(connection, onUpdate, onError)
|
||||
streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety))
|
||||
}
|
||||
if (!hasData) onError("The AI provider returned an empty response.")
|
||||
if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response))
|
||||
hasData
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||
onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300)))
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -1454,16 +1465,16 @@ suspend fun callByokGeminiInlineAi(
|
|||
val settings = loadAiByokSettings(context)
|
||||
val model = aiModelById(settings.modelIdFor(feature))
|
||||
if (model == null) {
|
||||
onError("Choose a model for ${feature.displayName()} in AI key and model settings.")
|
||||
onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context)))
|
||||
return@withContext false
|
||||
}
|
||||
if (model.provider != "gemini") {
|
||||
onError("This summary needs a Gemini model because the selected Groq models do not support PDF/image input.")
|
||||
onError(context.getString(R.string.ai_error_gemini_required_for_image_summary))
|
||||
return@withContext false
|
||||
}
|
||||
val apiKey = settings.geminiKey.trim()
|
||||
if (apiKey.isBlank()) {
|
||||
onError("Add a Gemini API key in AI key and model settings.")
|
||||
onError(context.getString(R.string.ai_error_add_provider_key, context.getString(R.string.provider_gemini)))
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
|
|
@ -1502,12 +1513,12 @@ suspend fun callByokGeminiInlineAi(
|
|||
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
val hasData = streamGeminiAiResponse(connection, onUpdate, onError)
|
||||
if (!hasData) onError("The AI provider returned an empty response.")
|
||||
val hasData = streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety))
|
||||
if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response))
|
||||
hasData
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||
onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300)))
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -1692,7 +1703,7 @@ suspend fun fetchRecap(
|
|||
if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap))
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
onError("${responseCode}. ${errorBody ?: ""}")
|
||||
onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorBody.orEmpty()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Recap error: ${e.message}")
|
||||
|
|
@ -2824,10 +2835,10 @@ fun ReaderThemePanel(
|
|||
|
||||
TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) {
|
||||
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }) {
|
||||
Text("Solid Colors", modifier = Modifier.padding(12.dp))
|
||||
Text(stringResource(R.string.theme_solid_colors), modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }) {
|
||||
Text("Textured", modifier = Modifier.padding(12.dp))
|
||||
Text(stringResource(R.string.theme_textured), modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2836,7 +2847,7 @@ fun ReaderThemePanel(
|
|||
if (selectedTabIndex == 1) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Texture Transparency", style = MaterialTheme.typography.labelMedium)
|
||||
Text(stringResource(R.string.theme_texture_transparency), style = MaterialTheme.typography.labelMedium)
|
||||
Text("${(globalTextureTransparency * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Slider(
|
||||
|
|
@ -2894,7 +2905,7 @@ fun ReaderThemePanel(
|
|||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = { editingTheme = null; builderIsTextured = selectedTabIndex == 1; showBuilder = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New Theme", tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.theme_new), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2958,7 +2969,7 @@ private fun ThemeGridItem(
|
|||
.clickable { onThemeSelected(theme.id) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
|
@ -2967,9 +2978,9 @@ private fun ThemeGridItem(
|
|||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Default.Edit, stringResource(R.string.action_edit), Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Icon(Icons.Default.Delete, stringResource(R.string.action_delete), Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2985,7 +2996,8 @@ fun ThemeBuilderView(
|
|||
onCancel: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var name by remember { mutableStateOf(initialTheme?.name ?: if (isTexturedMode) "Custom Textured" else "Custom Solid") }
|
||||
val defaultThemeName = stringResource(if (isTexturedMode) R.string.theme_custom_textured_default else R.string.theme_custom_solid_default)
|
||||
var name by remember(initialTheme?.id, isTexturedMode, defaultThemeName) { mutableStateOf(initialTheme?.name ?: defaultThemeName) }
|
||||
var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
|
||||
var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
|
||||
var editingColorType by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -3106,7 +3118,7 @@ private fun CustomTexturePickerSection(
|
|||
onImportTexture: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Select Custom Texture", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Text(stringResource(R.string.theme_select_custom_texture), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
|
|
@ -3118,8 +3130,8 @@ private fun CustomTexturePickerSection(
|
|||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Import", tint = MaterialTheme.colorScheme.primary)
|
||||
Text("Import", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_import), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.action_import), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1049,7 +1049,7 @@ fun DefaultTopAppBar(
|
|||
}
|
||||
}, actions = {
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = onAppThemeClick) {
|
||||
|
|
@ -1139,7 +1139,7 @@ fun DefaultTopAppBar(
|
|||
|
||||
if (!BuildConfig.IS_OFFLINE) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||
text = { Text(stringResource(if (hideReaderAiFeatures) R.string.options_show_ai_in_reader else R.string.options_hide_ai_in_reader)) },
|
||||
onClick = {
|
||||
onToggleHideReaderAi()
|
||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||
|
|
@ -1374,7 +1374,7 @@ private fun AppDrawerContent(
|
|||
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
label = { Text("Settings") },
|
||||
label = { Text(stringResource(R.string.settings)) },
|
||||
selected = false,
|
||||
onClick = onSettingsClick,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
|
|
@ -1391,7 +1391,7 @@ private fun AppDrawerContent(
|
|||
if (isOss && !BuildConfig.IS_OFFLINE) {
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
|
||||
label = { Text("AI keys and models") },
|
||||
label = { Text(stringResource(R.string.ai_settings_title)) },
|
||||
selected = false,
|
||||
onClick = onAiSettingsClick,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
|
|
@ -1855,7 +1855,7 @@ fun AppThemeBottomSheet(
|
|||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
|
||||
Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.app_theme_text_brightness_light), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -1877,7 +1877,7 @@ fun AppThemeBottomSheet(
|
|||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.app_theme_text_brightness_dark), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -2184,38 +2184,30 @@ fun CreateAppThemeDialog(
|
|||
@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 R.string.language_english_default,
|
||||
"ar" to R.string.language_arabic,
|
||||
"de" to R.string.language_german,
|
||||
"tr" to R.string.language_turkish,
|
||||
"fr" to R.string.language_french,
|
||||
"ru" to R.string.language_russian
|
||||
)
|
||||
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.toLanguageTag() else null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.options_language)) },
|
||||
text = {
|
||||
Column {
|
||||
languages.forEach { (tag, nameRes) ->
|
||||
appLanguageSelectionOptions.forEach { language ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
val locales = language.tag?.let { tag ->
|
||||
LocaleListCompat.forLanguageTags(tag)
|
||||
)
|
||||
} ?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
onDismiss()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = currentTag == tag, onClick = null)
|
||||
RadioButton(selected = currentTag == language.tag, onClick = null)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(stringResource(nameRes))
|
||||
Text(stringResource(language.labelRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@ fun LibraryScreenContent(
|
|||
}
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
|
|
@ -156,7 +157,7 @@ fun SettingsScreen(
|
|||
title = { Text(settingsPage.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = ::navigateBackFromSettings) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -242,11 +243,11 @@ fun SettingsScreen(
|
|||
SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context)
|
||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context)
|
||||
SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context)
|
||||
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.")
|
||||
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner(context.getString(R.string.debug_actions_existing_menus))
|
||||
SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE)
|
||||
SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE)
|
||||
SharedSettingsAction.ABOUT -> showAboutDialog = true
|
||||
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.")
|
||||
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner(context.getString(R.string.pdf_specific_settings_existing_reader))
|
||||
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||
SharedSettingsAction.READER_TOOLBAR,
|
||||
SharedSettingsAction.TTS_REPLACEMENTS,
|
||||
|
|
@ -374,7 +375,7 @@ fun SettingsScreen(
|
|||
onSpeakerChange = viewModel.ttsController::changeSpeaker,
|
||||
isTtsActive = ttsState.isPlaying,
|
||||
getAuthToken = { viewModel.getAuthToken() },
|
||||
bookTitle = "Reader defaults"
|
||||
bookTitle = context.getString(R.string.reader_defaults)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -395,19 +396,23 @@ private fun RecentLimitDialog(
|
|||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Recent files limit") },
|
||||
title = { Text(stringResource(R.string.options_recent_limit)) },
|
||||
text = {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
listOf(0, 10, 20, 50, 100).forEach { limit ->
|
||||
TextButton(onClick = { onSelect(limit) }) {
|
||||
val label = if (limit == 0) "No limit" else "$limit files"
|
||||
Text(if (currentLimit == limit) "$label selected" else label)
|
||||
val label = if (limit == 0) {
|
||||
stringResource(R.string.options_no_limit)
|
||||
} else {
|
||||
stringResource(R.string.options_files_limit, limit)
|
||||
}
|
||||
Text(if (currentLimit == limit) stringResource(R.string.option_selected_format, label) else label)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -522,6 +527,7 @@ private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
|
|||
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||
return when (this) {
|
||||
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||
AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
|
||||
AndroidReaderTextAlign.DEFAULT,
|
||||
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||
}
|
||||
|
|
@ -539,6 +545,7 @@ private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
|
|||
private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign {
|
||||
return when (this) {
|
||||
SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY
|
||||
SharedReaderTextAlign.RIGHT -> AndroidReaderTextAlign.RIGHT
|
||||
SharedReaderTextAlign.CENTER,
|
||||
SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT
|
||||
}
|
||||
|
|
|
|||
|
|
@ -550,10 +550,10 @@ fun FileInfoDialog(
|
|||
AlertDialog(
|
||||
onDismissRequest = { showRestoreConfirmation = false },
|
||||
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
||||
title = { Text("Restore original metadata?") },
|
||||
title = { Text(stringResource(R.string.dialog_restore_original_metadata)) },
|
||||
text = {
|
||||
Text(
|
||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
||||
stringResource(R.string.dialog_restore_original_metadata_desc)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
|
@ -564,7 +564,7 @@ fun FileInfoDialog(
|
|||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Restore")
|
||||
Text(stringResource(R.string.action_restore))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
|
|
@ -642,10 +642,10 @@ private fun BookMetadataInfoContent(
|
|||
)
|
||||
}
|
||||
val provenance = when {
|
||||
item.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
|
||||
item.type == FileType.EPUB -> "Metadata from EPUB file"
|
||||
!item.customName.isNullOrBlank() -> "Display name changed in app"
|
||||
else -> "Metadata from file"
|
||||
item.type == FileType.EPUB && hasMetadataChanges -> stringResource(R.string.metadata_provenance_epub_edited)
|
||||
item.type == FileType.EPUB -> stringResource(R.string.metadata_provenance_from_epub)
|
||||
!item.customName.isNullOrBlank() -> stringResource(R.string.metadata_provenance_display_name_changed)
|
||||
else -> stringResource(R.string.metadata_provenance_from_file)
|
||||
}
|
||||
Text(
|
||||
provenance,
|
||||
|
|
@ -655,23 +655,23 @@ private fun BookMetadataInfoContent(
|
|||
}
|
||||
}
|
||||
|
||||
FileInfoSection(title = "Metadata") {
|
||||
InfoRowDetailed("Title", item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3)
|
||||
FileInfoSection(title = stringResource(R.string.section_metadata)) {
|
||||
InfoRowDetailed(stringResource(R.string.label_title), item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3)
|
||||
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||
InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2)
|
||||
}
|
||||
item.seriesLabel()?.let {
|
||||
InfoRowDetailed("Series", it, maxLines = 2)
|
||||
InfoRowDetailed(stringResource(R.string.label_series), it, maxLines = 2)
|
||||
}
|
||||
InfoRowDetailed(stringResource(R.string.format), item.type.name)
|
||||
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
|
||||
InfoRowDetailed("Reading", item.readingProgressText(), maxLines = 2)
|
||||
InfoRowDetailed(stringResource(R.string.label_reading), item.readingProgressText(), maxLines = 2)
|
||||
}
|
||||
|
||||
FileInfoSection(title = "File") {
|
||||
InfoRowDetailed("File name", item.displayName, maxLines = 2)
|
||||
FileInfoSection(title = stringResource(R.string.section_file)) {
|
||||
InfoRowDetailed(stringResource(R.string.label_file_name_simple), item.displayName, maxLines = 2)
|
||||
InfoRowDetailed(stringResource(R.string.added), formattedDate)
|
||||
lastModifiedDate?.let { InfoRowDetailed("Modified", it) }
|
||||
lastModifiedDate?.let { InfoRowDetailed(stringResource(R.string.label_modified), it) }
|
||||
InfoRowDetailed(
|
||||
label = stringResource(R.string.location),
|
||||
value = pathText,
|
||||
|
|
@ -686,7 +686,7 @@ private fun BookMetadataInfoContent(
|
|||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("Summary", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.label_summary), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
ExpandableSummaryText(summary, collapsedMaxLines = 4)
|
||||
}
|
||||
}
|
||||
|
|
@ -698,7 +698,7 @@ private fun BookMetadataInfoContent(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Library tags", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.label_library_tags), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) }
|
||||
}
|
||||
|
||||
|
|
@ -732,11 +732,11 @@ private fun BookMetadataEditContent(
|
|||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Editable metadata", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.label_editable_metadata), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = titleInput,
|
||||
onValueChange = onTitleChange,
|
||||
label = { Text("Title") },
|
||||
label = { Text(stringResource(R.string.label_title)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3
|
||||
)
|
||||
|
|
@ -751,7 +751,7 @@ private fun BookMetadataEditContent(
|
|||
OutlinedTextField(
|
||||
value = seriesInput,
|
||||
onValueChange = onSeriesChange,
|
||||
label = { Text("Series") },
|
||||
label = { Text(stringResource(R.string.label_series)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2
|
||||
)
|
||||
|
|
@ -767,7 +767,7 @@ private fun BookMetadataEditContent(
|
|||
OutlinedTextField(
|
||||
value = descriptionInput,
|
||||
onValueChange = onDescriptionChange,
|
||||
label = { Text("Summary") },
|
||||
label = { Text(stringResource(R.string.label_summary)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp),
|
||||
|
|
@ -789,16 +789,16 @@ private fun BookDisplayNameEditContent(
|
|||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Display name", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.label_display_name), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = displayNameInput,
|
||||
onValueChange = onDisplayNameChange,
|
||||
label = { Text("Name shown in Reader") },
|
||||
label = { Text(stringResource(R.string.label_name_shown_in_reader)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3
|
||||
)
|
||||
Text(
|
||||
"Original file: $originalFileName",
|
||||
stringResource(R.string.original_file_format, originalFileName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
|
|
@ -833,7 +833,7 @@ private fun FileInfoBottomBar(
|
|||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Restore")
|
||||
Text(stringResource(R.string.action_restore))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onCancel) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
|
|
@ -49,6 +50,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
|
|
@ -104,12 +106,12 @@ fun TtsWordReplacementsSheet(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "TTS Word Replacements",
|
||||
text = stringResource(R.string.menu_tts_word_replacements),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book",
|
||||
text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.tts_replacements_current_book),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
|
@ -117,7 +119,7 @@ fun TtsWordReplacementsSheet(
|
|||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ fun TtsWordReplacementsSheet(
|
|||
selectedTab = 0
|
||||
editTarget = null
|
||||
},
|
||||
text = { Text("Global") },
|
||||
text = { Text(stringResource(R.string.tts_replacements_tab_global)) },
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == 1,
|
||||
|
|
@ -138,7 +140,7 @@ fun TtsWordReplacementsSheet(
|
|||
selectedTab = 1
|
||||
editTarget = null
|
||||
},
|
||||
text = { Text("This book") },
|
||||
text = { Text(stringResource(R.string.tts_replacements_tab_this_book)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -179,8 +181,8 @@ private fun GlobalReplacementTab(
|
|||
) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Enable replacements") },
|
||||
supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") },
|
||||
headlineContent = { Text(stringResource(R.string.tts_replacements_enable)) },
|
||||
supportingContent = { Text(stringResource(R.string.tts_replacements_enable_desc)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = preferences.isEnabled,
|
||||
|
|
@ -206,7 +208,7 @@ private fun GlobalReplacementTab(
|
|||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Add rule")
|
||||
Text(stringResource(R.string.tts_replacements_add_rule))
|
||||
}
|
||||
}
|
||||
if (editTarget != null) {
|
||||
|
|
@ -229,7 +231,7 @@ private fun GlobalReplacementTab(
|
|||
item {
|
||||
ReplacementRuleList(
|
||||
rules = preferences.globalRules,
|
||||
emptyText = "No global replacement rules yet.",
|
||||
emptyTextRes = R.string.tts_replacements_empty_global,
|
||||
onToggle = { rule, enabled ->
|
||||
onPreferencesChange(
|
||||
preferences.copy(
|
||||
|
|
@ -297,7 +299,7 @@ private fun BookReplacementTab(
|
|||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Add book rule")
|
||||
Text(stringResource(R.string.tts_replacements_add_book_rule))
|
||||
}
|
||||
}
|
||||
if (editTarget != null) {
|
||||
|
|
@ -320,7 +322,7 @@ private fun BookReplacementTab(
|
|||
item {
|
||||
ReplacementRuleList(
|
||||
rules = localRules,
|
||||
emptyText = "No book-specific rules yet.",
|
||||
emptyTextRes = R.string.tts_replacements_empty_book,
|
||||
onToggle = { rule, enabled ->
|
||||
onPreferencesChange(
|
||||
preferences.withBookRules(
|
||||
|
|
@ -349,8 +351,8 @@ private fun BookSettingsSwitches(
|
|||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Use global rules here") },
|
||||
supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") },
|
||||
headlineContent = { Text(stringResource(R.string.tts_replacements_use_global_here)) },
|
||||
supportingContent = { Text(stringResource(R.string.tts_replacements_use_global_here_desc)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.globalRulesEnabled,
|
||||
|
|
@ -360,8 +362,8 @@ private fun BookSettingsSwitches(
|
|||
)
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
headlineContent = { Text("Enable book rules") },
|
||||
supportingContent = { Text("Local rules run after global rules.") },
|
||||
headlineContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules)) },
|
||||
supportingContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules_desc)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.localRulesEnabled,
|
||||
|
|
@ -381,13 +383,13 @@ private fun InheritedGlobalRules(
|
|||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Inherited global rules",
|
||||
text = stringResource(R.string.tts_replacements_inherited_global_rules),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (globalRules.isEmpty()) {
|
||||
Text(
|
||||
text = "No global rules to inherit.",
|
||||
text = stringResource(R.string.tts_replacements_no_global_rules),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -395,9 +397,10 @@ private fun InheritedGlobalRules(
|
|||
}
|
||||
globalRules.forEach { rule ->
|
||||
val enabledHere = rule.id !in settings.disabledGlobalRuleIds
|
||||
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||
ListItem(
|
||||
headlineContent = { Text(rule.summaryText()) },
|
||||
supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") },
|
||||
headlineContent = { Text(rule.summaryText(silenceLabel)) },
|
||||
supportingContent = { Text(stringResource(if (enabledHere) R.string.tts_replacements_allowed_in_book else R.string.tts_replacements_disabled_for_book)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = enabledHere,
|
||||
|
|
@ -422,15 +425,16 @@ private fun SuggestionChips(
|
|||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Suggestions",
|
||||
text = stringResource(R.string.tts_replacements_suggestions),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
|
||||
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||
AssistChip(
|
||||
onClick = { onSuggestionClick(suggestion) },
|
||||
label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
label = { Text(suggestion.summaryText(silenceLabel), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
|
|
@ -456,8 +460,9 @@ private fun RuleEditorCard(
|
|||
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
|
||||
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
|
||||
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
|
||||
var previewInput by remember(initial.id) {
|
||||
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.")
|
||||
val defaultPreviewInput = stringResource(R.string.tts_replacements_preview_default)
|
||||
var previewInput by remember(initial.id, defaultPreviewInput) {
|
||||
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
|
||||
}
|
||||
|
||||
val draft = ReaderTtsReplacementRule(
|
||||
|
|
@ -490,7 +495,7 @@ private fun RuleEditorCard(
|
|||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (seedRule == null) "New replacement" else "Edit replacement",
|
||||
text = stringResource(if (seedRule == null) R.string.tts_replacements_new_replacement else R.string.tts_replacements_edit_replacement),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
|
@ -498,7 +503,7 @@ private fun RuleEditorCard(
|
|||
value = from,
|
||||
onValueChange = { from = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Replace") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
|
||||
singleLine = !isRegex,
|
||||
isError = !validation.isValid,
|
||||
supportingText = if (validation.message != null) {
|
||||
|
|
@ -515,7 +520,7 @@ private fun RuleEditorCard(
|
|||
value = to,
|
||||
onValueChange = { to = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Speak as") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_label_speak_as)) },
|
||||
singleLine = !isRegex,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
@ -523,7 +528,7 @@ private fun RuleEditorCard(
|
|||
FilterChip(
|
||||
selected = enabled,
|
||||
onClick = { enabled = !enabled },
|
||||
label = { Text("Enabled") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
|
||||
leadingIcon = if (enabled) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||
} else {
|
||||
|
|
@ -535,21 +540,21 @@ private fun RuleEditorCard(
|
|||
FilterChip(
|
||||
selected = isRegex,
|
||||
onClick = { isRegex = !isRegex },
|
||||
label = { Text("Regex") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
FilterChip(
|
||||
selected = wholeWord,
|
||||
onClick = { wholeWord = !wholeWord },
|
||||
label = { Text("Whole word") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
FilterChip(
|
||||
selected = matchCase,
|
||||
onClick = { matchCase = !matchCase },
|
||||
label = { Text("Match case") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -557,7 +562,7 @@ private fun RuleEditorCard(
|
|||
value = previewInput,
|
||||
onValueChange = { previewInput = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Preview input") },
|
||||
label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
|
||||
minLines = 2,
|
||||
)
|
||||
Text(
|
||||
|
|
@ -570,14 +575,14 @@ private fun RuleEditorCard(
|
|||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { onSave(draft) },
|
||||
enabled = validation.isValid,
|
||||
) {
|
||||
Text("Save")
|
||||
Text(stringResource(R.string.action_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -587,14 +592,14 @@ private fun RuleEditorCard(
|
|||
@Composable
|
||||
private fun ReplacementRuleList(
|
||||
rules: List<ReaderTtsReplacementRule>,
|
||||
emptyText: String,
|
||||
@StringRes emptyTextRes: Int,
|
||||
onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
|
||||
onEdit: (ReaderTtsReplacementRule) -> Unit,
|
||||
onDelete: (ReaderTtsReplacementRule) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Rules",
|
||||
text = stringResource(R.string.tts_replacements_rules),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
|
@ -606,7 +611,7 @@ private fun ReplacementRuleList(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = emptyText,
|
||||
text = stringResource(emptyTextRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -614,10 +619,11 @@ private fun ReplacementRuleList(
|
|||
return
|
||||
}
|
||||
rules.forEach { rule ->
|
||||
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = rule.summaryText(),
|
||||
text = rule.summaryText(silenceLabel),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
|
@ -632,10 +638,10 @@ private fun ReplacementRuleList(
|
|||
onCheckedChange = { onToggle(rule, it) },
|
||||
)
|
||||
IconButton(onClick = { onEdit(rule) }) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
|
||||
}
|
||||
IconButton(onClick = { onDelete(rule) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -648,16 +654,21 @@ private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsRep
|
|||
return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
|
||||
}
|
||||
|
||||
private fun ReaderTtsReplacementRule.summaryText(): String {
|
||||
val replacement = to.ifBlank { "silence" }
|
||||
private fun ReaderTtsReplacementRule.summaryText(silenceLabel: String): String {
|
||||
val replacement = to.ifBlank { silenceLabel }
|
||||
return "$from -> $replacement"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderTtsReplacementRule.optionSummary(): String {
|
||||
val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
|
||||
val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
|
||||
val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
|
||||
val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
|
||||
val parts = buildList {
|
||||
add(if (isRegex) "Regex" else "Plain text")
|
||||
if (wholeWord) add("whole word")
|
||||
if (matchCase) add("case-sensitive")
|
||||
add(if (isRegex) regexLabel else plainTextLabel)
|
||||
if (wholeWord) add(wholeWordLabel)
|
||||
if (matchCase) add(caseSensitiveLabel)
|
||||
}
|
||||
return parts.joinToString(" - ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,25 @@ package com.aryan.reader
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
data class AppLanguageOption(
|
||||
val tag: String?,
|
||||
@StringRes val labelRes: Int
|
||||
)
|
||||
|
||||
val systemAppLanguageOption = AppLanguageOption(null, R.string.language_system_default)
|
||||
|
||||
val supportedAppLanguageOptions = listOf(
|
||||
AppLanguageOption("en", R.string.language_english),
|
||||
AppLanguageOption("ar", R.string.language_arabic),
|
||||
AppLanguageOption("de", R.string.language_german),
|
||||
AppLanguageOption("tr", R.string.language_turkish),
|
||||
AppLanguageOption("fr", R.string.language_french),
|
||||
AppLanguageOption("ru", R.string.language_russian),
|
||||
AppLanguageOption("es", R.string.language_spanish)
|
||||
)
|
||||
|
||||
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
||||
|
||||
val AddBooksSource.labelRes: Int
|
||||
@StringRes get() = when (this) {
|
||||
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import android.widget.Toast
|
|||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -46,6 +48,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -69,6 +72,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -83,6 +87,10 @@ import androidx.compose.ui.window.PopupPositionProvider
|
|||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.getReaderTextureDataUri
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
|
@ -1026,6 +1034,8 @@ fun ChapterWebView(
|
|||
|
||||
// Custom Selection Menu Popup
|
||||
customMenuState?.let { state ->
|
||||
val configuration = LocalConfiguration.current
|
||||
val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp)
|
||||
val popupPositionProvider =
|
||||
remember(state.selectionBounds, density, state.isExistingHighlight) {
|
||||
object : PopupPositionProvider {
|
||||
|
|
@ -1035,30 +1045,23 @@ fun ChapterWebView(
|
|||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
val topMargin = with(density) { 16.dp.toPx() }.toInt()
|
||||
val bottomMargin = with(density) {
|
||||
val marginPx = with(density) { 16.dp.toPx() }
|
||||
val gapPx = with(density) {
|
||||
if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx()
|
||||
}.toInt()
|
||||
|
||||
var x = state.selectionBounds.centerX() - popupContentSize.width / 2
|
||||
|
||||
var y = state.selectionBounds.top - popupContentSize.height - topMargin
|
||||
if (y < with(density) { 24.dp.toPx() }.toInt()) {
|
||||
y = state.selectionBounds.bottom + bottomMargin
|
||||
}
|
||||
if (x < 0) x = 0
|
||||
if (x + popupContentSize.width > windowSize.width) {
|
||||
x = windowSize.width - popupContentSize.width
|
||||
}
|
||||
if (y + popupContentSize.height > windowSize.height) {
|
||||
y = windowSize.height - popupContentSize.height
|
||||
}
|
||||
if (y < 0) y = 0
|
||||
|
||||
return IntOffset(
|
||||
x.coerceIn(0, windowSize.width - popupContentSize.width),
|
||||
y.coerceIn(0, windowSize.height - popupContentSize.height)
|
||||
val placement = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height),
|
||||
popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height),
|
||||
selection = SharedSelectionMenuRect(
|
||||
left = state.selectionBounds.left.toFloat(),
|
||||
top = state.selectionBounds.top.toFloat(),
|
||||
right = state.selectionBounds.right.toFloat(),
|
||||
bottom = state.selectionBounds.bottom.toFloat()
|
||||
),
|
||||
marginPx = marginPx,
|
||||
gapPx = gapPx
|
||||
)
|
||||
return IntOffset(placement.x, placement.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1075,11 +1078,14 @@ fun ChapterWebView(
|
|||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(IntrinsicSize.Max)
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.heightIn(max = selectionMenuMaxHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -1087,8 +1093,8 @@ fun ChapterWebView(
|
|||
activeHighlightPalette.forEachIndexed { index, colorEnum ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(32.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(28.dp)
|
||||
.background(colorEnum.color, CircleShape)
|
||||
.pointerInput(colorEnum) {
|
||||
detectTapGestures(onTap = {
|
||||
|
|
@ -1115,7 +1121,7 @@ fun ChapterWebView(
|
|||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SpectrumButton(
|
||||
onClick = { showPaletteManager = true }, size = 32.dp
|
||||
onClick = { showPaletteManager = true }, size = 28.dp
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1123,7 +1129,7 @@ fun ChapterWebView(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ suspend fun summarizeBookContent(
|
|||
onFinish: () -> Unit
|
||||
) {
|
||||
if (content.isBlank()) {
|
||||
onError("The book content is empty.")
|
||||
onError(context.getString(R.string.ai_error_book_content_empty))
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ suspend fun summarizeBookContent(
|
|||
@Suppress("KotlinConstantConditions")
|
||||
if (BuildConfig.FLAVOR == "oss") {
|
||||
if (BuildConfig.IS_OFFLINE) {
|
||||
onError("AI features are unavailable in the offline OSS build.")
|
||||
onError(context.getString(R.string.ai_error_offline_oss))
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ suspend fun summarizeBookContent(
|
|||
}
|
||||
}
|
||||
if (!hasReceivedData) {
|
||||
onError("Failed to parse summary from server response.")
|
||||
onError(context.getString(R.string.ai_error_parse_summary))
|
||||
}
|
||||
} else {
|
||||
val errorBody = try {
|
||||
|
|
@ -162,12 +162,12 @@ suspend fun summarizeBookContent(
|
|||
} catch (_: Exception) { null }
|
||||
val errorDetail = try {
|
||||
JSONObject(errorBody.toString()).getString("detail")
|
||||
} catch (_: Exception) { "Could not fetch summary." }
|
||||
onError("Error: $responseCode. $errorDetail")
|
||||
} catch (_: Exception) { context.getString(R.string.ai_error_fetch_summary) }
|
||||
onError(context.getString(R.string.ai_error_with_code, responseCode, errorDetail))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Network error during summarization: ${e.message}")
|
||||
onError("Network error. Please check connection and server status.")
|
||||
onError(context.getString(R.string.ai_error_network_server))
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
onFinish()
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ fun PaginatedTextSelectionMenu(
|
|||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
|
||||
if (onHighlight != null) {
|
||||
HighlightColorRow(
|
||||
activeHighlightPalette = activeHighlightPalette,
|
||||
|
|
@ -531,7 +531,7 @@ fun PaginatedTextSelectionMenu(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
@ -539,23 +539,23 @@ fun PaginatedTextSelectionMenu(
|
|||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(64.dp)
|
||||
.width(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { action.onClick() }
|
||||
.padding(vertical = 8.dp),
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (action.imageVector != null) {
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
} else if (action.iconRes != null) {
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||
}
|
||||
}
|
||||
repeat(3 - rowActions.size) {
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
Spacer(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -582,7 +582,7 @@ fun HighlightColorRow(
|
|||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -591,8 +591,8 @@ fun HighlightColorRow(
|
|||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(32.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape) // 1. Clip shape for ripple
|
||||
.background(colorEnum.color) // 2. Apply background
|
||||
.clickable {
|
||||
|
|
@ -610,17 +610,17 @@ fun HighlightColorRow(
|
|||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_selected),
|
||||
tint = if (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onOpenPaletteManager != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
SpectrumButton(
|
||||
onClick = onOpenPaletteManager,
|
||||
size = 32.dp
|
||||
size = 28.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -650,7 +650,7 @@ fun PaginatedTextSelectionMenu(
|
|||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
|
||||
// 1. Colors Row
|
||||
if (onHighlight != null) {
|
||||
HighlightColorRow(
|
||||
|
|
@ -731,7 +731,7 @@ fun PaginatedTextSelectionMenu(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
@ -739,22 +739,22 @@ fun PaginatedTextSelectionMenu(
|
|||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(64.dp)
|
||||
.width(56.dp)
|
||||
.clickable { action.onClick() }
|
||||
.padding(vertical = 8.dp),
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (action.imageVector != null) {
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
} else if (action.iconRes != null) {
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||
}
|
||||
}
|
||||
repeat(3 - rowActions.size) {
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
Spacer(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import android.graphics.Canvas
|
|||
import android.os.Build
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
|
@ -153,26 +154,26 @@ import kotlinx.coroutines.withContext
|
|||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class ReaderTool(val title: String, val category: String) {
|
||||
DICTIONARY("External Apps", "Top Bar"),
|
||||
THEME("Theme Settings", "Top Bar"),
|
||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||
TOC("Sidebar", "Bottom Bar"),
|
||||
FORMAT("Text Formatting", "Bottom Bar"),
|
||||
SEARCH("Search", "Bottom Bar"),
|
||||
AI_FEATURES("AI Features", "Bottom Bar"),
|
||||
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
||||
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
|
||||
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
|
||||
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||
FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"),
|
||||
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
|
||||
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
|
||||
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
|
||||
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
|
||||
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||
VOLUME_SCROLL(R.string.menu_volume_button_scrolling, "Overflow Menu"),
|
||||
PAGE_TURN_ANIM(R.string.menu_realistic_page_turns, "Overflow Menu"),
|
||||
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
|
||||
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
||||
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
||||
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
||||
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
|
||||
}
|
||||
|
||||
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||
|
|
@ -182,7 +183,8 @@ data class FlatToolItem(
|
|||
val type: FlatItemType,
|
||||
val tool: ReaderTool? = null,
|
||||
val section: ToolbarSection? = null,
|
||||
val title: String? = null
|
||||
val title: String? = null,
|
||||
@StringRes val titleRes: Int? = null
|
||||
)
|
||||
|
||||
fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
||||
|
|
@ -197,7 +199,7 @@ fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
|||
}
|
||||
|
||||
ToolbarSection.entries.forEach { section ->
|
||||
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||
|
||||
val tools = sectionMap[section] ?: emptyList()
|
||||
if (tools.isEmpty()) {
|
||||
|
|
@ -266,6 +268,51 @@ private val epubToolbarTools = setOf(
|
|||
ReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
internal fun defaultReaderHiddenTools(): Set<String> = setOf(ReaderTool.SCREEN_ORIENTATION.name)
|
||||
|
||||
internal fun defaultReaderToolOrder(): List<ReaderTool> = ReaderTool.entries.toList()
|
||||
|
||||
internal fun defaultReaderBottomTools(): Set<String> {
|
||||
return ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
}
|
||||
|
||||
internal fun buildReaderToolbarItems(
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<ReaderTool>,
|
||||
bottomTools: Set<String>
|
||||
): List<FlatToolItem> {
|
||||
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in epubToolbarTools }
|
||||
|
||||
val list = mutableListOf<FlatToolItem>()
|
||||
|
||||
ToolbarSection.entries.forEach { section ->
|
||||
val tools = when (section) {
|
||||
ToolbarSection.TOP -> topTools
|
||||
ToolbarSection.BOTTOM -> bottomToolsList
|
||||
ToolbarSection.HIDDEN -> hiddenToolsList
|
||||
}
|
||||
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||
if (tools.isEmpty()) {
|
||||
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||
} else {
|
||||
tools.forEach { tool ->
|
||||
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
|
||||
moreTools.forEach { tool ->
|
||||
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpubReaderTopBar(
|
||||
isVisible: Boolean,
|
||||
|
|
@ -478,7 +525,7 @@ fun EpubReaderTopBar(
|
|||
|
||||
if (hiddenToolbarTools.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Hidden tools") },
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
|
|
@ -1717,37 +1764,11 @@ fun CustomizeToolsSheet(
|
|||
|
||||
var flatItems by remember {
|
||||
mutableStateOf(
|
||||
run {
|
||||
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in epubToolbarTools }
|
||||
|
||||
val list = mutableListOf<FlatToolItem>()
|
||||
|
||||
ToolbarSection.entries.forEach { section ->
|
||||
val tools = when(section) {
|
||||
ToolbarSection.TOP -> topTools
|
||||
ToolbarSection.BOTTOM -> bottomToolsList
|
||||
ToolbarSection.HIDDEN -> hiddenToolsList
|
||||
}
|
||||
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||
if (tools.isEmpty()) {
|
||||
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||
} else {
|
||||
tools.forEach { tool ->
|
||||
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu"))
|
||||
moreTools.forEach { tool ->
|
||||
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
|
||||
}
|
||||
list
|
||||
}
|
||||
buildReaderToolbarItems(
|
||||
hiddenTools = hiddenTools,
|
||||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1812,6 +1833,22 @@ fun CustomizeToolsSheet(
|
|||
}
|
||||
}
|
||||
|
||||
val resetToDefault = {
|
||||
val defaultHiddenTools = defaultReaderHiddenTools()
|
||||
val defaultToolOrder = defaultReaderToolOrder()
|
||||
val defaultBottomTools = defaultReaderBottomTools()
|
||||
|
||||
localHiddenTools = defaultHiddenTools
|
||||
flatItems = buildReaderToolbarItems(
|
||||
hiddenTools = defaultHiddenTools,
|
||||
toolOrder = defaultToolOrder,
|
||||
bottomTools = defaultBottomTools
|
||||
)
|
||||
onUpdate(defaultHiddenTools)
|
||||
onPlacementUpdate(defaultBottomTools)
|
||||
onOrderUpdate(defaultToolOrder)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
|
|
@ -1828,12 +1865,17 @@ fun CustomizeToolsSheet(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Customize Toolbar",
|
||||
text = stringResource(R.string.title_customize_toolbar),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = resetToDefault) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.action_reset))
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||
}
|
||||
|
|
@ -1866,8 +1908,9 @@ fun CustomizeToolsSheet(
|
|||
) {
|
||||
when (item.type) {
|
||||
FlatItemType.SECTION_HEADER -> {
|
||||
val titleRes = item.titleRes
|
||||
Text(
|
||||
text = item.title ?: "",
|
||||
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
|
@ -1883,7 +1926,7 @@ fun CustomizeToolsSheet(
|
|||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
FlatItemType.TOOL -> {
|
||||
|
|
@ -1900,8 +1943,9 @@ fun CustomizeToolsSheet(
|
|||
)
|
||||
}
|
||||
FlatItemType.MORE_HEADER -> {
|
||||
val titleRes = item.titleRes
|
||||
Text(
|
||||
text = item.title ?: "More menu",
|
||||
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
||||
|
|
@ -1909,7 +1953,7 @@ fun CustomizeToolsSheet(
|
|||
}
|
||||
FlatItemType.MORE_TOOL -> {
|
||||
MoreToolVisibilityRow(
|
||||
title = item.tool!!.title,
|
||||
title = stringResource(item.tool!!.titleRes),
|
||||
visible = !localHiddenTools.contains(item.tool.name),
|
||||
onToggle = {
|
||||
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
||||
|
|
@ -1952,14 +1996,14 @@ private fun ToolbarDragRow(
|
|||
ToolPreviewIcon(tool)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = tool.title,
|
||||
text = stringResource(tool.titleRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Menu,
|
||||
contentDescription = "Drag to reorder",
|
||||
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
|
|
@ -2007,25 +2051,26 @@ private fun MoreToolVisibilityRow(
|
|||
}
|
||||
}
|
||||
|
||||
enum class ToolbarSection(val title: String) {
|
||||
TOP("Top Bar"),
|
||||
BOTTOM("Bottom Bar"),
|
||||
HIDDEN("Hidden Tools")
|
||||
enum class ToolbarSection(@StringRes val titleRes: Int) {
|
||||
TOP(R.string.toolbar_top_bar),
|
||||
BOTTOM(R.string.toolbar_bottom_bar),
|
||||
HIDDEN(R.string.toolbar_hidden_tools)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolPreviewIcon(tool: ReaderTool) {
|
||||
val title = stringResource(tool.titleRes)
|
||||
when (tool) {
|
||||
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2050,7 +2095,7 @@ private fun HiddenEpubToolMenuItem(
|
|||
else -> true
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(tool.title) },
|
||||
text = { Text(stringResource(tool.titleRes)) },
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
showMoreMenu()
|
||||
|
|
@ -2198,7 +2243,11 @@ fun TtsOverlayControls(
|
|||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device",
|
||||
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
stringResource(R.string.tts_mode_cloud_ai)
|
||||
} else {
|
||||
stringResource(R.string.tts_mode_device_native)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
|
@ -2211,7 +2260,7 @@ fun TtsOverlayControls(
|
|||
) {
|
||||
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
|
||||
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
|
||||
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: stringResource(R.string.label_default)
|
||||
|
||||
Text(
|
||||
voiceName,
|
||||
|
|
|
|||
|
|
@ -253,6 +253,15 @@ private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
|
|||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||
|
||||
private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String {
|
||||
return text
|
||||
.replace('\n', ' ')
|
||||
.replace('\r', ' ')
|
||||
.replace('\t', ' ')
|
||||
.take(maxLength)
|
||||
}
|
||||
|
||||
private fun View.bottomRoundedCornerRadiusPx(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
|
||||
|
|
@ -303,7 +312,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
|||
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name
|
||||
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
|
||||
prefs.edit {
|
||||
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
|
|
@ -325,7 +334,7 @@ private fun loadToolOrder(context: Context): List<ReaderTool> {
|
|||
?.filter { it.isNotBlank() }
|
||||
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
|
||||
.orEmpty()
|
||||
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
|
||||
return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct()
|
||||
}
|
||||
|
||||
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
|
||||
|
|
@ -337,8 +346,8 @@ private fun loadBottomTools(context: Context): Set<String> {
|
|||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getStringSet(
|
||||
BOTTOM_TOOLS_KEY,
|
||||
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
defaultReaderBottomTools()
|
||||
) ?: defaultReaderBottomTools()
|
||||
}
|
||||
|
||||
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
||||
|
|
@ -2413,13 +2422,13 @@ fun EpubReaderHost(
|
|||
val targetPageIndex = pageIndex
|
||||
val targetCfi = cfi.orEmpty()
|
||||
if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) {
|
||||
return "Page ${targetPageIndex + 1}"
|
||||
return context.getString(R.string.pdf_page_short, targetPageIndex + 1)
|
||||
}
|
||||
val chapter = chapterIndex
|
||||
return if (chapter != null) {
|
||||
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}"
|
||||
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chapter_number_format, chapter + 1)
|
||||
} else {
|
||||
"Location"
|
||||
context.getString(R.string.location_generic)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3262,7 +3271,7 @@ fun EpubReaderHost(
|
|||
} ?: run {
|
||||
isSummarizationLoading = false
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "WebView not available.")
|
||||
SummarizationResult(error = context.getString(R.string.error_webview_not_available))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3333,7 +3342,7 @@ fun EpubReaderHost(
|
|||
if (fullSummary.isNotBlank()) {
|
||||
val chapterTitle =
|
||||
chapters.getOrNull(chapterIndex)?.title
|
||||
?: "Chapter ${chapterIndex + 1}"
|
||||
?: context.getString(R.string.chapter_number_format, chapterIndex + 1)
|
||||
summaryCacheManager.saveSummary(
|
||||
epubBook.title,
|
||||
chapterIndex,
|
||||
|
|
@ -3344,12 +3353,12 @@ fun EpubReaderHost(
|
|||
})
|
||||
} else {
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "Could not get chapter content.")
|
||||
SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content))
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
} else {
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "Could not determine current chapter.")
|
||||
SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter))
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
}
|
||||
|
|
@ -4207,7 +4216,7 @@ fun EpubReaderHost(
|
|||
isSummarizationLoading = false
|
||||
val fullSummary = finalSummaryBuilder.toString()
|
||||
if (fullSummary.isNotBlank()) {
|
||||
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}"
|
||||
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: context.getString(R.string.chapter_number_format, chapterIndexToSave + 1)
|
||||
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
|
||||
}
|
||||
}
|
||||
|
|
@ -4362,7 +4371,7 @@ fun EpubReaderHost(
|
|||
)
|
||||
val chapterTitle =
|
||||
epubBook.chapters.getOrNull(currentChapterIndex)?.title
|
||||
?: "Unknown Chapter"
|
||||
?: context.getString(R.string.unknown_chapter)
|
||||
val newBookmark = Bookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
|
|
@ -4571,15 +4580,33 @@ fun EpubReaderHost(
|
|||
highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1)
|
||||
},
|
||||
onHighlightCreated = { cfi, text, colorId ->
|
||||
val chapterIndex = currentChapterInPaginatedMode ?: 0
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " +
|
||||
"existingCount=${userHighlights.size} textLen=${text.length} " +
|
||||
"text='${epubHighlightDiagSnippet(text)}'"
|
||||
)
|
||||
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
|
||||
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
|
||||
val finalCfi = processAndAddHighlight(
|
||||
newCfi = cfi,
|
||||
newText = text,
|
||||
newColor = color,
|
||||
chapterIndex = currentChapterInPaginatedMode ?: 0,
|
||||
chapterIndex = chapterIndex,
|
||||
currentList = userHighlights
|
||||
)
|
||||
val savedHighlight = userHighlights.find {
|
||||
it.chapterIndex == chapterIndex && it.cfi == finalCfi
|
||||
}
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"persist_result finalCfi=$finalCfi chapter=$chapterIndex " +
|
||||
"savedId=${savedHighlight?.id} totalCount=${userHighlights.size} " +
|
||||
"matchingCfiCount=${userHighlights.count { it.chapterIndex == chapterIndex && it.cfi == finalCfi }} " +
|
||||
"locatorStart=${savedHighlight?.locator?.startOffset} " +
|
||||
"locatorEnd=${savedHighlight?.locator?.endOffset} " +
|
||||
"locatorPage=${savedHighlight?.locator?.pageIndex} " +
|
||||
"locatorCfi=${savedHighlight?.locator?.cfi}"
|
||||
)
|
||||
if (pendingNoteForNewHighlight) {
|
||||
pendingNoteForNewHighlight = false
|
||||
highlightToNoteCfi = finalCfi
|
||||
|
|
@ -4616,9 +4643,24 @@ fun EpubReaderHost(
|
|||
)?.let { recordEpubJump(it) }
|
||||
},
|
||||
onHighlightDeleted = { cfi ->
|
||||
val beforeCount = userHighlights.size
|
||||
val toRemove = userHighlights.find { it.cfi == cfi }
|
||||
if (toRemove != null) {
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"delete_request cfi=$cfi matchedId=${toRemove.id} " +
|
||||
"matchedChapter=${toRemove.chapterIndex} beforeCount=$beforeCount " +
|
||||
"locatorStart=${toRemove.locator.startOffset} " +
|
||||
"locatorEnd=${toRemove.locator.endOffset}"
|
||||
)
|
||||
userHighlights.remove(toRemove)
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"delete_result cfi=$cfi removedId=${toRemove.id} " +
|
||||
"afterCount=${userHighlights.size}"
|
||||
)
|
||||
} else {
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w(
|
||||
"delete_request cfi=$cfi matchedId=null beforeCount=$beforeCount"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -4745,7 +4787,7 @@ fun EpubReaderHost(
|
|||
val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi
|
||||
|
||||
val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
||||
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: "Unknown Chapter"
|
||||
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: context.getString(R.string.unknown_chapter)
|
||||
val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: ""
|
||||
|
||||
val pageInChapter: Int?
|
||||
|
|
@ -4862,7 +4904,7 @@ fun EpubReaderHost(
|
|||
val textToShow = if (bookPaginator != null && chapterIndex != null) {
|
||||
val chapterTitle =
|
||||
chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim()
|
||||
?: "Chapter"
|
||||
?: stringResource(R.string.chapter)
|
||||
val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex]
|
||||
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex]
|
||||
|
||||
|
|
@ -4874,7 +4916,7 @@ fun EpubReaderHost(
|
|||
chapterTitle
|
||||
}
|
||||
} else {
|
||||
"Page ${paginatedPagerState.currentPage + 1}/${paginatedPagerState.pageCount}"
|
||||
stringResource(R.string.page_number_of_total, paginatedPagerState.currentPage + 1, paginatedPagerState.pageCount)
|
||||
}
|
||||
|
||||
Text(
|
||||
|
|
@ -5568,7 +5610,7 @@ fun EpubReaderHost(
|
|||
onVerticalMarginChange = { currentVerticalMargin = it },
|
||||
currentFont = currentFontFamily,
|
||||
currentCustomFontName = if(currentCustomFontPath != null) {
|
||||
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
|
||||
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: stringResource(R.string.custom_font_fallback)
|
||||
} else null,
|
||||
onFontOptionClick = { showFontSelectionSheet = true },
|
||||
currentTextAlign = currentTextAlign,
|
||||
|
|
@ -5649,7 +5691,7 @@ fun EpubReaderHost(
|
|||
credits = credits,
|
||||
isProUser = isProUser,
|
||||
currentChapterIndex = effectiveCurrentChapterIndex,
|
||||
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}",
|
||||
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: context.getString(R.string.chapter_number_format, effectiveCurrentChapterIndex + 1),
|
||||
showAiHubSheet = showAiHubSheet,
|
||||
onGenerateSummary = handleGenerateSummary,
|
||||
onGenerateRecap = handleGenerateRecap,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ package com.aryan.reader.epubreader
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
|
@ -161,27 +162,28 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam
|
|||
LEXEND("lexend", "Lexend", "Lexend")
|
||||
}
|
||||
|
||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) {
|
||||
DEFAULT("default", "", R.drawable.format_align_left, "Default"),
|
||||
LEFT("left", "left", R.drawable.format_align_left, "Left"),
|
||||
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
|
||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) {
|
||||
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default),
|
||||
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left),
|
||||
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right),
|
||||
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify)
|
||||
}
|
||||
|
||||
enum class SystemUiMode(val id: Int, val title: String) {
|
||||
DEFAULT(0, "Always Show"),
|
||||
SYNC(1, "Sync with Menus"),
|
||||
HIDDEN(2, "Always Hide")
|
||||
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) {
|
||||
DEFAULT(0, R.string.label_always_show),
|
||||
SYNC(1, R.string.label_sync_with_menus),
|
||||
HIDDEN(2, R.string.label_always_hide)
|
||||
}
|
||||
|
||||
enum class PageInfoMode(val id: Int, val title: String) {
|
||||
DEFAULT(0, "Always Show"),
|
||||
SYNC(1, "Sync with Menus"),
|
||||
HIDDEN(2, "Always Hide")
|
||||
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) {
|
||||
DEFAULT(0, R.string.label_always_show),
|
||||
SYNC(1, R.string.label_sync_with_menus),
|
||||
HIDDEN(2, R.string.label_always_hide)
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||
BOTTOM(0, "Bottom"),
|
||||
TOP(1, "Top")
|
||||
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) {
|
||||
BOTTOM(0, R.string.label_bottom),
|
||||
TOP(1, R.string.label_top)
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
|
|
@ -649,6 +651,7 @@ fun ReaderTextFormatPanel(
|
|||
Row {
|
||||
ReaderTextAlign.entries.forEach { align ->
|
||||
val isSelected = currentTextAlign == align
|
||||
val alignDisplayName = stringResource(align.displayNameRes)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
|
|
@ -661,12 +664,12 @@ fun ReaderTextFormatPanel(
|
|||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = align.iconResId),
|
||||
contentDescription = align.displayName,
|
||||
contentDescription = alignDisplayName,
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = align.displayName,
|
||||
text = alignDisplayName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 11.sp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
|
@ -923,7 +926,7 @@ fun VisualOptionsSheet(
|
|||
options = SystemUiMode.entries,
|
||||
selectedOption = systemUiMode,
|
||||
onOptionSelected = onSystemUiModeChange,
|
||||
getLabel = { it.title }
|
||||
getLabel = { stringResource(it.titleRes) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
|
@ -936,7 +939,7 @@ fun VisualOptionsSheet(
|
|||
options = PageInfoMode.entries,
|
||||
selectedOption = pageInfoMode,
|
||||
onOptionSelected = onPageInfoModeChange,
|
||||
getLabel = { it.title }
|
||||
getLabel = { stringResource(it.titleRes) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -946,7 +949,7 @@ fun VisualOptionsSheet(
|
|||
options = PageInfoPosition.entries,
|
||||
selectedOption = pageInfoPosition,
|
||||
onOptionSelected = onPageInfoPositionChange,
|
||||
getLabel = { it.title }
|
||||
getLabel = { stringResource(it.titleRes) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
|
@ -1003,7 +1006,7 @@ fun <T> OptionSegmentedControl(
|
|||
options: List<T>,
|
||||
selectedOption: T,
|
||||
onOptionSelected: (T) -> Unit,
|
||||
getLabel: (T) -> String
|
||||
getLabel: @Composable (T) -> String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import android.webkit.JavascriptInterface
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
||||
|
|
@ -52,6 +53,9 @@ class InteractiveWebView(
|
|||
|
||||
companion object {
|
||||
private const val DRAG_SENSITIVITY_PX = 20f
|
||||
private const val SELECTION_MENU_INITIAL_DELAY_MS = 120L
|
||||
private const val SELECTION_MENU_RETRY_DELAY_MS = 140L
|
||||
private const val SELECTION_MENU_RETRY_COUNT = 8
|
||||
}
|
||||
|
||||
private var startY: Float = 0f
|
||||
|
|
@ -60,16 +64,26 @@ class InteractiveWebView(
|
|||
|
||||
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
||||
private var scrollStopRunnable: Runnable? = null
|
||||
private var selectionMenuRunnable: Runnable? = null
|
||||
private var activeSelectionActionMode: ActionMode? = null
|
||||
private var selectionMenuShownForActiveMode = false
|
||||
|
||||
init {
|
||||
addJavascriptInterface(ReaderSelectionBridge(), "ReaderSelectionBridge")
|
||||
}
|
||||
|
||||
private fun clearPendingSelectionWork() {
|
||||
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
scrollStopRunnable = null
|
||||
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
selectionMenuRunnable = null
|
||||
}
|
||||
|
||||
private fun startLocalSelectionActionMode(): ActionMode {
|
||||
private fun startLocalSelectionActionMode(scheduleMenu: Boolean = true): ActionMode {
|
||||
activeSelectionActionMode?.let { existingMode ->
|
||||
showCustomSelectionMenuFromCurrentSelection(existingMode)
|
||||
if (scheduleMenu) {
|
||||
scheduleCustomSelectionMenuFromCurrentSelection(existingMode)
|
||||
}
|
||||
return existingMode
|
||||
}
|
||||
|
||||
|
|
@ -78,10 +92,14 @@ class InteractiveWebView(
|
|||
if (activeSelectionActionMode === localMode) {
|
||||
activeSelectionActionMode = null
|
||||
}
|
||||
selectionMenuShownForActiveMode = false
|
||||
onHideCustomSelectionMenu()
|
||||
}
|
||||
selectionMenuShownForActiveMode = false
|
||||
activeSelectionActionMode = localMode
|
||||
showCustomSelectionMenuFromCurrentSelection(localMode)
|
||||
if (scheduleMenu) {
|
||||
scheduleCustomSelectionMenuFromCurrentSelection(localMode)
|
||||
}
|
||||
return localMode
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +108,56 @@ class InteractiveWebView(
|
|||
activeSelectionActionMode = null
|
||||
}
|
||||
|
||||
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
|
||||
private fun scheduleCustomSelectionMenuFromCurrentSelection(
|
||||
mode: ActionMode,
|
||||
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
|
||||
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
|
||||
) {
|
||||
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
selectionMenuRunnable = Runnable {
|
||||
selectionMenuRunnable = null
|
||||
showCustomSelectionMenuFromCurrentSelection(mode, remainingRetries)
|
||||
}
|
||||
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
|
||||
}
|
||||
|
||||
private fun scheduleSelectionActionModeRefreshAfterTouch(
|
||||
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
|
||||
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
|
||||
) {
|
||||
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
selectionMenuRunnable = Runnable {
|
||||
selectionMenuRunnable = null
|
||||
activeSelectionActionMode?.let { mode ->
|
||||
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
|
||||
return@Runnable
|
||||
}
|
||||
evaluateJavascript("(function() { var s = window.getSelection && window.getSelection(); return s ? s.toString().trim() : ''; })();") { result ->
|
||||
val selectedText = result?.removeSurrounding("\"")
|
||||
if (!selectedText.isNullOrBlank() && activeSelectionActionMode == null) {
|
||||
Timber.d("CustomSelection: selection exists after touch-up. Starting local action mode.")
|
||||
startLocalSelectionActionMode()
|
||||
} else {
|
||||
activeSelectionActionMode?.let { mode ->
|
||||
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
|
||||
return@evaluateJavascript
|
||||
}
|
||||
if (remainingRetries > 0) {
|
||||
scheduleSelectionActionModeRefreshAfterTouch(
|
||||
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||
remainingRetries = remainingRetries - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
|
||||
}
|
||||
|
||||
private fun showCustomSelectionMenuFromCurrentSelection(
|
||||
mode: ActionMode,
|
||||
remainingRetries: Int
|
||||
) {
|
||||
val jsToGetSelectionDetails = """
|
||||
(function() {
|
||||
var selection = window.getSelection();
|
||||
|
|
@ -99,20 +166,44 @@ class InteractiveWebView(
|
|||
return null;
|
||||
}
|
||||
var range = selection.getRangeAt(0);
|
||||
var rect = range.getBoundingClientRect();
|
||||
|
||||
// If getBoundingClientRect returns all zeros, try getClientRects()
|
||||
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
||||
var clientRects = range.getClientRects();
|
||||
if (clientRects.length > 0) {
|
||||
rect = clientRects[0]; // Use the first rect
|
||||
} else {
|
||||
return null; // No valid rect found
|
||||
var viewportLeft = 0;
|
||||
var viewportTop = 0;
|
||||
var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []);
|
||||
rects = rects.filter(function(rect) {
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||
return rect.right >= viewportLeft &&
|
||||
rect.left <= viewportRight &&
|
||||
rect.bottom >= viewportTop &&
|
||||
rect.top <= viewportBottom;
|
||||
});
|
||||
var rect = null;
|
||||
if (rects.length > 0) {
|
||||
var firstRect = rects[0];
|
||||
rect = rects.reduce(function(acc, item) {
|
||||
return {
|
||||
left: Math.min(acc.left, item.left),
|
||||
top: Math.min(acc.top, item.top),
|
||||
right: Math.max(acc.right, item.right),
|
||||
bottom: Math.max(acc.bottom, item.bottom)
|
||||
};
|
||||
}, {
|
||||
left: firstRect.left,
|
||||
top: firstRect.top,
|
||||
right: firstRect.right,
|
||||
bottom: firstRect.bottom
|
||||
});
|
||||
rect.width = rect.right - rect.left;
|
||||
rect.height = rect.bottom - rect.top;
|
||||
} else {
|
||||
rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null;
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the rect has some dimension
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -129,13 +220,28 @@ class InteractiveWebView(
|
|||
""".trimIndent()
|
||||
|
||||
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
||||
fun retryOrFinish(message: String) {
|
||||
Timber.d(message)
|
||||
if (selectionMenuShownForActiveMode) {
|
||||
return
|
||||
}
|
||||
if (activeSelectionActionMode === mode && remainingRetries > 0) {
|
||||
scheduleCustomSelectionMenuFromCurrentSelection(
|
||||
mode = mode,
|
||||
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||
remainingRetries = remainingRetries - 1
|
||||
)
|
||||
} else {
|
||||
mode.finish()
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSelectionActionMode !== mode) {
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
||||
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
||||
mode.finish()
|
||||
retryOrFinish("CustomSelection: JS returned null or invalid for selection details. Retries left: $remainingRetries")
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
|
|
@ -148,8 +254,7 @@ class InteractiveWebView(
|
|||
val selectedText = selectionDetails.getString("text")
|
||||
|
||||
if (selectedText.isBlank()) {
|
||||
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
||||
mode.finish()
|
||||
retryOrFinish("CustomSelection: Selected text is blank after JS processing. Retries left: $remainingRetries")
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
|
|
@ -161,8 +266,7 @@ class InteractiveWebView(
|
|||
val jsHeight = selectionDetails.getDouble("height")
|
||||
|
||||
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
||||
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
||||
mode.finish()
|
||||
retryOrFinish("CustomSelection: JS returned a zero-area rect. Retries left: $remainingRetries. Left: $jsLeft, Top: $jsTop")
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
|
|
@ -181,20 +285,85 @@ class InteractiveWebView(
|
|||
)
|
||||
|
||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||
mode.finish()
|
||||
retryOrFinish("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. Retries left: $remainingRetries. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
||||
|
||||
selectionMenuShownForActiveMode = true
|
||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||
mode.finish()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
||||
if (selectionMenuShownForActiveMode) {
|
||||
return@evaluateJavascript
|
||||
}
|
||||
if (remainingRetries > 0) {
|
||||
scheduleCustomSelectionMenuFromCurrentSelection(
|
||||
mode = mode,
|
||||
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||
remainingRetries = remainingRetries - 1
|
||||
)
|
||||
} else {
|
||||
mode.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomSelectionMenuFromSelectionDetailsJson(
|
||||
mode: ActionMode,
|
||||
rawJson: String
|
||||
) {
|
||||
if (activeSelectionActionMode !== mode) return
|
||||
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||
selectionMenuRunnable = null
|
||||
|
||||
try {
|
||||
val selectionDetails = JSONObject(rawJson)
|
||||
val selectedText = selectionDetails.getString("text")
|
||||
if (selectedText.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
val jsLeft = selectionDetails.getDouble("left")
|
||||
val jsTop = selectionDetails.getDouble("top")
|
||||
val jsRight = selectionDetails.getDouble("right")
|
||||
val jsBottom = selectionDetails.getDouble("bottom")
|
||||
val jsWidth = selectionDetails.getDouble("width")
|
||||
val jsHeight = selectionDetails.getDouble("height")
|
||||
|
||||
if (jsWidth <= 0.0 || jsHeight <= 0.0) {
|
||||
return
|
||||
}
|
||||
|
||||
val density = context.resources.displayMetrics.density
|
||||
val webViewLocation = IntArray(2)
|
||||
getLocationOnScreen(webViewLocation)
|
||||
val webViewX = webViewLocation[0]
|
||||
val webViewY = webViewLocation[1]
|
||||
|
||||
val selectionRectScreen = Rect(
|
||||
(webViewX + jsLeft * density).toInt(),
|
||||
(webViewY + jsTop * density).toInt(),
|
||||
(webViewX + jsRight * density).toInt(),
|
||||
(webViewY + jsBottom * density).toInt()
|
||||
)
|
||||
|
||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||
Timber.d("CustomSelection: Bridge supplied invalid rect: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom")
|
||||
return
|
||||
}
|
||||
|
||||
Timber.d("CustomSelection: Bridge selected text '$selectedText', Screen Rect: $selectionRectScreen")
|
||||
selectionMenuShownForActiveMode = true
|
||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||
mode.finish()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "CustomSelection: Error parsing bridge selection details: '$rawJson'")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -292,6 +461,8 @@ class InteractiveWebView(
|
|||
if (wasDragging) {
|
||||
Timber.d("Drag operation ended, enabling text selection.")
|
||||
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
|
||||
} else if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
scheduleSelectionActionModeRefreshAfterTouch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -306,12 +477,14 @@ class InteractiveWebView(
|
|||
|
||||
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
|
||||
// selections use the app's Compose popup without starting the platform toolbar.
|
||||
override fun startActionMode(originalCallback: ActionMode.Callback): ActionMode? {
|
||||
Timber.d("CustomSelection: handling primary action mode locally.")
|
||||
return startLocalSelectionActionMode()
|
||||
}
|
||||
|
||||
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
||||
if (type == ActionMode.TYPE_FLOATING) {
|
||||
Timber.d("CustomSelection: handling floating action mode locally.")
|
||||
return startLocalSelectionActionMode()
|
||||
}
|
||||
return super.startActionMode(originalCallback, type)
|
||||
Timber.d("CustomSelection: handling action mode locally. Type: $type")
|
||||
return startLocalSelectionActionMode()
|
||||
}
|
||||
|
||||
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||
|
|
@ -392,4 +565,14 @@ class InteractiveWebView(
|
|||
|
||||
override fun getMenuInflater(): MenuInflater = menuInflater
|
||||
}
|
||||
|
||||
private inner class ReaderSelectionBridge {
|
||||
@JavascriptInterface
|
||||
fun onSelectionChanged(selectionJson: String) {
|
||||
post {
|
||||
val mode = startLocalSelectionActionMode(scheduleMenu = false)
|
||||
showCustomSelectionMenuFromSelectionDetailsJson(mode, selectionJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ package com.aryan.reader.feedback
|
|||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import timber.log.Timber
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aryan.reader.AuthRepository
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.data.FeedbackMessage
|
||||
import com.aryan.reader.data.FeedbackRepository
|
||||
import com.aryan.reader.data.FeedbackThread
|
||||
|
|
@ -61,11 +63,15 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
private var messagesListener: Any? = null
|
||||
private val currentUser = authRepository.getSignedInUser()
|
||||
|
||||
private fun string(@StringRes resId: Int, vararg args: Any?): String {
|
||||
return getApplication<Application>().getString(resId, *args)
|
||||
}
|
||||
|
||||
init {
|
||||
if (currentUser != null) {
|
||||
startListeningToThreads(currentUser.uid)
|
||||
} else {
|
||||
_uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_required)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +133,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
fun onStartCreateTicket() {
|
||||
if (currentUser == null) {
|
||||
_uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_submit)) }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(isCreatingTicket = true) }
|
||||
|
|
@ -148,7 +154,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
fun onNewTicketImagesSelected(uris: List<Uri>) {
|
||||
val current = _uiState.value.newTicketAttachments
|
||||
if (current.size + uris.size > 3) {
|
||||
_uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_ticket_image_limit)) }
|
||||
return
|
||||
}
|
||||
validateAndAddImages(uris) { validUris ->
|
||||
|
|
@ -167,7 +173,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
fun onChatImagesSelected(uris: List<Uri>) {
|
||||
val current = _uiState.value.chatInputAttachments
|
||||
if (current.size + uris.size > 5) {
|
||||
_uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_message_image_limit)) }
|
||||
return
|
||||
}
|
||||
validateAndAddImages(uris) { validUris ->
|
||||
|
|
@ -186,7 +192,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
for (uri in uris) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize > maxFileSize) {
|
||||
_uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_images_size_limit)) }
|
||||
return
|
||||
}
|
||||
validUris.add(uri)
|
||||
|
|
@ -219,7 +225,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
onThreadSelected(threadId)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "ViewModel: Error submitting ticket")
|
||||
_uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") }
|
||||
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_create_ticket, e.message.orEmpty())) }
|
||||
} finally {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
|
|
@ -271,7 +277,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
Timber.e(e, "ViewModel: Error sending message")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Failed to send: ${e.message}",
|
||||
errorMessage = string(R.string.feedback_error_send, e.message.orEmpty()),
|
||||
// Updated reference to id
|
||||
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
|
||||
chatInputMessage = textToSend
|
||||
|
|
@ -290,4 +296,4 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
|||
repository.removeListener(messagesListener)
|
||||
repository.removeListener(threadsListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
||||
import com.aryan.reader.shared.opds.SharedOpdsSearch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -54,7 +55,12 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load feed: ${e.message}") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = getApplication<Application>().getString(R.string.opds_error_load_feed, e.message.orEmpty())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +89,8 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val response = client.newCall(request).execute()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body ?: throw Exception("Empty body")
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
|
|
@ -121,11 +128,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
} else {
|
||||
Timber.e("Download failed: ${response.code}")
|
||||
_uiState.update { it.copy(errorMessage = "Download failed: ${response.message}") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Download error")
|
||||
_uiState.update { it.copy(errorMessage = "Download error: ${e.message}") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) }
|
||||
} finally {
|
||||
_downloadingState.update { it - entry.id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,8 +255,17 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
|||
|
||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
||||
|
||||
private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String {
|
||||
return text
|
||||
.replace('\n', ' ')
|
||||
.replace('\r', ' ')
|
||||
.replace('\t', ' ')
|
||||
.take(maxLength)
|
||||
}
|
||||
|
||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||
}
|
||||
|
|
@ -325,6 +334,14 @@ private fun isBlockSelectedOnPage(
|
|||
return afterStart && beforeEnd
|
||||
}
|
||||
|
||||
internal fun highlightsForPaginatedPage(
|
||||
pageChapterIndex: Int?,
|
||||
userHighlights: List<UserHighlight>
|
||||
): List<UserHighlight> {
|
||||
if (pageChapterIndex == null) return emptyList()
|
||||
return userHighlights.filter { it.chapterIndex == pageChapterIndex }
|
||||
}
|
||||
|
||||
class ReactiveBlockMap(
|
||||
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
|
||||
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
|
||||
|
|
@ -939,6 +956,7 @@ fun PaginatedReaderScreen(
|
|||
when (debouncedTextAlign) {
|
||||
ReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
||||
ReaderTextAlign.LEFT -> TextAlign.Left
|
||||
ReaderTextAlign.RIGHT -> TextAlign.Right
|
||||
ReaderTextAlign.DEFAULT -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -1266,6 +1284,7 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
result
|
||||
},
|
||||
onGetChapterIndex = { pageIndex -> paginator.findChapterIndexForPage(pageIndex) },
|
||||
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
|
||||
onGetChapterInfo = { pageIndex ->
|
||||
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
|
||||
|
|
@ -1522,8 +1541,12 @@ internal fun getHighlightOffsetsInBlock(
|
|||
.takeIf { it > blockStartAbs }
|
||||
?: (blockStartAbs + block.content.text.length)
|
||||
|
||||
Timber.d(
|
||||
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_check blockCfi=${block.cfi} blockPath=$blockPath " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs blockLen=${block.content.text.length} " +
|
||||
"highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " +
|
||||
"highlightCfi=${highlight.cfi} startCfi=$startCfi endCfi=$endCfi " +
|
||||
"highlightTextLen=${highlight.text.length} highlightText='${highlightDiagSnippet(highlight.text)}'"
|
||||
)
|
||||
|
||||
val relevantPart = parts.find { cfiPart ->
|
||||
|
|
@ -1559,8 +1582,8 @@ internal fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
|
||||
if (relevantPart != null) {
|
||||
Timber.d(
|
||||
" -> Block ${block.cfi} matches specific part of multipart highlight: $relevantPart"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_relevant_part blockCfi=${block.cfi} highlightId=${highlight.id} part=$relevantPart"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1573,16 +1596,38 @@ internal fun getHighlightOffsetsInBlock(
|
|||
isMultipartHighlight &&
|
||||
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
||||
|
||||
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_decision blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||
"relevantPart=$relevantPart isIntermediateBlock=$isIntermediateBlock"
|
||||
)
|
||||
|
||||
if (relevantPart == null) {
|
||||
if (!isIntermediateBlock) return null
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) {
|
||||
val range = 0 until blockText.length
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=intermediate_exact blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
}
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) {
|
||||
val range = 0 until blockText.length
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=intermediate_exact_ignore_case blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
}
|
||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||
0 until blockText.length
|
||||
val range = 0 until blockText.length
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=intermediate_normalized blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
range
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -1604,18 +1649,26 @@ internal fun getHighlightOffsetsInBlock(
|
|||
val startMatches = arePathsEquivalent(startCfi, block.cfi!!)
|
||||
val endMatches = if (endCfi != null) arePathsEquivalent(endCfi, block.cfi!!) else false
|
||||
|
||||
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_path_equivalence blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||
"startMatches=$startMatches endMatches=$endMatches"
|
||||
)
|
||||
|
||||
if (startMatches || endMatches) {
|
||||
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
||||
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_offset_inputs blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs"
|
||||
)
|
||||
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
||||
val rangeStartAbs = minOf(startAbs, endAbs)
|
||||
val rangeEndAbs = maxOf(startAbs, endAbs)
|
||||
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
||||
Timber.d(
|
||||
" -> Skipping same-path split block outside highlight offsets. " +
|
||||
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_skip reason=same_path_split_outside_offsets blockCfi=${block.cfi} " +
|
||||
"highlightId=${highlight.id} highlightAbs=$rangeStartAbs..$rangeEndAbs " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
|
@ -1653,8 +1706,9 @@ internal fun getHighlightOffsetsInBlock(
|
|||
val targetRel = relOffset - safeStart
|
||||
val bestRel = matches.minByOrNull { abs(it - targetRel) }!!
|
||||
val newS = safeStart + bestRel
|
||||
Timber.d(
|
||||
"Snapped start offset from rel $relOffset to $newS based on prefix '$prefix'"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_snap_start blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||
"fromRel=$relOffset toRel=$newS prefix='$prefix'"
|
||||
)
|
||||
s = newS
|
||||
snapped = true
|
||||
|
|
@ -1674,8 +1728,9 @@ internal fun getHighlightOffsetsInBlock(
|
|||
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
||||
val relOffset = absOffset - blockStartAbs
|
||||
|
||||
Timber.d(
|
||||
" -> EndCFI Match. AbsOffset: $absOffset. RelOffset: $relOffset. Block Length: ${blockText.length}"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_end_match blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||
"absOffset=$absOffset relOffset=$relOffset blockLen=${blockText.length}"
|
||||
)
|
||||
|
||||
e = if (relOffset > blockText.length) {
|
||||
|
|
@ -1689,19 +1744,38 @@ internal fun getHighlightOffsetsInBlock(
|
|||
e = e.coerceIn(0, blockText.length)
|
||||
|
||||
if (s < e) {
|
||||
Timber.d("Fallback to CFI offsets for block ${block.cfi}. Range: $s..$e")
|
||||
return s until e
|
||||
val range = s until e
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=cfi_offsets blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
} else {
|
||||
Timber.w(
|
||||
" -> Invalid Range detected (likely highlight is on other split part): $s..$e"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w(
|
||||
"map_skip reason=invalid_range blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$s..$e"
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) {
|
||||
val range = 0 until blockText.length
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=block_inside_highlight_text blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
}
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) {
|
||||
val range = 0 until blockText.length
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=block_inside_highlight_text_ignore_case blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
}
|
||||
|
||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||
if (startIndex == -1) {
|
||||
|
|
@ -1709,15 +1783,27 @@ internal fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
|
||||
if (startIndex >= 0) {
|
||||
return startIndex until (startIndex + highlightText.length)
|
||||
val range = startIndex until (startIndex + highlightText.length)
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=highlight_text_inside_block blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||
)
|
||||
return range
|
||||
}
|
||||
|
||||
val match = findFuzzyMatch(blockText, highlightText)
|
||||
if (match != null) return match
|
||||
if (match != null) {
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_result reason=fuzzy_text blockCfi=${block.cfi} " +
|
||||
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$match"
|
||||
)
|
||||
return match
|
||||
}
|
||||
|
||||
if (relevantPart != null) {
|
||||
Timber.d(
|
||||
"Failed to match highlight text in block despite CFI match. " + "BlockCfi=${block.cfi}, HighlightCfi=${highlight.cfi}. "
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"map_skip reason=cfi_match_text_miss blockCfi=${block.cfi} " +
|
||||
"highlightId=${highlight.id} highlightCfi=${highlight.cfi}"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1781,6 +1867,17 @@ private fun TextWithEmphasis(
|
|||
val range = getHighlightOffsetsInBlock(block, highlight)
|
||||
if (range != null) {
|
||||
try {
|
||||
val blockStartAbs = getTextBlockCharOffset(block)
|
||||
val blockEndAbs = block.endCharOffsetInSource
|
||||
.takeIf { it > blockStartAbs }
|
||||
?: (blockStartAbs + block.content.text.length)
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"draw_highlight page=$pageIndex blockCfi=${block.cfi} " +
|
||||
"blockIndex=${block.blockIndex} blockAbs=$blockStartAbs..$blockEndAbs " +
|
||||
"highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " +
|
||||
"highlightCfi=${highlight.cfi} range=$range " +
|
||||
"blockText='${highlightDiagSnippet(block.content.text)}'"
|
||||
)
|
||||
val path = layout.getPathForRange(range.first, range.last + 1)
|
||||
paths.add(path to highlight.color.color.copy(alpha = 0.4f))
|
||||
if (highlight.cfi == pressedHighlightCfi) {
|
||||
|
|
@ -2064,6 +2161,13 @@ private fun TextWithEmphasis(
|
|||
val range = getHighlightOffsetsInBlock(block, highlight) ?: continue
|
||||
|
||||
if (charOffset in range) {
|
||||
val blockStartAbs = getTextBlockCharOffset(block)
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"tap_highlight page=$pageIndex blockCfi=${block.cfi} " +
|
||||
"blockIndex=${block.blockIndex} blockAbsStart=$blockStartAbs " +
|
||||
"charOffset=$charOffset absoluteCharOffset=${blockStartAbs + charOffset} " +
|
||||
"highlightId=${highlight.id} highlightCfi=${highlight.cfi} range=$range"
|
||||
)
|
||||
val path = layout.getPathForRange(range.first, range.last)
|
||||
val bounds = path.getBounds()
|
||||
return highlight to bounds
|
||||
|
|
@ -2222,6 +2326,7 @@ internal fun PaginatedReaderContent(
|
|||
horizontalPadding: Dp,
|
||||
verticalPadding: Dp,
|
||||
onGetPage: (Int) -> Page?,
|
||||
onGetChapterIndex: (Int) -> Int?,
|
||||
onGetChapterPath: (Int) -> String?,
|
||||
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
||||
onInternalLinkNavigated: (Int) -> Unit,
|
||||
|
|
@ -2393,6 +2498,11 @@ internal fun PaginatedReaderContent(
|
|||
|
||||
var pageContent by remember { mutableStateOf<Page?>(null) }
|
||||
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
||||
val pageChapterIndex = onGetChapterIndex(pageIndex)
|
||||
val pageUserHighlights = highlightsForPaginatedPage(
|
||||
pageChapterIndex = pageChapterIndex,
|
||||
userHighlights = userHighlights
|
||||
)
|
||||
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
|
||||
pageContent?.applyReaderThemeForDisplay(
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
|
@ -2401,6 +2511,15 @@ internal fun PaginatedReaderContent(
|
|||
)
|
||||
}
|
||||
|
||||
if (pageUserHighlights.size != userHighlights.size) {
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"page_scope page=$pageIndex pageChapter=$pageChapterIndex " +
|
||||
"inputHighlightCount=${userHighlights.size} " +
|
||||
"pageHighlightCount=${pageUserHighlights.size} " +
|
||||
"inputHighlightChapters=${userHighlights.map { it.chapterIndex }.distinct()}"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(pageIndex, uiState.generation) {
|
||||
val fetchStartTime = System.currentTimeMillis()
|
||||
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
|
||||
|
|
@ -2856,7 +2975,7 @@ internal fun PaginatedReaderContent(
|
|||
onLinkClick = onLinkClickCallback,
|
||||
onGeneralTap = onGeneralTapCallback,
|
||||
block = block,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection = sel
|
||||
|
|
@ -2939,7 +3058,7 @@ internal fun PaginatedReaderContent(
|
|||
onLinkClick = onLinkClickCallback,
|
||||
onGeneralTap = onGeneralTapCallback,
|
||||
block = block,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection = sel
|
||||
|
|
@ -3025,7 +3144,7 @@ internal fun PaginatedReaderContent(
|
|||
onLinkClick = onLinkClickCallback,
|
||||
onGeneralTap = onGeneralTapCallback,
|
||||
block = block,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection = sel
|
||||
|
|
@ -3142,7 +3261,7 @@ internal fun PaginatedReaderContent(
|
|||
onLinkClick = onLinkClickCallback,
|
||||
onGeneralTap = onGeneralTapCallback,
|
||||
block = block,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection = sel
|
||||
|
|
@ -3210,7 +3329,7 @@ internal fun PaginatedReaderContent(
|
|||
textMeasurer = textMeasurer,
|
||||
onLinkClickCallback = onLinkClickCallback,
|
||||
onGeneralTapCallback = onGeneralTapCallback,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection =
|
||||
|
|
@ -3263,7 +3382,7 @@ internal fun PaginatedReaderContent(
|
|||
textMeasurer = textMeasurer,
|
||||
onLinkClickCallback = onLinkClickCallback,
|
||||
onGeneralTapCallback = onGeneralTapCallback,
|
||||
userHighlights = userHighlights,
|
||||
userHighlights = pageUserHighlights,
|
||||
activeSelection = activeSelection,
|
||||
onSelectionChange = { sel ->
|
||||
activeSelection =
|
||||
|
|
@ -3838,15 +3957,45 @@ internal fun PaginatedReaderContent(
|
|||
activeSelection = null
|
||||
},
|
||||
onHighlight = { color ->
|
||||
val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset
|
||||
val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset
|
||||
val finalCfi =
|
||||
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
||||
val absoluteCandidateCfi =
|
||||
"${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"create_request source=highlight_menu color=${color.id} " +
|
||||
"savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " +
|
||||
"startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " +
|
||||
"startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " +
|
||||
"startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " +
|
||||
"localOffsets=${sel.startOffset}..${sel.endOffset} " +
|
||||
"blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " +
|
||||
"absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " +
|
||||
"textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'"
|
||||
)
|
||||
onHighlightCreated(finalCfi, sel.text, color.id)
|
||||
activeSelection = null
|
||||
},
|
||||
onNote = {
|
||||
onNoteRequested(null)
|
||||
val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset
|
||||
val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset
|
||||
val finalCfi =
|
||||
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
||||
val absoluteCandidateCfi =
|
||||
"${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset"
|
||||
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||
"create_request source=note_menu color=${HighlightColor.YELLOW.id} " +
|
||||
"savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " +
|
||||
"startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " +
|
||||
"startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " +
|
||||
"startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " +
|
||||
"localOffsets=${sel.startOffset}..${sel.endOffset} " +
|
||||
"blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " +
|
||||
"absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " +
|
||||
"textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'"
|
||||
)
|
||||
onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id)
|
||||
activeSelection = null
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -40,6 +43,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
|
|
@ -58,6 +62,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -76,6 +81,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
|
@ -84,6 +90,32 @@ internal data class PdfBookmark(val pageIndex: Int, val title: String, val total
|
|||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
private enum class PdfDrawerSection {
|
||||
TABS,
|
||||
CHAPTERS,
|
||||
BOOKMARKS,
|
||||
HIGHLIGHTS,
|
||||
PAGES
|
||||
}
|
||||
|
||||
private val PdfDrawerSection.titleResId: Int
|
||||
get() = when (this) {
|
||||
PdfDrawerSection.TABS -> R.string.tab_tabs
|
||||
PdfDrawerSection.CHAPTERS -> R.string.tab_chapters
|
||||
PdfDrawerSection.BOOKMARKS -> R.string.tab_bookmarks
|
||||
PdfDrawerSection.HIGHLIGHTS -> R.string.tab_highlights
|
||||
PdfDrawerSection.PAGES -> R.string.tab_pages
|
||||
}
|
||||
|
||||
private val PdfDrawerSection.testTag: String?
|
||||
get() = when (this) {
|
||||
PdfDrawerSection.TABS -> "TabsTab"
|
||||
PdfDrawerSection.BOOKMARKS -> "BookmarksTab"
|
||||
PdfDrawerSection.HIGHLIGHTS -> "HighlightsTab"
|
||||
PdfDrawerSection.PAGES -> "PagesTab"
|
||||
PdfDrawerSection.CHAPTERS -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
|
|
@ -278,6 +310,225 @@ internal fun PdfTocTreeItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfTabsDrawerPage(
|
||||
openTabs: List<RecentFileItem>,
|
||||
activeTabBookId: String?,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 10.dp, end = 8.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.active_tabs),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Text(
|
||||
text = openTabs.size.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onNewTabClick,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.content_desc_new_tab)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (openTabs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.msg_no_other_pdfs_found),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
items(openTabs, key = { it.bookId }) { tab ->
|
||||
PdfDrawerTabItem(
|
||||
tab = tab,
|
||||
isSelected = tab.bookId == activeTabBookId,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabClosed = onTabClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfDrawerTabItem(
|
||||
tab: RecentFileItem,
|
||||
isSelected: Boolean,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "PdfDrawerTabContainer"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
label = "PdfDrawerTabBorder"
|
||||
)
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val progressPercent = remember(isSelected, currentPage, totalPages, tab.progressPercentage) {
|
||||
when {
|
||||
isSelected && totalPages > 0 -> (((currentPage + 1).toFloat() / totalPages.toFloat()) * 100f)
|
||||
.coerceIn(0f, 100f)
|
||||
.toInt()
|
||||
else -> tab.progressPercentage
|
||||
?.coerceIn(0f, 100f)
|
||||
?.toInt()
|
||||
}
|
||||
}
|
||||
val pageLabel = when {
|
||||
isSelected && totalPages > 0 -> stringResource(R.string.page_of_pages, currentPage + 1, totalPages)
|
||||
tab.lastPage != null -> stringResource(R.string.pdf_page_short, tab.lastPage + 1)
|
||||
else -> null
|
||||
}
|
||||
val progressLabel = progressPercent
|
||||
?.takeIf { it > 0 }
|
||||
?.let { stringResource(R.string.progress_complete, it) }
|
||||
val supportingText = remember(pageLabel, progressLabel, tab.author) {
|
||||
listOfNotNull(pageLabel, progressLabel, tab.author)
|
||||
.distinct()
|
||||
.joinToString(" - ")
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.clip(shape)
|
||||
.background(containerColor)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable { onTabSelected(tab.bookId) }
|
||||
.testTag("PdfDrawerTab_${tab.bookId}")
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 68.dp)
|
||||
.padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (supportingText.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { onTabClosed(tab.bookId) },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.close_tab),
|
||||
tint = contentColor.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
progressPercent
|
||||
?.takeIf { it > 0 }
|
||||
?.let { percent ->
|
||||
LinearProgressIndicator(
|
||||
progress = { percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
trackColor = Color.Transparent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
|
|
@ -288,58 +539,81 @@ internal fun PdfNavigationDrawerContent(
|
|||
userHighlights: List<PdfUserHighlight>,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
isTabsEnabled: Boolean = false,
|
||||
openTabs: List<RecentFileItem> = emptyList(),
|
||||
activeTabBookId: String? = null,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onTabSelected: (String) -> Unit = {},
|
||||
onTabClosed: (String) -> Unit = {},
|
||||
onNewTabClick: () -> Unit = {},
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||
onNoteRequested: (String?) -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 4 })
|
||||
val showTabsPane = isTabsEnabled && openTabs.isNotEmpty()
|
||||
val drawerSections = remember(showTabsPane) {
|
||||
buildList {
|
||||
if (showTabsPane) add(PdfDrawerSection.TABS)
|
||||
add(PdfDrawerSection.CHAPTERS)
|
||||
add(PdfDrawerSection.BOOKMARKS)
|
||||
add(PdfDrawerSection.HIGHLIGHTS)
|
||||
add(PdfDrawerSection.PAGES)
|
||||
}
|
||||
}
|
||||
val drawerPagerState = rememberPagerState(pageCount = { drawerSections.size })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(drawerSections.size) {
|
||||
if (drawerPagerState.currentPage >= drawerSections.size) {
|
||||
drawerPagerState.scrollToPage(drawerSections.lastIndex.coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val selectedDrawerTabIndex = drawerPagerState.currentPage.coerceIn(0, drawerSections.lastIndex)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = drawerPagerState.currentPage,
|
||||
selectedTabIndex = selectedDrawerTabIndex,
|
||||
edgePadding = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||
}, text = { Text(stringResource(R.string.tab_chapters)) })
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 1,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
|
||||
},
|
||||
text = { Text(stringResource(R.string.tab_bookmarks)) },
|
||||
modifier = Modifier.testTag("BookmarksTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 2,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
|
||||
},
|
||||
text = { Text(stringResource(R.string.tab_highlights)) },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 3,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
|
||||
},
|
||||
text = { Text(stringResource(R.string.tab_pages)) },
|
||||
modifier = Modifier.testTag("PagesTab")
|
||||
)
|
||||
drawerSections.forEachIndexed { index, section ->
|
||||
Tab(
|
||||
selected = selectedDrawerTabIndex == index,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(index) }
|
||||
},
|
||||
text = { Text(stringResource(section.titleResId)) },
|
||||
modifier = section.testTag?.let { Modifier.testTag(it) } ?: Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = drawerPagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> { // Chapters Page
|
||||
when (drawerSections[page]) {
|
||||
PdfDrawerSection.TABS -> PdfTabsDrawerPage(
|
||||
openTabs = openTabs,
|
||||
activeTabBookId = activeTabBookId,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
onTabSelected = { bookId ->
|
||||
if (bookId == activeTabBookId) {
|
||||
onCloseDrawer()
|
||||
} else {
|
||||
onCloseDrawer()
|
||||
onTabSelected(bookId)
|
||||
}
|
||||
},
|
||||
onTabClosed = onTabClosed,
|
||||
onNewTabClick = onNewTabClick
|
||||
)
|
||||
|
||||
PdfDrawerSection.CHAPTERS -> { // Chapters Page
|
||||
if (flatTableOfContents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
|
|
@ -500,7 +774,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
}
|
||||
|
||||
1 -> { // Bookmarks Page
|
||||
PdfDrawerSection.BOOKMARKS -> { // Bookmarks Page
|
||||
if (bookmarks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -642,7 +916,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
2 -> { // Highlights Page
|
||||
PdfDrawerSection.HIGHLIGHTS -> { // Highlights Page
|
||||
if (userHighlights.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -801,7 +1075,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
3 -> { // Pages Page
|
||||
PdfDrawerSection.PAGES -> { // Pages Page
|
||||
val listState = rememberLazyListState()
|
||||
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import androidx.compose.ui.graphics.Brush
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -236,6 +237,9 @@ internal fun PdfSelectionMenuPopup(
|
|||
onNote: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp)
|
||||
val menuScrollState = rememberScrollState()
|
||||
|
||||
Popup(
|
||||
popupPositionProvider = popupPositionProvider,
|
||||
|
|
@ -251,9 +255,15 @@ internal fun PdfSelectionMenuPopup(
|
|||
shadowElevation = 8.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.widthIn(max = 300.dp)
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.heightIn(max = selectionMenuMaxHeight)
|
||||
) {
|
||||
Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) {
|
||||
Column(
|
||||
modifier = (if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max))
|
||||
.heightIn(max = selectionMenuMaxHeight)
|
||||
.verticalScroll(menuScrollState)
|
||||
) {
|
||||
if (!menuState.note.isNullOrBlank()) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
|
|
@ -330,7 +340,7 @@ internal fun PdfSelectionMenuPopup(
|
|||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -338,7 +348,7 @@ internal fun PdfSelectionMenuPopup(
|
|||
PdfHighlightColor.entries.forEach { colorEnum ->
|
||||
val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 6.dp).size(32.dp)
|
||||
modifier = Modifier.padding(horizontal = 4.dp).size(28.dp)
|
||||
.background(displayColor, CircleShape).clip(CircleShape)
|
||||
.clickable {
|
||||
Timber.tag("PdfHighlightDebug")
|
||||
|
|
@ -352,8 +362,8 @@ internal fun PdfSelectionMenuPopup(
|
|||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.size(32.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brush.sweepGradient(rainbowColors))
|
||||
.clickable { onPaletteClick() },
|
||||
|
|
@ -392,7 +402,7 @@ internal fun PdfSelectionMenuPopup(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
@ -400,22 +410,22 @@ internal fun PdfSelectionMenuPopup(
|
|||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(64.dp)
|
||||
.width(56.dp)
|
||||
.clickable { action.onClick() }
|
||||
.padding(vertical = 8.dp),
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (action.imageVector != null) {
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
} else if (action.iconRes != null) {
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||
}
|
||||
}
|
||||
repeat(3 - rowActions.size) {
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
Spacer(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ import com.aryan.reader.pdf.data.PdfTextBox
|
|||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.pdf.ocr.OcrElement
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -2139,7 +2143,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val dragWorker = coroutineScope.launch(Dispatchers.IO) {
|
||||
var pageForDrag: ReaderPage? = null
|
||||
var textPageForDrag: ReaderTextPage? = null
|
||||
|
||||
|
|
@ -2473,106 +2477,107 @@ internal fun PdfPageComposable(
|
|||
} finally {
|
||||
dragEventChannel.close()
|
||||
}
|
||||
dragWorker.invokeOnCompletion {
|
||||
coroutineScope.launch {
|
||||
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
||||
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
||||
val currentRange = selectionCharRange.value!!
|
||||
var pageForMenu: ReaderPage? = null
|
||||
var textPageForMenu: ReaderTextPage? = null
|
||||
try {
|
||||
val text = withContext(Dispatchers.IO) {
|
||||
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
|
||||
textPageForMenu = pageForMenu?.openTextPage()
|
||||
textPageForMenu?.textPageGetText(
|
||||
currentRange.first,
|
||||
currentRange.second - currentRange.first
|
||||
)
|
||||
}
|
||||
if (!text.isNullOrBlank()) {
|
||||
val combinedRect = Rect(selectedWordScreenRects.first())
|
||||
selectedWordScreenRects.forEach { combinedRect.union(it) }
|
||||
|
||||
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
||||
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
||||
val currentRange = selectionCharRange.value!!
|
||||
coroutineScope.launch {
|
||||
var pageForMenu: ReaderPage? = null
|
||||
var textPageForMenu: ReaderTextPage? = null
|
||||
try {
|
||||
val text = withContext(Dispatchers.IO) {
|
||||
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
|
||||
textPageForMenu = pageForMenu?.openTextPage()
|
||||
textPageForMenu?.textPageGetText(
|
||||
currentRange.first,
|
||||
currentRange.second - currentRange.first
|
||||
customMenuState = CustomPdfMenuState(
|
||||
selectedText = text,
|
||||
anchorRect = combinedRect,
|
||||
charRange = currentRange
|
||||
)
|
||||
Timber.d(
|
||||
"Menu shown after drag. Anchor: ${customMenuState?.anchorRect}"
|
||||
)
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(
|
||||
e, "Error fetching text for menu after drag"
|
||||
)
|
||||
customMenuState = null
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
textPageForMenu?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
pageForMenu?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text.isNullOrBlank()) {
|
||||
val combinedRect = Rect(selectedWordScreenRects.first())
|
||||
selectedWordScreenRects.forEach { combinedRect.union(it) }
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
} else {
|
||||
if (ocrSelectionSymbolIndices != null && selectedWordScreenRects.isNotEmpty()) {
|
||||
val indices = ocrSelectionSymbolIndices!!
|
||||
val selectedSymbolInfos = allOcrSymbolsForSelection.subList(
|
||||
indices.first, indices.second
|
||||
)
|
||||
if (selectedSymbolInfos.isNotEmpty()) {
|
||||
val selectedText = buildString {
|
||||
selectedSymbolInfos.forEachIndexed { index, info ->
|
||||
append(info.symbol.text)
|
||||
|
||||
if (index < selectedSymbolInfos.size - 1) {
|
||||
val nextInfo = selectedSymbolInfos[index + 1]
|
||||
|
||||
if (info.parentLine !== nextInfo.parentLine) {
|
||||
append('\n')
|
||||
} else if (info.parentElement !== nextInfo.parentElement) {
|
||||
append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val firstRect = selectedSymbolInfos.first().symbol.boundingBox!!
|
||||
val combinedRect = Rect(firstRect)
|
||||
selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } }
|
||||
|
||||
customMenuState = CustomPdfMenuState(
|
||||
selectedText = text,
|
||||
selectedText = selectedText,
|
||||
anchorRect = combinedRect,
|
||||
charRange = currentRange
|
||||
charRange = Pair(indices.first, indices.second)
|
||||
)
|
||||
Timber.d(
|
||||
"Menu shown after drag. Anchor: ${customMenuState?.anchorRect}"
|
||||
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
|
||||
)
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(
|
||||
e, "Error fetching text for menu after drag"
|
||||
)
|
||||
} else {
|
||||
customMenuState = null
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
textPageForMenu?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
pageForMenu?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
} else {
|
||||
if (ocrSelectionSymbolIndices != null && selectedWordScreenRects.isNotEmpty()) {
|
||||
val indices = ocrSelectionSymbolIndices!!
|
||||
val selectedSymbolInfos = allOcrSymbolsForSelection.subList(
|
||||
indices.first, indices.second
|
||||
activeDraggingHandle = null
|
||||
showMagnifier = false
|
||||
Timber.d(
|
||||
"PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState"
|
||||
)
|
||||
if (selectedSymbolInfos.isNotEmpty()) {
|
||||
val selectedText = buildString {
|
||||
selectedSymbolInfos.forEachIndexed { index, info ->
|
||||
append(info.symbol.text)
|
||||
|
||||
if (index < selectedSymbolInfos.size - 1) {
|
||||
val nextInfo = selectedSymbolInfos[index + 1]
|
||||
|
||||
if (info.parentLine !== nextInfo.parentLine) {
|
||||
append('\n')
|
||||
} else if (info.parentElement !== nextInfo.parentElement) {
|
||||
append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val firstRect = selectedSymbolInfos.first().symbol.boundingBox!!
|
||||
val combinedRect = Rect(firstRect)
|
||||
selectedSymbolInfos.forEach { info -> info.symbol.boundingBox?.let { combinedRect.union(it) } }
|
||||
|
||||
customMenuState = CustomPdfMenuState(
|
||||
selectedText = selectedText,
|
||||
anchorRect = combinedRect,
|
||||
charRange = Pair(indices.first, indices.second)
|
||||
)
|
||||
Timber.d(
|
||||
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
|
||||
)
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
} else {
|
||||
customMenuState = null
|
||||
}
|
||||
}
|
||||
activeDraggingHandle = null
|
||||
showMagnifier = false
|
||||
Timber.d(
|
||||
"PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState"
|
||||
)
|
||||
} else {
|
||||
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
|
||||
try {
|
||||
|
|
@ -5632,22 +5637,20 @@ private fun PdfPageRenderer(
|
|||
val topLeftWindow = coords.localToWindow(topLeftLocal)
|
||||
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
|
||||
|
||||
val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
|
||||
val gapPx = with(density) { 16.dp.toPx() }
|
||||
|
||||
var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt()
|
||||
|
||||
if (yInWindow < 0) {
|
||||
yInWindow = (bottomRightWindow.y + gapPx).toInt()
|
||||
if (yInWindow + popupContentSize.height > windowSize.height) {
|
||||
yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt()
|
||||
.coerceIn(0, windowSize.width - popupContentSize.width)
|
||||
|
||||
return IntOffset(xInWindow, yInWindow)
|
||||
val placement = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height),
|
||||
popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height),
|
||||
selection = SharedSelectionMenuRect(
|
||||
left = topLeftWindow.x,
|
||||
top = topLeftWindow.y,
|
||||
right = bottomRightWindow.x,
|
||||
bottom = bottomRightWindow.y
|
||||
),
|
||||
marginPx = gapPx,
|
||||
gapPx = gapPx
|
||||
)
|
||||
return IntOffset(placement.x, placement.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTheme
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
|
|
@ -49,33 +51,46 @@ internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
|||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
|
||||
enum class PdfReaderTool(val title: String, val category: String) {
|
||||
DICTIONARY("External Apps", "Top Bar"),
|
||||
THEME("Theme Settings", "Top Bar"),
|
||||
LOCK_PANNING("Lock Panning", "Top Bar"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
||||
FULL_SCREEN("Full Screen", "Top Bar"),
|
||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||
TOC("Sidebar", "Bottom Bar"),
|
||||
SEARCH("Search", "Bottom Bar"),
|
||||
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
|
||||
AI_FEATURES("AI Features", "Bottom Bar"),
|
||||
EDIT_MODE("Edit Mode", "Bottom Bar"),
|
||||
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
||||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
||||
SHARE("Share", "Overflow Menu"),
|
||||
SAVE_COPY("Save Copy", "Overflow Menu"),
|
||||
PRINT("Print", "Overflow Menu")
|
||||
enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||
LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"),
|
||||
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||
FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"),
|
||||
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||
HIGHLIGHT_ALL(R.string.tool_highlight_selectable_text, "Bottom Bar"),
|
||||
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
|
||||
EDIT_MODE(R.string.tool_edit_mode, "Bottom Bar"),
|
||||
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
|
||||
OCR_LANGUAGE(R.string.menu_ocr_language, "Overflow Menu"),
|
||||
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
|
||||
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
|
||||
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
||||
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
||||
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
||||
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
|
||||
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
|
||||
PAGE_MANAGEMENT(R.string.tool_page_management, "Overflow Menu"),
|
||||
REFLOW(R.string.tool_text_view_reflow, "Overflow Menu"),
|
||||
SHARE(R.string.action_share, "Overflow Menu"),
|
||||
SAVE_COPY(R.string.action_save_copy_to_device, "Overflow Menu"),
|
||||
PRINT(R.string.action_print, "Overflow Menu")
|
||||
}
|
||||
|
||||
internal fun defaultPdfHiddenTools(): Set<String> {
|
||||
return setOf(
|
||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||
)
|
||||
}
|
||||
|
||||
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.toList()
|
||||
|
||||
internal fun defaultPdfBottomTools(): Set<String> {
|
||||
return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = listOf(
|
||||
|
|
@ -99,10 +114,7 @@ internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
|||
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + setOf(
|
||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||
)
|
||||
val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools()
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
|
|
@ -127,7 +139,7 @@ internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
|||
?.filter { it.isNotBlank() }
|
||||
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||
.orEmpty()
|
||||
return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
|
||||
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
||||
}
|
||||
|
||||
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||
|
|
@ -137,7 +149,7 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>)
|
|||
|
||||
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
val defaultBottomTools = defaultPdfBottomTools()
|
||||
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
|
|
@ -34,6 +36,7 @@ import androidx.compose.material.icons.filled.Edit
|
|||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ScreenRotation
|
||||
|
|
@ -45,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
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
|
||||
|
|
@ -77,7 +81,8 @@ data class PdfFlatToolItem(
|
|||
val type: PdfFlatItemType,
|
||||
val tool: PdfReaderTool? = null,
|
||||
val section: PdfToolbarSection? = null,
|
||||
val title: String? = null
|
||||
val title: String? = null,
|
||||
@StringRes val titleRes: Int? = null
|
||||
)
|
||||
|
||||
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
|
||||
|
|
@ -92,7 +97,7 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
|||
}
|
||||
|
||||
PdfToolbarSection.entries.forEach { section ->
|
||||
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||
val tools = sectionMap[section] ?: emptyList()
|
||||
if (tools.isEmpty()) {
|
||||
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||
|
|
@ -108,6 +113,51 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
|||
return result
|
||||
}
|
||||
|
||||
private val pdfReorderableToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
internal fun buildPdfToolbarItems(
|
||||
hiddenTools: Set<String>,
|
||||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>
|
||||
): List<PdfFlatToolItem> {
|
||||
val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||
|
||||
val list = mutableListOf<PdfFlatToolItem>()
|
||||
|
||||
PdfToolbarSection.entries.forEach { section ->
|
||||
val tools = when (section) {
|
||||
PdfToolbarSection.TOP -> topTools
|
||||
PdfToolbarSection.BOTTOM -> bottomToolsList
|
||||
PdfToolbarSection.HIDDEN -> hiddenToolsList
|
||||
}
|
||||
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||
if (tools.isEmpty()) {
|
||||
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||
} else {
|
||||
tools.forEach { tool ->
|
||||
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
|
||||
moreTools.forEach { tool ->
|
||||
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
class PdfDragDropState(
|
||||
val lazyListState: LazyListState,
|
||||
val onMove: (String, String) -> Unit
|
||||
|
|
@ -142,54 +192,20 @@ fun PdfCustomizeToolsSheet(
|
|||
onPlacementUpdate: (Set<String>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val reorderableToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||
var flatItems by remember {
|
||||
mutableStateOf<List<PdfFlatToolItem>>(
|
||||
run {
|
||||
val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in reorderableToolbarTools }
|
||||
|
||||
val list = mutableListOf<PdfFlatToolItem>()
|
||||
|
||||
PdfToolbarSection.entries.forEach { section ->
|
||||
val tools = when(section) {
|
||||
PdfToolbarSection.TOP -> topTools
|
||||
PdfToolbarSection.BOTTOM -> bottomToolsList
|
||||
PdfToolbarSection.HIDDEN -> hiddenToolsList
|
||||
}
|
||||
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||
if (tools.isEmpty()) {
|
||||
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||
} else {
|
||||
tools.forEach { tool ->
|
||||
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
|
||||
moreTools.forEach { tool ->
|
||||
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
|
||||
}
|
||||
list
|
||||
}
|
||||
buildPdfToolbarItems(
|
||||
hiddenTools = hiddenTools,
|
||||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val commitDragDrop = {
|
||||
val newHidden = localHiddenTools.filter { toolName ->
|
||||
toolOrder.find { it.name == toolName } !in reorderableToolbarTools
|
||||
toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools
|
||||
}.toMutableSet()
|
||||
|
||||
val newBottom = mutableSetOf<String>()
|
||||
|
|
@ -247,6 +263,22 @@ fun PdfCustomizeToolsSheet(
|
|||
}
|
||||
}
|
||||
|
||||
val resetToDefault = {
|
||||
val defaultHiddenTools = defaultPdfHiddenTools()
|
||||
val defaultToolOrder = defaultPdfToolOrder()
|
||||
val defaultBottomTools = defaultPdfBottomTools()
|
||||
|
||||
localHiddenTools = defaultHiddenTools
|
||||
flatItems = buildPdfToolbarItems(
|
||||
hiddenTools = defaultHiddenTools,
|
||||
toolOrder = defaultToolOrder,
|
||||
bottomTools = defaultBottomTools
|
||||
)
|
||||
onUpdate(defaultHiddenTools)
|
||||
onPlacementUpdate(defaultBottomTools)
|
||||
onOrderUpdate(defaultToolOrder)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
|
|
@ -269,6 +301,11 @@ fun PdfCustomizeToolsSheet(
|
|||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = resetToDefault) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.action_reset))
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||
}
|
||||
|
|
@ -300,8 +337,9 @@ fun PdfCustomizeToolsSheet(
|
|||
) {
|
||||
when (item.type) {
|
||||
PdfFlatItemType.SECTION_HEADER -> {
|
||||
val titleRes = item.titleRes
|
||||
Text(
|
||||
text = item.title ?: "",
|
||||
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
|
@ -317,7 +355,7 @@ fun PdfCustomizeToolsSheet(
|
|||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
PdfFlatItemType.TOOL -> {
|
||||
|
|
@ -334,8 +372,9 @@ fun PdfCustomizeToolsSheet(
|
|||
)
|
||||
}
|
||||
PdfFlatItemType.MORE_HEADER -> {
|
||||
val titleRes = item.titleRes
|
||||
Text(
|
||||
text = item.title ?: "More menu",
|
||||
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
||||
|
|
@ -343,7 +382,7 @@ fun PdfCustomizeToolsSheet(
|
|||
}
|
||||
PdfFlatItemType.MORE_TOOL -> {
|
||||
PdfMoreToolVisibilityRow(
|
||||
title = item.tool!!.title,
|
||||
title = stringResource(item.tool!!.titleRes),
|
||||
visible = !localHiddenTools.contains(item.tool.name),
|
||||
onToggle = {
|
||||
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
||||
|
|
@ -386,18 +425,19 @@ private fun PdfToolbarDragRow(
|
|||
PdfToolPreviewIcon(tool)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = tool.title,
|
||||
text = stringResource(tool.titleRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Menu,
|
||||
contentDescription = "Drag to reorder",
|
||||
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.padding(12.dp)
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
.clip(CircleShape)
|
||||
.pointerInput(tool) {
|
||||
detectDragGestures(
|
||||
onDragStart = { onDragStart() },
|
||||
|
|
@ -453,7 +493,7 @@ private fun PdfToolbarDragRow(
|
|||
PdfToolPreviewIcon(tool)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = tool.title,
|
||||
text = stringResource(tool.titleRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -489,27 +529,28 @@ private fun PdfMoreToolVisibilityRow(
|
|||
}
|
||||
}
|
||||
|
||||
enum class PdfToolbarSection(val title: String) {
|
||||
TOP("Top Bar"),
|
||||
BOTTOM("Bottom Bar"),
|
||||
HIDDEN("Hidden Tools")
|
||||
enum class PdfToolbarSection(@StringRes val titleRes: Int) {
|
||||
TOP(R.string.toolbar_top_bar),
|
||||
BOTTOM(R.string.toolbar_bottom_bar),
|
||||
HIDDEN(R.string.toolbar_hidden_tools)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
||||
val title = stringResource(tool.titleRes)
|
||||
when (tool) {
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -556,7 +597,7 @@ fun PdfVisualOptionsSheet(
|
|||
options = SystemUiMode.entries,
|
||||
selectedOption = systemUiMode,
|
||||
onOptionSelected = onSystemUiModeChange,
|
||||
getLabel = { it.title }
|
||||
getLabel = { stringResource(it.titleRes) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ internal fun PdfTopBar(
|
|||
|
||||
if (hiddenToolbarTools.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Hidden tools") },
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
|
|
@ -665,7 +665,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
else -> true
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(tool.title) },
|
||||
text = { Text(stringResource(tool.titleRes)) },
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
closeMenu()
|
||||
|
|
|
|||
|
|
@ -2118,7 +2118,7 @@ fun PdfViewerScreen(
|
|||
words.take(6).joinToString(" ") + "..."
|
||||
} else {
|
||||
Timber.d("No words found. Falling back to 'Page X' title.")
|
||||
"Page ${pageIndex + 1}"
|
||||
context.getString(R.string.pdf_page_short, pageIndex + 1)
|
||||
}
|
||||
|
||||
val chapterTitle =
|
||||
|
|
@ -2472,7 +2472,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
if (virtualPage is VirtualPage.BlankPage) {
|
||||
onUpdate(SummarizationResult(error = "Cannot summarize a blank page."))
|
||||
onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_blank_page_summary)))
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
|
@ -2480,7 +2480,7 @@ fun PdfViewerScreen(
|
|||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: currentPageIndex
|
||||
|
||||
val doc = pdfDocument ?: run {
|
||||
onUpdate(SummarizationResult(error = "Document not loaded."))
|
||||
onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_document_not_loaded)))
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
|
@ -2593,7 +2593,7 @@ fun PdfViewerScreen(
|
|||
if (fullText.isEmpty() && lastResult?.error == null) {
|
||||
onUpdate(
|
||||
SummarizationResult(
|
||||
error = "Failed to parse summary from server response."
|
||||
error = context.getString(R.string.ai_error_parse_summary)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -2607,17 +2607,21 @@ fun PdfViewerScreen(
|
|||
val errorDetail = try {
|
||||
errorBody?.let { JSONObject(it).getString("detail") }
|
||||
} catch (_: Exception) {
|
||||
"Could not fetch summary."
|
||||
context.getString(R.string.ai_error_fetch_summary)
|
||||
}
|
||||
onUpdate(
|
||||
SummarizationResult(
|
||||
error = "Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}"
|
||||
error = context.getString(
|
||||
R.string.ai_error_with_code,
|
||||
responseCode,
|
||||
errorDetail ?: context.getString(R.string.error_unknown_server)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Exception during PDF page summarization: ${e.message}")
|
||||
onUpdate(SummarizationResult(error = "An error occurred: ${e.localizedMessage}"))
|
||||
onUpdate(SummarizationResult(error = context.getString(R.string.error_occurred_format, e.localizedMessage)))
|
||||
} finally {
|
||||
pageBitmap?.recycle()
|
||||
connection?.disconnect()
|
||||
|
|
@ -2783,8 +2787,8 @@ fun PdfViewerScreen(
|
|||
val chunks = splitTextIntoChunks(textToChunk)
|
||||
|
||||
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
||||
?: effectivePdfUri.lastPathSegment ?: "Document"
|
||||
val pageTitle = "Page ${pageToRead + 1}"
|
||||
?: effectivePdfUri.lastPathSegment ?: context.getString(R.string.default_document_title)
|
||||
val pageTitle = context.getString(R.string.pdf_page_short, pageToRead + 1)
|
||||
|
||||
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
||||
|
||||
|
|
@ -2805,8 +2809,8 @@ fun PdfViewerScreen(
|
|||
}
|
||||
} else {
|
||||
val finalError = when {
|
||||
ocrAttempted -> "OCR found no text on this page."
|
||||
else -> "Page seems empty or text not extractable."
|
||||
ocrAttempted -> context.getString(R.string.error_no_text_on_page_after_ocr)
|
||||
else -> context.getString(R.string.error_page_text_not_extractable)
|
||||
}
|
||||
|
||||
val nextPage = pageToRead + 1
|
||||
|
|
@ -3216,7 +3220,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
} else {
|
||||
Timber.e(e, "Error loading fixed-layout document")
|
||||
errorMessage = "Error loading document: ${e.localizedMessage}"
|
||||
errorMessage = context.getString(R.string.error_loading_document_format, e.localizedMessage)
|
||||
isLoadingDocument = false
|
||||
}
|
||||
if (pdfDocument == null) {
|
||||
|
|
@ -3516,7 +3520,7 @@ fun PdfViewerScreen(
|
|||
results.add(
|
||||
SearchResult(
|
||||
locationInSource = match.pageIndex,
|
||||
locationTitle = "Page ${match.pageIndex + 1}",
|
||||
locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1),
|
||||
snippet = parseSnippet(match.snippet),
|
||||
query = query,
|
||||
occurrenceIndexInLocation = occurrenceIndex,
|
||||
|
|
@ -3529,7 +3533,7 @@ fun PdfViewerScreen(
|
|||
results.add(
|
||||
SearchResult(
|
||||
locationInSource = match.pageIndex,
|
||||
locationTitle = "Page ${match.pageIndex + 1}",
|
||||
locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1),
|
||||
snippet = parseSnippet(match.snippet),
|
||||
query = query,
|
||||
occurrenceIndexInLocation = 0,
|
||||
|
|
@ -3678,6 +3682,9 @@ fun PdfViewerScreen(
|
|||
userHighlights = visibleUserHighlights,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages,
|
||||
isTabsEnabled = isPdfTabStripVisible,
|
||||
openTabs = openTabs,
|
||||
activeTabBookId = activeTabBookId,
|
||||
customHighlightColors = customHighlightColors,
|
||||
onPageSelected = { targetPage ->
|
||||
coroutineScope.launch {
|
||||
|
|
@ -3694,6 +3701,29 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
},
|
||||
onTabSelected = { tabBookId ->
|
||||
coroutineScope.launch {
|
||||
currentBookId?.let { tabStateMap[it] = currentPage }
|
||||
saveAllData(true).join()
|
||||
viewModel.switchTab(tabBookId)
|
||||
}
|
||||
},
|
||||
onTabClosed = { tabBookId ->
|
||||
coroutineScope.launch {
|
||||
val isSelected = tabBookId == activeTabBookId
|
||||
if (isSelected) saveAllData(true).join()
|
||||
viewModel.closeTab(tabBookId)
|
||||
if (isSelected && openTabs.size == 1) {
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
},
|
||||
onNewTabClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
showNewTabSheet = true
|
||||
}
|
||||
},
|
||||
onRenameBookmark = { bookmarkToRename, newTitle ->
|
||||
if (newTitle.isNotBlank()) {
|
||||
val updatedBookmark = bookmarkToRename.copy(title = newTitle)
|
||||
|
|
@ -6376,7 +6406,7 @@ fun PdfViewerScreen(
|
|||
AiHubBottomSheet(
|
||||
bookTitle = bookTitle,
|
||||
currentChapterIndex = currentPageForDisplay,
|
||||
chapterTitle = "Page ${currentPageForDisplay + 1}",
|
||||
chapterTitle = stringResource(R.string.pdf_page_short, currentPageForDisplay + 1),
|
||||
summaryCacheManager = summaryCacheManager,
|
||||
summarizationResult = summarizationResult,
|
||||
isSummarizationLoading = isSummarizationLoading,
|
||||
|
|
@ -6412,7 +6442,12 @@ fun PdfViewerScreen(
|
|||
isSummarizationLoading = false
|
||||
val finalSummary = summarizationResult?.summary
|
||||
if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
|
||||
summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary)
|
||||
summaryCacheManager.saveSummary(
|
||||
bookTitle,
|
||||
currentPageForDisplay,
|
||||
context.getString(R.string.pdf_page_short, currentPageForDisplay + 1),
|
||||
finalSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -6959,7 +6994,7 @@ fun PdfViewerScreen(
|
|||
AlertDialog(
|
||||
onDismissRequest = { clickedLinkUrl = null },
|
||||
title = { Text(stringResource(R.string.dialog_external_link_title)) },
|
||||
text = { Text(stringResource(R.string.desc_external_link_warning)) },
|
||||
text = { Text(stringResource(R.string.desc_external_link_warning, url)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import timber.log.Timber
|
||||
|
|
@ -81,6 +82,7 @@ private const val PREFETCH_LOOKAHEAD = 3
|
|||
|
||||
@UnstableApi
|
||||
class TtsPlaybackManager(
|
||||
context: Context,
|
||||
private val player: Player,
|
||||
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
||||
private val onResetContext: () -> Unit,
|
||||
|
|
@ -88,6 +90,7 @@ class TtsPlaybackManager(
|
|||
private val onPlaybackSessionStopped: () -> Unit = {}
|
||||
) : MediaSession.Callback, Player.Listener {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var mediaSession: MediaSession? = null
|
||||
private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
|
||||
|
|
@ -388,7 +391,7 @@ class TtsPlaybackManager(
|
|||
args: Bundle // Added this parameter
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_no_text))
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
|
||||
return
|
||||
}
|
||||
|
|
@ -553,7 +556,7 @@ class TtsPlaybackManager(
|
|||
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||
if (firstChunk == null) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = appContext.getString(R.string.tts_error_starting_playback))
|
||||
onPlaybackSessionStopped()
|
||||
return
|
||||
}
|
||||
|
|
@ -565,7 +568,7 @@ class TtsPlaybackManager(
|
|||
)
|
||||
|
||||
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
|
||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||
|
||||
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
|
||||
|
|
@ -630,7 +633,7 @@ class TtsPlaybackManager(
|
|||
} else {
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
||||
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
|
||||
)
|
||||
onPlaybackSessionStopped()
|
||||
}
|
||||
|
|
@ -827,7 +830,7 @@ class TtsPlaybackManager(
|
|||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_playback, error.message.orEmpty()))
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
|
||||
|
|
@ -854,7 +857,7 @@ class TtsPlaybackManager(
|
|||
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
|
||||
|
||||
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
|
||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||
|
||||
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ class TtsService : MediaSessionService() {
|
|||
null
|
||||
}
|
||||
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
|
||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
|
||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = getString(R.string.tts_error_cloud_not_configured))
|
||||
} else {
|
||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
|
||||
liveClient.generateChunk(text, cachedFile)
|
||||
|
|
@ -810,6 +810,7 @@ class TtsService : MediaSessionService() {
|
|||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
||||
|
||||
playbackManager = TtsPlaybackManager(
|
||||
context = this,
|
||||
player = player,
|
||||
generateAudioChunk = audioGenerator,
|
||||
onResetContext = { liveClient.close() },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue