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:
Aryan 2026-05-16 16:19:31 +05:30 committed by GitHub
parent 759d4b73a0
commit 056485a140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 9184 additions and 5947 deletions

View file

@ -1,6 +1,7 @@
@file:Suppress("UnstableApiUsage")
import java.util.Properties
import javax.xml.parsers.DocumentBuilderFactory
plugins {
alias(libs.plugins.android.application)
@ -18,6 +19,27 @@ if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) }
}
fun configuredAppLocaleTags(): Set<String> {
val localesConfig = file("src/main/res/xml/locales_config.xml")
val androidNamespace = "http://schemas.android.com/apk/res/android"
val document = DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(localesConfig)
val localeNodes = document.getElementsByTagName("locale")
return buildSet {
for (index in 0 until localeNodes.length) {
val name = localeNodes.item(index)
.attributes
?.getNamedItemNS(androidNamespace, "name")
?.nodeValue
?.takeIf { it.isNotBlank() }
if (name != null) add(name)
}
}
}
kotlin {
jvmToolchain(21)
}
@ -33,7 +55,7 @@ android {
versionCode = 51
versionName = "1.0.47"
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
resourceConfigurations += configuredAppLocaleTags()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {

View file

@ -207,6 +207,131 @@
};
window.setTextSelectionEnabled(true);
installReaderSelectionBridge();
}
function installReaderSelectionBridge() {
if (window.__readerSelectionBridgeInstalled) {
return;
}
window.__readerSelectionBridgeInstalled = true;
var pointerActive = false;
var lastPayloadJson = "";
function buildSelectionPayload() {
var selection = window.getSelection && window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
var selectedText = selection.toString().trim();
if (!selectedText) {
return null;
}
var range = selection.getRangeAt(0);
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;
}
}
return {
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
};
}
function reportSelectionToBridge() {
if (pointerActive) {
return;
}
if (!window.ReaderSelectionBridge || !window.ReaderSelectionBridge.onSelectionChanged) {
return;
}
var payload = buildSelectionPayload();
if (!payload) {
lastPayloadJson = "";
return;
}
var payloadJson = JSON.stringify(payload);
if (payloadJson === lastPayloadJson) {
return;
}
lastPayloadJson = payloadJson;
window.ReaderSelectionBridge.onSelectionChanged(payloadJson);
}
function scheduleSelectionReport(delay) {
setTimeout(reportSelectionToBridge, delay == null ? 160 : delay);
}
function markPointerActive() {
pointerActive = true;
}
function markPointerReleased() {
pointerActive = false;
scheduleSelectionReport(80);
scheduleSelectionReport(220);
scheduleSelectionReport(520);
}
document.addEventListener("selectionchange", function () {
if (!pointerActive) {
scheduleSelectionReport(160);
}
});
document.addEventListener("pointerdown", markPointerActive, true);
document.addEventListener("pointerup", markPointerReleased, true);
document.addEventListener("pointercancel", markPointerReleased, true);
document.addEventListener("touchstart", markPointerActive, true);
document.addEventListener("touchend", markPointerReleased, true);
document.addEventListener("touchcancel", markPointerReleased, true);
document.addEventListener("mousedown", markPointerActive, true);
document.addEventListener("mouseup", markPointerReleased, true);
document.addEventListener("keyup", function () { scheduleSelectionReport(80); });
window.addEventListener("scroll", function () { scheduleSelectionReport(80); }, false);
}
window.VIEWPORT_PADDING_TOP = 0;
@ -766,6 +891,8 @@
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
if (textAlign === "left") {
alignCss = alignSelector + " { text-align: left !important; }";
} else if (textAlign === "right") {
alignCss = alignSelector + " { text-align: right !important; }";
} else if (textAlign === "justify") {
alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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))
}
}
}

View file

@ -723,7 +723,7 @@ fun LibraryScreenContent(
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
}
)

View file

@ -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
}

View file

@ -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) {

View file

@ -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(" - ")
}

View file

@ -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

View file

@ -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
) {

View file

@ -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()

View file

@ -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))
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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 }
}

View file

@ -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
},

View file

@ -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) }

View file

@ -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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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))

View file

@ -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()

View file

@ -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 = {

View file

@ -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")

View file

@ -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() },

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M120,840L120,760L840,760L840,840L120,840ZM360,680L360,600L840,600L840,680L360,680ZM120,520L120,440L840,440L840,520L120,520ZM360,360L360,280L840,280L840,360L360,360ZM120,200L120,120L840,120L840,200L120,200Z"/>
</vector>

View file

@ -642,4 +642,7 @@
<string name="label_highlight_color">مُظلِل</string>
<string name="msg_page_unavailable">الصفحة غير متوفرة</string>
<string name="sign_in_to_purchase_credits">يرجى تسجيل الدخول إلى حساب Google الخاص بك لشراء رصيد.</string>
<string name="language_system_default">لغة النظام</string>
<string name="language_english">English (الإنجليزية)</string>
<string name="language_spanish">Español (الإسبانية)</string>
</resources>

View file

@ -732,12 +732,15 @@
<string name="options_export_logs_last_lines">Protokolle exportieren (Letzte %1$d Zeilen)</string>
<string name="dialog_strict_file_filter_title">Strengen Dateifilter aktivieren</string>
<string name="dialog_strict_file_filter_desc">Wenn Sie diese Option aktivieren, werden einige unterstützte Dateiformate wie AZW3, CB7 und FB2 je nach Dateimanager möglicherweise nicht angezeigt.\n\nSind sie sicher, dass Sie diesen Filter aktivieren möchten?</string>
<string name="language_system_default">Systemstandard</string>
<string name="language_english">Englisch</string>
<string name="language_english_default">Englisch (Standard)</string>
<string name="language_arabic">العربية (Arabisch)</string>
<string name="language_german">Deutsch (Deutsch)</string>
<string name="language_turkish">Türkçe (Türkisch)</string>
<string name="language_french">Français (Französisch)</string>
<string name="language_russian">Русский (Russisch)</string>
<string name="language_spanish">Español (Spanisch)</string>
<string name="app_theme_title">App-Thema</string>
<string name="app_theme_text_brightness">Text-Helligkeit</string>
<string name="app_theme_color_scheme">Farbschema</string>

View file

@ -2,22 +2,54 @@
<resources>
<plurals name="book_count">
<item quantity="one">%1$d libro</item>
<item quantity="many">%1$d libros</item>
<item quantity="other">%1$d libros</item>
</plurals>
<plurals name="book_word">
<item quantity="one">libro</item>
<item quantity="many">libros</item>
<item quantity="other">libros</item>
</plurals>
<plurals name="shelf_count">
<item quantity="one">%1$d estantería</item>
<item quantity="many">%1$d estanterías</item>
<item quantity="other">%1$d estanterías</item>
<item quantity="one">%1$d estante</item>
<item quantity="other">%1$d estantes</item>
</plurals>
<plurals name="search_results_count">
<item quantity="one">%1$d resultado encontrado</item>
<item quantity="many">%1$d resultados encontrados</item>
<item quantity="other">%1$d resultados encontrados</item>
</plurals>
<plurals name="search_matches_count">
<item quantity="one">%1$d coincidencia encontrada</item>
<item quantity="other">%1$d coincidencias encontradas</item>
</plurals>
<plurals name="dialog_delete_permanently">
<item quantity="one">Eliminar archivo permanentemente</item>
<item quantity="other">Eliminar archivos permanentemente</item>
</plurals>
<plurals name="dialog_permanently_delete_desc">
<item quantity="one">¿Quieres eliminar permanentemente %1$d archivo seleccionado de tu dispositivo? Esta acción no se puede deshacer.</item>
<item quantity="other">¿Quieres eliminar permanentemente %1$d archivos seleccionados de tu dispositivo? Esta acción no se puede deshacer.</item>
</plurals>
<plurals name="dialog_remove_recents_desc">
<item quantity="one">¿Quieres quitar %1$d archivo seleccionado de la lista de archivos recientes? Volverá a aparecer si lo abres de nuevo desde la biblioteca.</item>
<item quantity="other">¿Quieres quitar %1$d archivos seleccionados de la lista de archivos recientes? Volverán a aparecer si los abres de nuevo desde la biblioteca.</item>
</plurals>
<plurals name="dialog_remove_from_shelf_desc">
<item quantity="one">¿Seguro que quieres quitar %1$d libro del estante \'%2$s\'? El libro seguirá en tu biblioteca y aparecerá en Sin estante.</item>
<item quantity="other">¿Seguro que quieres quitar %1$d libros del estante \'%2$s\'? Los libros seguirán en tu biblioteca y aparecerán en Sin estante.</item>
</plurals>
<plurals name="banner_books_removed_library">
<item quantity="one">%1$d libro quitado de la biblioteca.</item>
<item quantity="other">%1$d libros quitados de la biblioteca.</item>
</plurals>
<plurals name="folder_count">
<item quantity="one">%1$d carpeta</item>
<item quantity="other">%1$d carpetas</item>
</plurals>
<plurals name="tag_count">
<item quantity="one">%1$d etiqueta</item>
<item quantity="other">%1$d etiquetas</item>
</plurals>
<plurals name="tts_cache_chunk_count_parenthetical">
<item quantity="one">(%1$d fragmento)</item>
<item quantity="other">(%1$d fragmentos)</item>
</plurals>
</resources>

File diff suppressed because it is too large Load diff

View file

@ -705,12 +705,15 @@
<string name="options_language">Langue</string>
<string name="options_export_logs_last_lines">Exporter les logs (dernières %1$d lignes)</string>
<string name="dialog_strict_file_filter_desc">Si vous activez cette option, certains formats supportés comme AZW3, CB7 et FB2 pourraient ne pas apparaitre, selon votre gestionnaire de fichiers.\n\nÊtes-vous sûr de vouloir activer ce filtre?</string>
<string name="language_system_default">Langue du système</string>
<string name="language_english">English (Anglais)</string>
<string name="language_english_default">English (Anglais; par défaut)</string>
<string name="language_arabic">العربية (Arabe)</string>
<string name="language_german">Deutsch (Allemand)</string>
<string name="language_turkish">Türkçe (Turque)</string>
<string name="language_french">Français</string>
<string name="language_russian">Русский (Russe)</string>
<string name="language_spanish">Español (Espagnol)</string>
<string name="app_theme_contrast">Contraste</string>
<string name="app_theme_text_brightness">Luminosité du texte</string>
<string name="app_theme_preset_ocean">Océan</string>

View file

@ -707,12 +707,15 @@
<string name="options_export_logs_last_lines">Экспорт %1$d последних строк журнала</string>
<string name="dialog_strict_file_filter_title">Строгий фильтр файлов</string>
<string name="dialog_strict_file_filter_desc">Если вы включите эту функцию, некоторые поддерживаемые типы файлов, такие как AZW3, CB7 и FB2, могут не отображаться в зависимости от используемого вами файлового менеджера.\n\nВы уверены, что хотите включить этот фильтр?</string>
<string name="language_system_default">Системный язык</string>
<string name="language_english">Английский</string>
<string name="language_english_default">Английский (по умолчанию)</string>
<string name="language_arabic">Арабский</string>
<string name="language_german">Немецкий</string>
<string name="language_turkish">Турецкий</string>
<string name="language_french">Французский</string>
<string name="language_russian">Русский</string>
<string name="language_spanish">Испанский</string>
<string name="app_theme_title">Тема приложения</string>
<string name="app_theme_appearance">Внешний вид</string>
<string name="app_theme_contrast">Контрастность</string>

View file

@ -707,12 +707,15 @@
<string name="options_export_logs_last_lines">Günlükleri Dışarı Aktar (Son %1$d satır)</string>
<string name="dialog_strict_file_filter_title">Katı Dosya Filtresini Aç</string>
<string name="dialog_strict_file_filter_desc">Bunu etkinleştirirseniz, dosya yöneticinize bağlı olarak AZW3, CB7 ve FB2 gibi bazı desteklenen dosya türleri görünmeyebilir.\n\nBu filtreyi etkinleştirmek istediğinizden emin misiniz?</string>
<string name="language_system_default">Sistem varsayılanı</string>
<string name="language_english">İngilizce</string>
<string name="language_english_default">İngilizce (Varsayılan)</string>
<string name="language_arabic">العربية (Arapça)</string>
<string name="language_german">Deutsch (Almanca)</string>
<string name="language_turkish">Türkçe</string>
<string name="language_french">Français (Fransızca)</string>
<string name="language_russian">Русский (Rusça)</string>
<string name="language_spanish">Español (İspanyolca)</string>
<string name="app_theme_title">Uygulama Teması</string>
<string name="app_theme_appearance">Görünüm</string>
<string name="app_theme_contrast">Karşıtlık</string>

View file

@ -765,6 +765,7 @@
<!-- EpubReaderDrawer.kt -->
<string name="tab_chapters">Chapters</string>
<string name="tab_tabs">Tabs</string>
<string name="tab_bookmarks">Bookmarks</string>
<string name="tab_highlights">Highlights</string>
<string name="tab_pages">Pages</string>
@ -1108,12 +1109,15 @@
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
<!-- App language picker. Language names are shown in their own language followed by an English hint. -->
<string name="language_system_default">System default</string>
<string name="language_english">English</string>
<string name="language_english_default">English (Default)</string>
<string name="language_arabic">العربية (Arabic)</string>
<string name="language_german">Deutsch (German)</string>
<string name="language_turkish">Türkçe (Turkish)</string>
<string name="language_french">Français (French)</string>
<string name="language_russian">Русский (Russian)</string>
<string name="language_spanish">Español (Spanish)</string>
<!-- App-wide theme controls in HomeScreen.kt. -->
<string name="app_theme_title">App Theme</string>
@ -1326,4 +1330,223 @@
<string name="options_screen_capture_protection">Screen capture protection</string>
<string name="banner_screen_capture_protection_on">Screen capture protection is on</string>
<string name="banner_screen_capture_protection_off">Screen capture protection is off</string>
<!-- Android-only hardcoded UI string cleanup. -->
<string name="settings">Settings</string>
<string name="action_edit">Edit</string>
<string name="action_restore">Restore</string>
<!-- %1$s = the selected option label. -->
<string name="option_selected_format">%1$s selected</string>
<string name="reader_defaults">Reader defaults</string>
<!-- Debug-only placeholder, not shown in normal user flows. -->
<string name="debug_actions_existing_menus" translatable="false">Debug actions remain in their existing menus.</string>
<!-- PDF and OCR are technical feature names; keep the acronyms as-is. -->
<string name="pdf_specific_settings_existing_reader">PDF-specific OCR, annotation, and tool settings remain in the PDF reader.</string>
<!-- Provider/product name. Do not translate. -->
<string name="provider_gemini" translatable="false">Gemini</string>
<!-- Provider/product name. Do not translate. -->
<string name="provider_groq" translatable="false">Groq</string>
<!-- AI = Artificial Intelligence. Keep the acronym "AI" as-is. -->
<string name="ai_definition_title">AI Definition</string>
<!-- %1$d = 1-indexed chapter number. -->
<string name="chapter_number_format">Chapter %1$d</string>
<string name="location_generic">Location</string>
<string name="custom_font_fallback">Custom Font</string>
<!-- Raw status/detail format only. %1$d = response code; %2$s = server/system detail. -->
<string name="error_response_code_with_detail" translatable="false">%1$d. %2$s</string>
<!-- %1$s = error detail from the system. -->
<string name="error_occurred_format">An error occurred: %1$s</string>
<!-- %1$s = error detail from the document loader. -->
<string name="error_loading_document_format">Error loading document: %1$s</string>
<!-- OCR = Optical Character Recognition. Keep the acronym as-is. -->
<string name="error_no_text_on_page_after_ocr">OCR found no text on this page.</string>
<string name="error_page_text_not_extractable">Page seems empty or text not extractable.</string>
<!-- PDF = Portable Document Format. Keep the acronym as-is. -->
<string name="pdf_error_blank_page_summary">Cannot summarize a blank page.</string>
<!-- PDF = Portable Document Format. Keep the acronym as-is. -->
<string name="pdf_error_document_not_loaded">Document not loaded.</string>
<!-- AI = Artificial Intelligence; OSS = Open Source Software. Keep both acronyms as-is. -->
<string name="ai_error_offline_oss">AI features are unavailable in the offline OSS build.</string>
<string name="ai_error_blocked_safety">Blocked for safety reasons.</string>
<!-- AI = Artificial Intelligence. %1$s = feature name such as "Summaries". -->
<string name="ai_error_choose_model">Choose a model for %1$s in AI key and model settings.</string>
<!-- API = Application Programming Interface. %1$s = provider name such as Gemini or Groq. -->
<string name="ai_error_add_provider_key">Add a %1$s API key in AI key and model settings.</string>
<!-- AI = Artificial Intelligence. -->
<string name="ai_error_provider_empty_response">The AI provider returned an empty response.</string>
<!-- AI = Artificial Intelligence. %1$d = HTTP/status code; %2$s = raw provider error body. -->
<string name="ai_error_provider_error">AI provider error: %1$d. %2$s</string>
<!-- Gemini and Groq are provider names. PDF = file format; keep all as-is. -->
<string name="ai_error_gemini_required_for_image_summary">This summary needs a Gemini model because the selected Groq models do not support PDF/image input.</string>
<string name="feedback_error_sign_in_required">You must be signed in to use feedback.</string>
<string name="feedback_error_sign_in_submit">You must be signed in to submit feedback.</string>
<string name="feedback_error_ticket_image_limit">Max 3 images allowed for tickets.</string>
<string name="feedback_error_message_image_limit">Max 5 images allowed per message.</string>
<!-- 5MB = five megabytes; keep the unit compact. -->
<string name="feedback_error_images_size_limit">One or more images exceed the 5MB limit.</string>
<!-- %1$s = error detail from the feedback backend. -->
<string name="feedback_error_create_ticket">Failed to create ticket: %1$s</string>
<!-- %1$s = error detail from the feedback backend. -->
<string name="feedback_error_send">Failed to send: %1$s</string>
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = error detail. -->
<string name="opds_error_load_feed">Failed to load feed: %1$s</string>
<string name="opds_error_empty_response">Empty body</string>
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = server message. -->
<string name="opds_error_download_failed">Download failed: %1$s</string>
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = error detail. -->
<string name="opds_error_download_error">Download error: %1$s</string>
<!-- %1$s = billing debug message from Google Play Billing. -->
<string name="billing_error_purchase_failed">Purchase failed: %1$s</string>
<string name="billing_error_connect">Could not connect to billing service.</string>
<string name="billing_error_products_not_found">Products not found.</string>
<string name="billing_error_query_products_failed">Failed to query products</string>
<!-- OSS = Open Source Software. Keep the acronym as-is. -->
<string name="billing_error_not_available_oss">Not available in Open Source version</string>
<!-- TTS = Text-to-Speech. -->
<string name="tts_error_no_text">No text to read.</string>
<string name="tts_error_starting_playback">Error starting playback.</string>
<string name="tts_error_load_audio">Failed to load audio.</string>
<!-- TTS = Text-to-Speech. %1$s = playback error detail. -->
<string name="tts_error_playback">Playback error: %1$s</string>
<!-- TTS = Text-to-Speech. -->
<string name="tts_error_cloud_not_configured">Cloud TTS is not configured.</string>
<!-- Fallback media title when the source book title is unavailable. -->
<string name="tts_unknown_book">Unknown Book</string>
<!-- AI key/model settings. AI = Artificial Intelligence. -->
<string name="ai_settings_title">AI keys and models</string>
<string name="ai_settings_saved_keys">Saved keys</string>
<string name="ai_settings_add_or_replace_key">Add or replace key</string>
<string name="label_provider">Provider</string>
<!-- API = Application Programming Interface. Keep the acronym as-is. -->
<string name="label_api_key">API key</string>
<string name="ai_settings_save_key">Save key</string>
<string name="ai_settings_use_one_model">Use one model for all features</string>
<string name="ai_settings_use_one_model_desc">When off, each reader AI feature uses its own selected model.</string>
<!-- AI = Artificial Intelligence. -->
<string name="ai_settings_all_features">All AI features</string>
<string name="ai_settings_all_features_desc">Smart dictionary, summaries, and recaps all use this model.</string>
<string name="ai_settings_smart_dictionary">Smart dictionary</string>
<string name="ai_settings_smart_dictionary_desc">Used when defining selected words or phrases.</string>
<string name="ai_settings_summaries">Summaries</string>
<!-- EPUB and PDF are file format names. Gemini is a provider name. Keep all as-is. -->
<string name="ai_settings_summaries_desc">Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.</string>
<string name="ai_settings_recaps">Recaps</string>
<string name="ai_settings_recaps_desc">Used for story recap generation.</string>
<!-- TTS = Text-to-Speech. Gemini is a provider name. %1$s = exact model identifier; do not translate the inserted value. -->
<string name="ai_settings_cloud_tts_desc">Uses the saved Gemini key. Only %1$s is supported for now.</string>
<!-- %1$s = provider name, such as Gemini or Groq. -->
<string name="dialog_save_provider_key">Save %1$s key?</string>
<string name="dialog_save_key_desc">After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.</string>
<!-- %1$s = provider name, such as Gemini or Groq. -->
<string name="dialog_delete_provider_key">Delete %1$s key?</string>
<string name="dialog_delete_key_desc">Features using this provider will stop working until a new key is saved.</string>
<string name="ai_settings_no_key_saved">No key saved</string>
<!-- %1$s = provider display name. -->
<string name="content_desc_delete_provider_key">Delete %1$s key</string>
<string name="label_model">Model</string>
<string name="ai_settings_no_model_selected">No model selected</string>
<string name="options_show_ai_in_reader">Show AI in reader</string>
<string name="options_hide_ai_in_reader">Hide AI in reader</string>
<!-- Theme and texture editor. -->
<string name="theme_solid_colors">Solid Colors</string>
<string name="theme_textured">Textured</string>
<string name="theme_custom_solid_default">Custom Solid</string>
<string name="theme_custom_textured_default">Custom Textured</string>
<string name="theme_select_custom_texture">Select Custom Texture</string>
<string name="app_theme_text_brightness_light">Text Brightness (Light)</string>
<string name="app_theme_text_brightness_dark">Text Brightness (Dark)</string>
<string name="label_default">Default</string>
<string name="label_left">Left</string>
<string name="label_right">Right</string>
<string name="label_justify">Justify</string>
<string name="label_always_show">Always Show</string>
<string name="label_sync_with_menus">Sync with Menus</string>
<string name="label_always_hide">Always Hide</string>
<string name="label_top">Top</string>
<string name="label_bottom">Bottom</string>
<!-- File metadata dialog. -->
<string name="dialog_restore_original_metadata">Restore original metadata?</string>
<!-- EPUB is a file format name; keep the acronym as-is. -->
<string name="dialog_restore_original_metadata_desc">This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change.</string>
<!-- EPUB is a file format name; keep the acronym as-is. -->
<string name="metadata_provenance_epub_edited">EPUB metadata edited</string>
<!-- EPUB is a file format name; keep the acronym as-is. -->
<string name="metadata_provenance_from_epub">Metadata from EPUB file</string>
<string name="metadata_provenance_display_name_changed">Display name changed in app</string>
<string name="metadata_provenance_from_file">Metadata from file</string>
<string name="section_metadata">Metadata</string>
<string name="section_file">File</string>
<string name="label_title">Title</string>
<string name="label_series">Series</string>
<string name="label_reading">Reading</string>
<string name="label_file_name_simple">File name</string>
<string name="label_modified">Modified</string>
<string name="label_summary">Summary</string>
<string name="label_library_tags">Library tags</string>
<string name="label_editable_metadata">Editable metadata</string>
<string name="label_display_name">Display name</string>
<string name="label_name_shown_in_reader">Name shown in Reader</string>
<!-- %1$s = original file name. -->
<string name="original_file_format">Original file: %1$s</string>
<!-- Toolbar customization. -->
<string name="toolbar_top_bar">Top Bar</string>
<string name="toolbar_bottom_bar">Bottom Bar</string>
<string name="toolbar_hidden_tools">Hidden Tools</string>
<string name="toolbar_more_menu">More menu</string>
<string name="toolbar_hidden_tools_menu">Hidden tools</string>
<string name="toolbar_drop_tools_here">Drop tools here</string>
<string name="content_desc_drag_to_reorder">Drag to reorder</string>
<string name="tool_external_apps">External Apps</string>
<string name="tool_navigation_slider">Navigation Slider</string>
<string name="tool_sidebar">Sidebar</string>
<string name="tool_highlight_selectable_text">Highlight selectable text</string>
<string name="tool_edit_mode">Edit Mode</string>
<!-- TTS = Text-to-Speech. Keep the acronym as-is. -->
<string name="tool_tts_controls">TTS Controls</string>
<string name="tool_reading_mode">Reading Mode</string>
<string name="tool_page_management">Page Management</string>
<!-- "Text View" is the PDF reflow feature name. -->
<string name="tool_text_view_reflow">Text View (Reflow)</string>
<!-- TTS word replacement sheet. TTS = Text-to-Speech. -->
<string name="tts_replacements_current_book">Current book</string>
<string name="tts_replacements_tab_global">Global</string>
<string name="tts_replacements_tab_this_book">This book</string>
<string name="tts_replacements_enable">Enable replacements</string>
<string name="tts_replacements_enable_desc">Rules here apply to every book unless disabled for a specific title.</string>
<string name="tts_replacements_add_rule">Add rule</string>
<string name="tts_replacements_add_book_rule">Add book rule</string>
<string name="tts_replacements_empty_global">No global replacement rules yet.</string>
<string name="tts_replacements_empty_book">No book-specific rules yet.</string>
<string name="tts_replacements_use_global_here">Use global rules here</string>
<string name="tts_replacements_use_global_here_desc">Turn this off when a book needs its own pronunciation choices.</string>
<string name="tts_replacements_enable_book_rules">Enable book rules</string>
<string name="tts_replacements_enable_book_rules_desc">Local rules run after global rules.</string>
<string name="tts_replacements_inherited_global_rules">Inherited global rules</string>
<string name="tts_replacements_no_global_rules">No global rules to inherit.</string>
<string name="tts_replacements_allowed_in_book">Allowed in this book</string>
<string name="tts_replacements_disabled_for_book">Disabled for this book</string>
<string name="tts_replacements_suggestions">Suggestions</string>
<!-- English sample input for replacement previews; keep abbreviations as written so built-in examples behave consistently. -->
<string name="tts_replacements_preview_default" translatable="false">Dr. Smith met NASA at 5 p.m.</string>
<string name="tts_replacements_new_replacement">New replacement</string>
<string name="tts_replacements_edit_replacement">Edit replacement</string>
<string name="tts_replacements_label_replace">Replace</string>
<string name="tts_replacements_label_speak_as">Speak as</string>
<string name="tts_replacements_chip_enabled">Enabled</string>
<!-- Regex is a technical term for regular expression; keep as-is. -->
<string name="tts_replacements_chip_regex" translatable="false">Regex</string>
<string name="tts_replacements_chip_whole_word">Whole word</string>
<string name="tts_replacements_chip_match_case">Match case</string>
<string name="tts_replacements_label_preview_input">Preview input</string>
<string name="tts_replacements_rules">Rules</string>
<!-- Lowercase because it is inserted into summaries like "word -> silence". -->
<string name="tts_replacements_silence">silence</string>
<string name="tts_replacements_plain_text">Plain text</string>
<!-- Lowercase because it is joined into a comma-separated option summary. -->
<string name="tts_replacements_case_sensitive">case-sensitive</string>
</resources>

View file

@ -3,7 +3,8 @@
<locale android:name="en"/>
<locale android:name="ar"/>
<locale android:name="de"/>
<locale android:name="tr"/>
<locale android:name="fr"/>
<locale android:name="ru"/>
<locale android:name="tr"/>
<locale android:name="es"/>
</locale-config>

View file

@ -25,6 +25,7 @@ class BillingClientWrapper(
private val externalScope: CoroutineScope,
private val onPurchaseVerified: (PurchaseEntity) -> Unit
) {
private val appContext = context.applicationContext
private val _proUpgradeState = MutableStateFlow(ProUpgradeState())
val proUpgradeState = _proUpgradeState.asStateFlow()
@ -41,7 +42,7 @@ class BillingClientWrapper(
productId: String = PRO_LIFETIME_PRODUCT_ID,
obfuscatedAccountId: String? = null
) {
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
_proUpgradeState.value = _proUpgradeState.value.copy(error = appContext.getString(R.string.billing_error_not_available_oss))
}
fun consumePurchase(purchaseToken: String) {}

View file

@ -0,0 +1,60 @@
package com.aryan.reader
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Test
class AppLanguageOptionsTest {
@Test
fun `supported app languages include Spanish`() {
assertEquals(
listOf("en", "ar", "de", "tr", "fr", "ru", "es"),
supportedAppLanguageOptions.mapNotNull { it.tag }
)
assertEquals(R.string.language_spanish, supportedAppLanguageOptions.last().labelRes)
}
@Test
fun `app language selection defaults to system before explicit overrides`() {
assertEquals(null, appLanguageSelectionOptions.first().tag)
assertEquals(R.string.language_system_default, appLanguageSelectionOptions.first().labelRes)
assertEquals(supportedAppLanguageOptions, appLanguageSelectionOptions.drop(1))
}
@Test
fun `supported app language tags are unique`() {
val tags = supportedAppLanguageOptions.mapNotNull { it.tag }
assertEquals(tags.distinct(), tags)
}
@Test
fun `supported app languages match Android locale config`() {
assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag })
}
private fun readLocaleConfigTags(): List<String> {
val localeConfig = listOf(
File("src/main/res/xml/locales_config.xml"),
File("app/src/main/res/xml/locales_config.xml")
).first { it.isFile }
val document = DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(localeConfig)
val localeNodes = document.getElementsByTagName("locale")
return buildList {
for (index in 0 until localeNodes.length) {
add(
localeNodes.item(index)
.attributes
.getNamedItemNS("http://schemas.android.com/apk/res/android", "name")
.nodeValue
)
}
}
}
}

View file

@ -198,4 +198,29 @@ class EpubReaderBridgeAndControlsTest {
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
}
@Test
fun `reader toolbar reset defaults match first-run toolbar defaults`() {
assertEquals(setOf(ReaderTool.SCREEN_ORIENTATION.name), defaultReaderHiddenTools())
assertEquals(ReaderTool.entries.toList(), defaultReaderToolOrder())
assertEquals(
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
defaultReaderBottomTools()
)
val defaultItems = buildReaderToolbarItems(
hiddenTools = defaultReaderHiddenTools(),
toolOrder = defaultReaderToolOrder(),
bottomTools = defaultReaderBottomTools()
)
assertEquals(
ToolbarSection.HIDDEN,
defaultItems.single { it.tool == ReaderTool.SCREEN_ORIENTATION }.section
)
assertEquals(
ToolbarSection.BOTTOM,
defaultItems.single { it.tool == ReaderTool.SLIDER }.section
)
}
}

View file

@ -57,7 +57,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
verticalMargin = 0.4f,
fontFamily = ReaderFont.LORA,
customFontPath = null,
textAlign = ReaderTextAlign.JUSTIFY
textAlign = ReaderTextAlign.RIGHT
)
saveLocalReaderSettings(
context = context,
@ -78,7 +78,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
assertEquals(1.4f, global.fontSize, 0.0001f)
assertEquals(ReaderFont.LORA, global.font)
assertEquals(ReaderTextAlign.JUSTIFY, global.textAlign)
assertEquals(ReaderTextAlign.RIGHT, global.textAlign)
assertNull(global.customPath)
assertEquals(0.9f, local.fontSize, 0.0001f)
assertEquals(1.1f, local.lineHeight, 0.0001f)

View file

@ -54,6 +54,35 @@ class PaginatedHighlightMappingTest {
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `paginated page highlights are scoped to page chapter`() {
val chapterFourHighlight = highlight(
cfi = "/4/10:11|/4/12:79",
text = "Original chapter text",
chapterIndex = 4
)
val chapterFiveHighlight = highlight(
cfi = "/4/10:11|/4/12:79",
text = "Different chapter text",
chapterIndex = 5
)
assertEquals(
listOf(chapterFiveHighlight),
highlightsForPaginatedPage(
pageChapterIndex = 5,
userHighlights = listOf(chapterFourHighlight, chapterFiveHighlight)
)
)
assertEquals(
emptyList<UserHighlight>(),
highlightsForPaginatedPage(
pageChapterIndex = null,
userHighlights = listOf(chapterFourHighlight)
)
)
}
private fun paragraph(
text: String,
cfi: String,
@ -70,14 +99,15 @@ class PaginatedHighlightMappingTest {
private fun highlight(
cfi: String,
text: String
text: String,
chapterIndex: Int = 0
): UserHighlight {
return UserHighlight(
id = "highlight",
cfi = cfi,
text = text,
color = HighlightColor.YELLOW,
chapterIndex = 0
chapterIndex = chapterIndex
)
}
}

View file

@ -160,4 +160,36 @@ class PdfReaderSettingsAndSharedModelsTest {
assertTrue(width >= 1)
assertTrue(height >= 1)
}
@Test
fun `pdf toolbar reset defaults match first-run toolbar defaults`() {
assertEquals(
setOf(PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name),
defaultPdfHiddenTools()
)
assertEquals(PdfReaderTool.entries.toList(), defaultPdfToolOrder())
assertEquals(
PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
defaultPdfBottomTools()
)
val defaultItems = buildPdfToolbarItems(
hiddenTools = defaultPdfHiddenTools(),
toolOrder = defaultPdfToolOrder(),
bottomTools = defaultPdfBottomTools()
)
assertEquals(
PdfToolbarSection.HIDDEN,
defaultItems.single { it.tool == PdfReaderTool.SCREEN_ORIENTATION }.section
)
assertEquals(
PdfToolbarSection.HIDDEN,
defaultItems.single { it.tool == PdfReaderTool.HIGHLIGHT_ALL }.section
)
assertEquals(
PdfToolbarSection.BOTTOM,
defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section
)
}
}