String extract (#148)

* FolderSyncWorker: update supported file types in getFileType

* Extract hardcoded strings to `strings.xml` and `plurals.xml`

* Extract hardcoded strings to `strings.xml` and `plurals.xml`

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in MainViewModel

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in Common.kt

* Extract hardcoded strings to `strings.xml` and `plurals.xml` in Epub Reader
This commit is contained in:
Aryan 2026-04-05 09:32:59 +05:30 committed by GitHub
parent da1c5bce1e
commit 381193d774
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1388 additions and 855 deletions

View file

@ -1,5 +1,5 @@
// Common.kt
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
@file:OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader
@ -389,14 +389,14 @@ fun SearchTopBar(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close Search"
contentDescription = stringResource(R.string.content_desc_close_search)
)
}
TextField(
value = searchState.searchQuery,
onValueChange = { searchState.onQueryChange(it) },
placeholder = { Text("Search in book...") },
placeholder = { Text(stringResource(R.string.search_in_book)) },
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
@ -425,7 +425,7 @@ fun SearchTopBar(
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear Search"
contentDescription = stringResource(R.string.content_desc_clear_search)
)
}
}
@ -446,7 +446,10 @@ fun SearchTopBar(
) {
Icon(
imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown,
contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results"
contentDescription = stringResource(
if (searchState.showSearchResultsPanel) R.string.content_desc_hide_results
else R.string.content_desc_show_results
)
)
}
}
@ -474,7 +477,7 @@ fun SearchNavigationControls(
onClick = { onNavigate(searchState.currentSearchResultIndex - 1) },
enabled = searchState.currentSearchResultIndex > 0
) {
Icon(Icons.Default.ArrowDropUp, contentDescription = "Previous Search Result")
Icon(Icons.Default.ArrowDropUp, contentDescription = stringResource(R.string.content_desc_prev_result))
}
Text(
@ -489,7 +492,7 @@ fun SearchNavigationControls(
onClick = { onNavigate(searchState.currentSearchResultIndex + 1) },
enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1
) {
Icon(Icons.Default.ArrowDropDown, contentDescription = "Next Search Result")
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.content_desc_next_result))
}
}
}
@ -549,7 +552,7 @@ fun SummarizationPopup(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator()
Text("Generating summary...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.generating_summary), modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
}
} else if (result != null) {
val summaryText = result.summary
@ -596,7 +599,7 @@ fun SummarizationPopup(
) {
Icon(
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
contentDescription = stringResource(if (isTtsSessionActive) R.string.action_stop else R.string.action_read_aloud)
)
}
Spacer(modifier = Modifier.width(8.dp))
@ -605,7 +608,7 @@ fun SummarizationPopup(
}) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy"
contentDescription = stringResource(R.string.action_copy)
)
}
}
@ -656,7 +659,7 @@ fun SummarizationPopup(
onTextLayout = { textLayoutResult = it }
)
} else {
Text("No summary could be generated.", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.no_summary_generated), style = MaterialTheme.typography.bodyLarge)
}
}
}
@ -711,7 +714,7 @@ fun AiDefinitionPopup(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator()
Text("Thinking...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.ai_thinking), modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
}
} else if (result != null) {
word?.let {
@ -771,7 +774,7 @@ fun AiDefinitionPopup(
) {
Icon(
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
contentDescription = stringResource(if (isTtsSessionActive) R.string.action_stop else R.string.action_read_aloud)
)
}
Spacer(modifier = Modifier.width(8.dp))
@ -780,14 +783,14 @@ fun AiDefinitionPopup(
}) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy"
contentDescription = stringResource(R.string.action_copy)
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onOpenExternalDictionary) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Open in Dictionary App"
contentDescription = stringResource(R.string.content_desc_open_dictionary)
)
}
}
@ -839,11 +842,11 @@ fun AiDefinitionPopup(
onTextLayout = { textLayoutResult = it }
)
} else {
Text("AI could not provide a definition.", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.ai_no_definition), style = MaterialTheme.typography.bodyLarge)
}
} else if (word != null) {
Text(
text = "Asking AI about '$word'...",
text = stringResource(R.string.ai_asking_about, word),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
maxLines = 1,
@ -874,13 +877,13 @@ fun SearchResultsPanel(
}
results.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.search_no_results_simple), style = MaterialTheme.typography.bodyLarge)
}
}
else -> {
Column {
Text(
text = "${results.size} " + if (results.size == 1) "result found" else "results found",
text = LocalContext.current.resources.getQuantityString(R.plurals.search_results_count, results.size, results.size),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
@ -907,12 +910,13 @@ fun SearchResultsPanel(
suspend fun fetchAiDefinition(
text: String,
context: Context,
onUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
if (text.isBlank()) {
onError("Text is empty.")
onError(context.getString(R.string.error_text_empty))
onFinish()
return
}
@ -961,16 +965,16 @@ suspend fun fetchAiDefinition(
}
Timber.d("Definition: Finished reading stream.")
if (!hasReceivedData) {
onError("AI returned an empty definition.")
onError(context.getString(R.string.error_ai_empty_definition))
}
} 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("Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}")
onError("${responseCode}. ${errorDetail ?: context.getString(R.string.error_unknown_server)}")
}
} catch (e: Exception) {
Timber.e(e, "Network error fetching AI definition: ${e.message}")
onError("Network error. Check connection.")
onError(context.getString(R.string.error_network_check_connection))
} finally {
connection?.disconnect()
onFinish()
@ -1091,12 +1095,13 @@ class SummaryCacheManager(context: Context) {
suspend fun fetchRecap(
pastSummaries: List<String>,
currentText: String,
context: Context,
onUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
if (pastSummaries.isEmpty() && currentText.isBlank()) {
onError("Not enough context for a recap.")
onError(context.getString(R.string.error_not_enough_context))
onFinish()
return
}
@ -1143,14 +1148,14 @@ suspend fun fetchRecap(
}
}
}
if (!hasReceivedData) onError("Failed to parse recap.")
if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap))
} else {
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
onError("Error: $responseCode. ${errorBody ?: ""}")
onError("${responseCode}. ${errorBody ?: ""}")
}
} catch (e: Exception) {
Timber.e(e, "Recap error: ${e.message}")
onError("Network error during recap generation.")
onError(context.getString(R.string.error_network_recap))
} finally {
connection?.disconnect()
onFinish()
@ -1194,7 +1199,7 @@ fun TtsSettingsSheet(
.padding(bottom = 24.dp)
) {
Text(
text = "Text-to-Speech Settings",
text = stringResource(R.string.tts_settings),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
@ -1213,7 +1218,7 @@ fun TtsSettingsSheet(
Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer)
Spacer(Modifier.width(12.dp))
Text(
"Please stop playback to change settings.",
stringResource(R.string.tts_stop_to_change_settings),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
@ -1222,7 +1227,7 @@ fun TtsSettingsSheet(
}
Text(
text = "Synthesis Mode",
text = stringResource(R.string.tts_synthesis_mode),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
@ -1239,7 +1244,7 @@ fun TtsSettingsSheet(
) {
TtsPlaybackManager.TtsMode.entries.forEach { mode ->
val isSelected = currentMode == mode
val label = if (mode == TtsPlaybackManager.TtsMode.BASE) "On-Device" else "Cloud (HQ)"
val label = if (mode == TtsPlaybackManager.TtsMode.BASE) stringResource(R.string.tts_mode_on_device) else stringResource(R.string.tts_mode_cloud_hq)
val icon = if (mode == TtsPlaybackManager.TtsMode.BASE) Icons.Default.Smartphone else Icons.Default.Cloud
Surface(
@ -1271,7 +1276,7 @@ fun TtsSettingsSheet(
if (currentMode == TtsPlaybackManager.TtsMode.CLOUD) {
Text(
text = "Voice Selection",
text = stringResource(R.string.tts_voice_selection),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
@ -1306,7 +1311,7 @@ fun TtsSettingsSheet(
} else {
Icon(
imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = "Play Sample",
contentDescription = stringResource(R.string.tts_play_sample),
tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -1503,13 +1508,13 @@ fun DeviceVoiceSettingsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "On-Device Voice Settings",
text = stringResource(R.string.tts_device_voice_settings),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close Settings")
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close_settings))
}
}
@ -1534,19 +1539,19 @@ fun DeviceVoiceSettingsSheet(
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "System Default",
text = stringResource(R.string.tts_system_default),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Matches your Android system settings",
text = stringResource(R.string.tts_system_default_desc),
style = MaterialTheme.typography.bodySmall
)
}
if (savedVoiceName == null) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.content_desc_selected),
tint = MaterialTheme.colorScheme.primary
)
}
@ -1562,7 +1567,7 @@ fun DeviceVoiceSettingsSheet(
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text("Loading voices...", modifier = Modifier.padding(top = 48.dp))
Text(stringResource(R.string.tts_loading_voices), modifier = Modifier.padding(top = 48.dp))
}
} else if (allVoices.isEmpty()) {
Box(
@ -1570,7 +1575,7 @@ fun DeviceVoiceSettingsSheet(
contentAlignment = Alignment.Center
) {
Text(
"No voices available on this device.",
stringResource(R.string.tts_no_voices),
color = MaterialTheme.colorScheme.error
)
}
@ -1583,7 +1588,7 @@ fun DeviceVoiceSettingsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Specific Voices",
text = stringResource(R.string.tts_specific_voices),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@ -1597,7 +1602,7 @@ fun DeviceVoiceSettingsSheet(
.clickable { expandedLanguageMenu = true },
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(
border = BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
)
) {
@ -1644,7 +1649,7 @@ fun DeviceVoiceSettingsSheet(
if (filteredVoices.isNotEmpty()) {
Text(
text = "Available Voices (${filteredVoices.size})",
text = stringResource(R.string.tts_available_voices_count, filteredVoices.size),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp, start = 4.dp)
@ -1676,13 +1681,13 @@ fun DeviceVoiceSettingsSheet(
)
},
supportingContent = if (voice.locale.variant.isNotEmpty()) {
{ Text("Variant: ${voice.locale.variant}") }
{ Text(stringResource(R.string.tts_voice_variant, voice.locale.variant)) }
} else null,
leadingContent = {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.content_desc_selected),
tint = MaterialTheme.colorScheme.primary
)
} else {
@ -1698,8 +1703,7 @@ fun DeviceVoiceSettingsSheet(
Timber.e(e, "Failed to set language for sample")
}
ttsEngine?.voice = voice
val sampleText =
"This is a sample of ${voice.locale.displayLanguage}."
val sampleText = context.getString(R.string.tts_voice_sample_text, voice.locale.displayLanguage)
ttsEngine?.speak(
sampleText,
TextToSpeech.QUEUE_FLUSH,
@ -1709,7 +1713,7 @@ fun DeviceVoiceSettingsSheet(
}) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play Sample",
contentDescription = stringResource(R.string.tts_play_sample),
tint = MaterialTheme.colorScheme.primary
)
}
@ -1736,7 +1740,7 @@ fun DeviceVoiceSettingsSheet(
contentAlignment = Alignment.Center
) {
Text(
"No voices found for this language.",
stringResource(R.string.tts_no_voices_for_language),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -2164,14 +2168,13 @@ fun ReaderThemePanel(
.padding(16.dp)
.padding(bottom = 16.dp)
) {
Text(
"Reading Themes",
Text(stringResource(R.string.reading_themes),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Presets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
@ -2181,7 +2184,7 @@ fun ReaderThemePanel(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary)
}
@ -2189,7 +2192,7 @@ fun ReaderThemePanel(
Spacer(Modifier.height(8.dp))
if (customThemes.isEmpty()) {
Text("No custom themes yet. Tap '+' to create one.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.theme_no_custom), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
ThemeGrid(
themes = customThemes,
@ -2290,7 +2293,7 @@ fun ThemeBuilderView(
.padding(16.dp)
) {
Text(
text = if (initialTheme == null) "New Theme" else "Edit Theme",
text = stringResource(if (initialTheme == null) R.string.theme_new else R.string.theme_edit),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
@ -2302,7 +2305,7 @@ fun ThemeBuilderView(
androidx.compose.material3.OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme Name") },
label = { Text(stringResource(R.string.theme_name)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
singleLine = true
)
@ -2325,14 +2328,12 @@ fun ThemeBuilderView(
} else this
}) {
Column(Modifier.padding(16.dp).fillMaxWidth()) {
Text(
text = "So many books, so little time.",
Text(text = stringResource(R.string.theme_preview_quote),
color = txtColor,
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
Text(
text = "- Frank Zappa",
Text(text = stringResource(R.string.theme_preview_author),
color = txtColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
@ -2344,8 +2345,7 @@ fun ThemeBuilderView(
// Animated Contrast Warning
AnimatedVisibility(visible = contrast < 4.5f) {
Text(
"⚠️ Low contrast! This might cause eye strain.",
Text(stringResource(R.string.theme_low_contrast_warning),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 8.dp)
@ -2357,13 +2357,13 @@ fun ThemeBuilderView(
// Sleek Color Swatches
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
ColorSwatchItem(
label = "Page Color",
label = stringResource(R.string.theme_page_color),
color = bgColor,
onClick = { editingColorType = "bg" },
modifier = Modifier.weight(1f)
)
ColorSwatchItem(
label = "Text Color",
label = stringResource(R.string.theme_text_color),
color = txtColor,
onClick = { editingColorType = "text" },
modifier = Modifier.weight(1f)
@ -2379,13 +2379,13 @@ fun ThemeBuilderView(
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onCancel) {
Text("Cancel", color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.action_cancel), color = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(8.dp))
Button(onClick = {
onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true))
}) {
Text("Save", color = MaterialTheme.colorScheme.onPrimary)
Text(stringResource(R.string.action_save), color = MaterialTheme.colorScheme.onPrimary)
}
}
}
@ -2393,7 +2393,7 @@ fun ThemeBuilderView(
editingColorType?.let { type ->
ThemeColorPickerDialog(
initialColor = if (type == "bg") bgColor else txtColor,
title = if (type == "bg") "Page Color" else "Text Color",
title = if (type == "bg") stringResource(R.string.theme_page_color) else stringResource(R.string.theme_text_color),
bgColor = bgColor,
textColor = txtColor,
editingColorType = type,
@ -2502,14 +2502,12 @@ fun ThemeColorPickerDialog(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Live Preview",
Text(text = stringResource(R.string.theme_color_live_preview),
color = liveTextColor,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Reading is dreaming.",
Text(text = stringResource(R.string.theme_color_preview_text),
color = liveTextColor,
style = MaterialTheme.typography.bodySmall
)
@ -2553,7 +2551,7 @@ fun ThemeColorPickerDialog(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Text(stringResource(R.string.theme_color_hex), color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
}
@ -2562,18 +2560,15 @@ fun ThemeColorPickerDialog(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(
label = "R", value = currentColor.red,
RgbInputColumn(label = stringResource(R.string.color_r), value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "G", value = currentColor.green,
RgbInputColumn(label = stringResource(R.string.color_g), value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "B", value = currentColor.blue,
RgbInputColumn(label = stringResource(R.string.color_b), value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
@ -2593,7 +2588,7 @@ fun ThemeColorPickerDialog(
containerColor = Color.White
)
) {
Text("Save", color = Color.Black, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.action_save), color = Color.Black, fontWeight = FontWeight.Bold)
}
}
}

View file

@ -315,14 +315,19 @@ class FolderSyncWorker(
}
private fun getFileType(name: String, mimeType: String?): FileType? {
val lowerName = name.lowercase()
return when {
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || name.endsWith(".docx", true) -> FileType.DOCX
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
name.endsWith(".md", true) -> FileType.MD
name.endsWith(".txt", true) -> FileType.TXT
name.endsWith(".html", true) || name.endsWith(".xhtml", true) || name.endsWith(".htm", true) -> FileType.HTML
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
lowerName.endsWith(".cbz") -> FileType.CBZ
lowerName.endsWith(".cbr") -> FileType.CBR
lowerName.endsWith(".cb7") -> FileType.CB7
lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD
lowerName.endsWith(".txt") -> FileType.TXT
mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML
else -> null
}
}

View file

@ -59,6 +59,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.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -97,10 +98,10 @@ fun FontsScreen(
)
Scaffold(
modifier = Modifier.statusBarsPadding(), // Fixes content flowing under status bar
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text("Custom Fonts") },
title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -109,12 +110,11 @@ fun FontsScreen(
)
},
floatingActionButton = {
// Hide FAB when empty state is visible (list is empty)
if (fonts.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = { pickFontLauncher.launch(fontMimeTypes) },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Import Font") }
text = { Text(stringResource(R.string.import_font)) }
)
}
}
@ -122,15 +122,14 @@ fun FontsScreen(
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (fonts.isEmpty()) {
EmptyState(
title = "No Custom Fonts",
message = "Import TTF or OTF files to use them in your books.",
title = stringResource(R.string.no_custom_fonts),
message = stringResource(R.string.import_fonts_desc),
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
// Padding bottom 88.dp allows scrolling past the FloatingActionButton
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
@ -223,14 +222,14 @@ fun FontListItem(
) {
if (customTypeface != null) {
Text(
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
text = stringResource(R.string.font_preview_text),
fontFamily = customTypeface,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = "Preview unavailable (Invalid font file)",
text = stringResource(R.string.font_preview_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@ -255,18 +254,18 @@ fun DeleteFontConfirmationDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Font?") },
text = { Text("Are you sure you want to delete '$fontName'? This will remove it from all your devices if sync is on.") },
title = { Text(stringResource(R.string.dialog_delete_font)) },
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Delete")
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}

View file

@ -114,6 +114,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -247,7 +248,7 @@ fun HomeScreen(
try {
fallbackFilePickerLauncher.launch("*/*")
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner("No file manager found. Please install a file manager app.", isError = true)
viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true)
}
}
}
@ -336,17 +337,17 @@ fun HomeScreen(
if (recentFilesForHome.isEmpty()) {
if (uiState.recentFiles.isEmpty()) {
EmptyState(
title = "Your Library is Empty",
message = "Select a file to read, or sync a local folder to automatically import books.",
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f),
secondaryButtonText = "Setup Folder Sync",
secondaryButtonText = stringResource(R.string.setup_folder_sync),
onSecondaryClick = { viewModel.navigateToFolderSync() }
)
} else {
EmptyState(
title = "No Recent Files",
message = "Open a file from your library to see it here.",
title = stringResource(R.string.no_recent_files),
message = stringResource(R.string.no_recent_files_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
)
@ -409,8 +410,8 @@ fun HomeScreen(
if (showClearBookCacheDialog) {
DangerousFolderActionDialog(
title = "Clear Book Cache",
message = "This will clear all processed page in pagination mode. This helps fix layout issues but will require books to be re-processed next time you open them.",
title = stringResource(R.string.dialog_clear_book_cache),
message = stringResource(R.string.dialog_clear_book_cache_desc),
onConfirm = {
viewModel.clearBookCache()
showClearBookCacheDialog = false
@ -436,8 +437,8 @@ fun HomeScreen(
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
title = "Clear Reflow Cache",
message = "This will delete all generated 'Text View' versions of your PDFs and clear their associated images/HTML cache. Your original PDFs will remain untouched.",
title = stringResource(R.string.dialog_clear_reflow_cache),
message = stringResource(R.string.dialog_clear_reflow_cache_desc),
onConfirm = {
viewModel.clearReflowCache()
showClearReflowCacheDialog = false
@ -528,10 +529,10 @@ private fun RecentFilesContent(
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.Button(onClick = onSelectFileClick) {
Text("Select File")
Text(stringResource(R.string.empty_select_file))
}
androidx.compose.material3.Button(onClick = onNavigateToFolderSync) {
Text("Sync Folder")
Text(stringResource(R.string.sync_folder))
}
}
}
@ -570,7 +571,7 @@ private fun RecentFilesGrid(
Column(modifier = modifier) {
Text(
text = "Recent Files",
text = stringResource(R.string.recent_files),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp, top = 24.dp)
)
@ -650,7 +651,7 @@ fun RecentFileCard(
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = "Local Folder",
contentDescription = stringResource(R.string.local_folder),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
@ -671,7 +672,7 @@ fun RecentFileCard(
) {
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = "OPDS Stream",
contentDescription = stringResource(R.string.opds_stream),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
@ -691,7 +692,7 @@ fun RecentFileCard(
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
contentDescription = stringResource(R.string.pinned),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
@ -710,7 +711,7 @@ fun RecentFileCard(
} else {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "Not available locally",
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(48.dp),
tint = Color.White
)
@ -745,7 +746,7 @@ fun RecentFileCard(
) {
item.progressPercentage?.let { progress ->
Text(
text = "${progress.toInt()}% complete",
text = stringResource(R.string.progress_complete, progress.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@ -788,7 +789,7 @@ fun DefaultTopAppBar(
// Recent Files Limit Menu
Box {
IconButton(onClick = { showLimitMenu = true }) {
Icon(Icons.Default.FormatListNumbered, contentDescription = "Recent Files Limit")
Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.options_recent_limit))
}
DropdownMenu(
expanded = showLimitMenu, onDismissRequest = { showLimitMenu = false }
@ -796,7 +797,7 @@ fun DefaultTopAppBar(
val limitOptions = listOf(0, 10, 20, 50, 100)
limitOptions.forEach { limit ->
DropdownMenuItem(
text = { Text(if (limit == 0) "No limit" else "$limit files") },
text = { Text(if (limit == 0) stringResource(R.string.options_no_limit) else stringResource(R.string.options_files_limit, limit)) },
onClick = {
onRecentFilesLimitChange(limit)
showLimitMenu = false
@ -816,29 +817,28 @@ fun DefaultTopAppBar(
}
DropdownMenu(
expanded = showOptionsMenu, onDismissRequest = { showOptionsMenu = false }) {
DropdownMenuItem(text = { Text("About") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.about_title)) }, onClick = {
onAboutClick()
showOptionsMenu = false
})
HorizontalDivider()
DropdownMenuItem(text = { Text("Clear Book Cache") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text("Clear Reflow Cache") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_reflow_cache)) }, onClick = {
onClearReflowCache()
showOptionsMenu = false
})
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
HorizontalDivider()
DropdownMenuItem(text = { Text("[Debug] Show Device Management") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.debug_show_device_management)) }, onClick = {
onShowDeviceManagement()
showOptionsMenu = false
})
DropdownMenuItem(
text = { Text("[Debug] Clear Cloud & Local Data") },
DropdownMenuItem(text = { Text(stringResource(R.string.debug_clear_cloud_local_data)) },
onClick = {
onClearCloudData()
showOptionsMenu = false
@ -905,12 +905,8 @@ private fun AppDrawerContent(
// Signed-out: Show Sign In button at the top
Spacer(modifier = Modifier.height(8.dp))
NavigationDrawerItem(
icon = {
Icon(
Icons.Outlined.AccountCircle, contentDescription = "Sign In"
)
},
label = { Text("Sign in with Google") },
icon = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sign_in)) },
selected = false,
onClick = onSignInClick,
modifier = Modifier.padding(horizontal = 12.dp)
@ -918,7 +914,7 @@ private fun AppDrawerContent(
// LegalText
LegalText(
prefixText = "By signing in,",
prefixText = stringResource(R.string.drawer_by_signing_in),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
textAlign = TextAlign.Start
)
@ -928,14 +924,9 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.height(16.dp))
NavigationDrawerItem(
icon = {
Icon(
Icons.Default.VerifiedUser, contentDescription = "Episteme Pro"
)
},
icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) },
label = {
val text =
if (uiState.isProUser) "Episteme Pro" else "Upgrade to Episteme Pro"
val text = if (uiState.isProUser) stringResource(R.string.drawer_pro_unlocked) else stringResource(R.string.drawer_upgrade_pro)
Text(text)
},
selected = false,
@ -946,12 +937,9 @@ private fun AppDrawerContent(
// Sync Toggle Item
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.sync),
contentDescription = "Sync Library"
)
}, label = { Text("Sync Library") }, badge = {
icon = { Icon(painterResource(id = R.drawable.sync), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sync_library)) },
badge = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!uiState.isProUser) {
Icon(
@ -979,17 +967,12 @@ private fun AppDrawerContent(
}
if (uiState.currentUser != null && uiState.isSyncEnabled) {
NavigationDrawerItem(
icon = {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = "Backup Local Folders"
)
},
icon = { Icon(imageVector = Icons.Default.FolderSpecial, contentDescription = null) },
label = {
Column {
Text("Cloud sync for Local Folders")
Text(stringResource(R.string.drawer_backup_local_folders))
Text(
"Upload books from your synced folders to Google Drive).",
stringResource(R.string.drawer_backup_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -1020,32 +1003,22 @@ private fun AppDrawerContent(
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Episteme OSS", style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(R.string.app_name_oss), style = MaterialTheme.typography.titleMedium)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
}
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.fonts),
contentDescription = "Custom Fonts"
)
},
label = { Text("Custom Fonts") },
icon = { Icon(painterResource(id = R.drawable.fonts), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_custom_fonts)) },
selected = false,
onClick = onFontsClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback"
)
},
label = { Text("Help & Feedback") },
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_help_feedback)) },
selected = false,
onClick = { navController.navigate("feedback_screen_route") },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
@ -1054,13 +1027,8 @@ private fun AppDrawerContent(
if (!isOss) {
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.logout),
contentDescription = "Sign Out"
)
},
label = { Text("Sign Out") },
icon = { Icon(painterResource(id = R.drawable.logout), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_sign_out)) },
selected = false,
onClick = onSignOutClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
@ -1077,7 +1045,7 @@ private fun AppDrawerContent(
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
Text(
text = "Privacy Policy • Terms of Service • Licenses",
text = stringResource(R.string.legal_footer_combined),
style = scaledTextStyle,
maxLines = 1,
softWrap = false,
@ -1098,29 +1066,21 @@ private fun AppDrawerContent(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Privacy Policy",
text = stringResource(R.string.legal_privacy_policy),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(PRIVACY_POLICY_URL) },
softWrap = false
)
Text("", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant))
Text(
"",
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
softWrap = false
)
Text(
text = "Terms of Service",
text = stringResource(R.string.legal_terms_of_service),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(TERMS_URL) },
softWrap = false
)
Text("", style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant))
Text(
"",
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
softWrap = false
)
Text(
text = "Licenses",
text = stringResource(R.string.legal_licenses),
style = scaledTextStyle.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.clickable { uriHandler.openUri(LICENSES_URL) },
softWrap = false
@ -1136,13 +1096,13 @@ fun UpgradeDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VerifiedUser, contentDescription = null) },
title = { Text("Unlock Episteme Pro") },
text = { Text("Sync across devices is a Pro feature. Unlock all pro features with a single, one-time purchase.") },
title = { Text(stringResource(R.string.dialog_unlock_pro)) },
text = { Text(stringResource(R.string.dialog_unlock_pro_desc)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Upgrade") }
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_upgrade)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@ -1150,18 +1110,18 @@ fun UpgradeDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
fun SignOutConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Confirm Sign Out") },
text = { Text("Are you sure you want to sign out?") },
title = { Text(stringResource(R.string.dialog_confirm_sign_out)) },
text = { Text(stringResource(R.string.dialog_confirm_sign_out_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Sign Out")
Text(stringResource(R.string.drawer_sign_out))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@ -1185,13 +1145,13 @@ fun DeviceManagementScreen(
verticalArrangement = Arrangement.Center
) {
Text(
text = "Device Limit Reached",
text = stringResource(R.string.device_limit_reached),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "To use Episteme Pro on this device, please remove one of your existing registered devices.",
text = stringResource(R.string.device_limit_reached_desc),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@ -1218,14 +1178,14 @@ fun DeviceManagementScreen(
Text(device.deviceName, fontWeight = FontWeight.SemiBold)
device.lastSeen?.let {
Text(
"Last seen: ${dateFormatter.format(it)}",
stringResource(R.string.last_seen, dateFormatter.format(it)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
TextButton(onClick = { onRemoveDevice(device.deviceId) }) {
Text("Remove")
Text(stringResource(R.string.action_remove))
}
}
}
@ -1241,18 +1201,18 @@ fun ClearAllDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit)
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Confirm Destructive Action") },
text = { Text("This will permanently delete all your books and reading progress from this device AND from your Google Drive account. This action cannot be undone. Are you sure?") },
title = { Text(stringResource(R.string.dialog_destructive_action)) },
text = { Text(stringResource(R.string.dialog_destructive_action_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Delete Everything")
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
})
}
@ -1276,7 +1236,7 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
}
Text(
text = "FPS: $fps",
text = stringResource(R.string.debug_fps, fps),
color = Color.Green,
style = MaterialTheme.typography.labelLarge,
modifier = modifier
@ -1315,12 +1275,12 @@ fun DangerousFolderActionDialog(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Confirm & Clear")
Text(stringResource(R.string.action_confirm_clear))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)

View file

@ -115,6 +115,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@ -138,14 +140,16 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
private fun getBookCountString(count: Int): String {
return if (count == 1) "1 book" else "$count books"
return pluralStringResource(id = R.plurals.book_count, count, count)
}
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty()
@ -187,7 +191,7 @@ fun LibraryScreen(
try {
pickFolderLauncher.launch(null)
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner("Your device doesn't support folder selection. You can still import files individually.", isError = true)
viewModel.showBanner(context.getString(R.string.error_folder_selection_unsupported), isError = true)
}
}
@ -222,7 +226,7 @@ fun LibraryScreen(
try {
fallbackFilePickerLauncher.launch("*/*")
} catch (_: android.content.ActivityNotFoundException) {
viewModel.showBanner("No file manager found. Please install a file manager app.", isError = true)
viewModel.showBanner(context.getString(R.string.error_no_file_manager), isError = true)
}
}
}
@ -301,7 +305,7 @@ fun LibraryScreen(
isLoading = uiState.isLoading,
isRefreshing = uiState.isRefreshing,
onOpdsBookDownloaded = { uri, title ->
viewModel.showBanner("Downloaded $title")
viewModel.showBanner(context.getString(R.string.banner_downloaded, title))
viewModel.onFileSelected(uri, isFromRecent = false)
},
onStreamOpdsBook = { entry, catalog ->
@ -542,7 +546,12 @@ fun LibraryScreenContent(
val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
val tabTitles = listOf("All Books", "Shelves", "Folders", "Catalogs")
val tabTitles = listOf(
stringResource(R.string.tab_all_books),
stringResource(R.string.tab_shelves),
stringResource(R.string.tab_folders),
stringResource(R.string.tab_catalogs)
)
val searchFocusRequester = remember { FocusRequester() }
var textFieldValue by remember(isSearchActive) {
@ -603,7 +612,7 @@ fun LibraryScreenContent(
textFieldValue = it
onSearchQueryChange(it.text)
},
placeholder = { Text("Search title or author...") },
placeholder = { Text(stringResource(R.string.search_placeholder)) },
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
@ -627,8 +636,7 @@ fun LibraryScreenContent(
}
}
} else {
CustomTopAppBar(
title = { Text("Library") },
CustomTopAppBar(title = { Text(stringResource(R.string.library_title)) },
actions = {
if (pagerState.currentPage == 0) {
IconButton(onClick = onFilterClick) {
@ -698,21 +706,21 @@ fun LibraryScreenContent(
if (libraryFilters.fileTypes.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(fileTypes = emptySet())) },
label = { Text("Types: ${libraryFilters.fileTypes.joinToString { it.name }}") },
label = { Text(stringResource(R.string.filter_types, libraryFilters.fileTypes.joinToString { it.name })) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.sourceFolders.isNotEmpty()) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(sourceFolders = emptySet())) },
label = { Text("Folders: ${libraryFilters.sourceFolders.size}") },
label = { Text(stringResource(R.string.filter_folders, libraryFilters.sourceFolders.size)) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
if (libraryFilters.readStatus != ReadStatusFilter.ALL) {
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(readStatus = ReadStatusFilter.ALL)) },
label = { Text("Status: ${libraryFilters.readStatus.displayName}") },
label = { Text(stringResource(R.string.filter_status, libraryFilters.readStatus.displayName)) },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
@ -727,7 +735,7 @@ fun LibraryScreenContent(
0 -> {
if (recentFiles.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text("Add file") },
text = { Text(stringResource(R.string.fab_add_file)) },
icon = { Icon(Icons.Default.Add, contentDescription = "Add file") },
onClick = onSelectFileClick,
modifier = Modifier.padding(16.dp)
@ -736,7 +744,7 @@ fun LibraryScreenContent(
}
1 -> {
ExtendedFloatingActionButton(
text = { Text("New shelf") },
text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = "New shelf") },
onClick = onNewShelfClick,
modifier = Modifier.padding(16.dp)
@ -757,12 +765,12 @@ fun LibraryScreenContent(
0 -> {
if (recentFiles.isEmpty() && searchQuery.isNotEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found for \"$searchQuery\"")
Text(stringResource(R.string.no_results_found, searchQuery))
}
} else if (recentFiles.isEmpty()) {
EmptyState(
title = "Your Library is Empty",
message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.",
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.library_empty_desc),
onSelectFileClick = onSelectFileClick,
modifier = Modifier.fillMaxSize()
)
@ -850,12 +858,12 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create New Shelf") },
title = { Text(stringResource(R.string.create_new_shelf)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Shelf Name") },
placeholder = { Text(stringResource(R.string.shelf_name_hint)) },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
@ -865,12 +873,12 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit
onClick = { onConfirm(text) },
enabled = text.isNotBlank()
) {
Text("Create")
Text(stringResource(R.string.action_create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)
@ -981,14 +989,14 @@ private fun ShelfDetailScreen(
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text("Rename shelf") },
text = { Text(stringResource(R.string.menu_rename_shelf)) },
onClick = {
onRenameShelf()
showMoreMenu = false
}
)
DropdownMenuItem(
text = { Text("Delete shelf") },
text = { Text(stringResource(R.string.menu_delete_shelf)) },
onClick = {
onDeleteShelf()
showMoreMenu = false
@ -1006,7 +1014,7 @@ private fun ShelfDetailScreen(
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add books") }
text = { Text(stringResource(R.string.fab_add_books)) }
)
}
}
@ -1016,7 +1024,7 @@ private fun ShelfDetailScreen(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("This shelf is empty", style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.shelf_empty), style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
@ -1059,7 +1067,7 @@ private fun AddBooksModeScreen(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text("Add to $shelfName") },
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -1127,7 +1135,7 @@ private fun AddBooksModeScreen(
floatingActionButton = {
if (selectedBookUris.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text("ADD (${selectedBookUris.size})") },
text = { Text(stringResource(R.string.fab_add_count, selectedBookUris.size)) },
icon = { Icon(Icons.Default.Check, contentDescription = "Add books") },
onClick = onAddSelectedBooks
)
@ -1140,7 +1148,7 @@ private fun AddBooksModeScreen(
contentAlignment = Alignment.Center
) {
Text(
text = if (currentSource == AddBooksSource.UNSHELVED) "No unshelved books to add" else "All books are already in this shelf",
text = if (currentSource == AddBooksSource.UNSHELVED) stringResource(R.string.no_unshelved_books) else stringResource(R.string.all_books_in_shelf),
style = MaterialTheme.typography.bodyLarge
)
}
@ -1431,12 +1439,12 @@ private fun RenameShelfDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename Shelf") },
title = { Text(stringResource(R.string.menu_rename_shelf)) },
text = {
OutlinedTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
placeholder = { Text("Shelf Name") },
placeholder = { Text(stringResource(R.string.shelf_name_hint)) },
singleLine = true,
modifier = Modifier.focusRequester(focusRequester)
)
@ -1446,12 +1454,12 @@ private fun RenameShelfDialog(
onClick = { onConfirm(textFieldValue.text) },
enabled = textFieldValue.text.isNotBlank() && textFieldValue.text != initialName
) {
Text("Rename")
Text(stringResource(R.string.action_rename))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)
@ -1470,13 +1478,13 @@ private fun DeleteShelfConfirmationDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Shelf?") },
text = { Text("Are you sure you want to delete the '$shelfName' shelf? All books will be moved to Unshelved.") },
title = { Text(stringResource(R.string.dialog_delete_shelf)) },
text = { Text(stringResource(R.string.dialog_delete_shelf_desc)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Delete") }
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -1488,16 +1496,16 @@ private fun RemoveFromShelfConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val bookStr = if (count == 1) "book" else "books"
val bookStr = pluralStringResource(id = R.plurals.book_word, count)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Remove from Shelf?") },
text = { Text("Are you sure you want to remove $count $bookStr from the '$shelfName' shelf? The book(s) will remain in your library and appear under Unshelved.") },
title = { Text(stringResource(R.string.dialog_remove_from_shelf)) },
text = { Text(stringResource(R.string.dialog_remove_from_shelf_desc, count, bookStr, shelfName)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Remove") }
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_remove)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -1508,16 +1516,16 @@ private fun DeleteShelvesConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val shelfStr = if (count == 1) "shelf" else "shelves"
val shelfStr = pluralStringResource(id = R.plurals.shelf_count, count)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete $shelfStr?") },
text = { Text("Are you sure you want to delete the $count selected $shelfStr? All books within will be moved to Unshelved.") },
title = { Text(stringResource(R.string.dialog_delete_shelves, shelfStr)) },
text = { Text(stringResource(R.string.dialog_delete_shelves_desc, count, shelfStr)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Delete") }
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -1539,7 +1547,7 @@ private fun FolderSyncScreen(
floatingActionButton = {
if (syncedFolders.size < 3) {
ExtendedFloatingActionButton(
text = { Text("Add Folder") },
text = { Text(stringResource(R.string.fab_add_folder)) },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = onAddFolderClick
)
@ -1570,7 +1578,7 @@ private fun FolderSyncScreen(
Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp))
Text(if (isLoading) "Scanning..." else "Scan All")
Text(if (isLoading) stringResource(R.string.scanning) else stringResource(R.string.scan_all))
}
androidx.compose.material3.OutlinedButton(
@ -1581,15 +1589,15 @@ private fun FolderSyncScreen(
) {
Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Sync Meta")
Text(stringResource(R.string.sync_meta))
}
}
} else {
EmptyState(
title = "Sync Local Folders",
message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.",
title = stringResource(R.string.sync_local_folders),
message = stringResource(R.string.sync_folders_desc),
onSelectFileClick = onAddFolderClick,
primaryButtonText = "Select Folder",
primaryButtonText = stringResource(R.string.action_select_folder),
modifier = Modifier.fillMaxSize()
)
}
@ -1632,7 +1640,7 @@ private fun FolderCard(
) {
var showMenu by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
val folderFiles = remember(allRecentFiles, folder.uriString) {
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
@ -1676,14 +1684,14 @@ private fun FolderCard(
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Edit Filters") },
text = { Text(stringResource(R.string.menu_edit_filters)) },
onClick = {
showMenu = false
onEditFiltersClick(folder)
}
)
DropdownMenuItem(
text = { Text("Remove Folder") },
text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = {
showMenu = false
onRemoveClick(folder)
@ -1701,7 +1709,7 @@ private fun FolderCard(
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST SYNC",
text = stringResource(R.string.last_sync),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
@ -1711,7 +1719,7 @@ private fun FolderCard(
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text(
text = "BOOKS",
text = stringResource(R.string.books_count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
@ -1730,7 +1738,7 @@ private fun FolderCard(
countsByType.forEach { (type, count) ->
AssistChip(
onClick = { },
label = { Text("${type.name}: $count") }
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
)
}
}
@ -1749,11 +1757,11 @@ private fun EditFolderFiltersDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Filter File Types") },
title = { Text(stringResource(R.string.filter_file_types)) },
text = {
Column {
Text(
text = "Select the file types you want to sync from this folder:",
stringResource(R.string.filter_file_types_desc),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
@ -1783,10 +1791,10 @@ private fun EditFolderFiltersDialog(
TextButton(
onClick = { onConfirm(selectedTypes) },
enabled = selectedTypes.isNotEmpty()
) { Text("Save") }
) { Text(stringResource(R.string.action_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -1812,9 +1820,9 @@ fun LibraryFilterSheet(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Filter Library", style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.filter_library), style = MaterialTheme.typography.titleLarge)
Text("File Type", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.filter_file_type), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -1832,7 +1840,7 @@ fun LibraryFilterSheet(
}
if (syncedFolders.isNotEmpty()) {
Text("Source Folder", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -1850,7 +1858,7 @@ fun LibraryFilterSheet(
}
}
Text("Read Status", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.filter_read_status), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -1866,11 +1874,11 @@ fun LibraryFilterSheet(
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text("Clear All")
Text(stringResource(R.string.clear_all))
}
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = { onApply(currentFilters); onDismiss() }) {
Text("Apply")
Text(stringResource(R.string.action_apply))
}
}
Spacer(modifier = Modifier.height(32.dp))
@ -1930,7 +1938,7 @@ fun OpdsTab(
}
ExtendedFloatingActionButton(
text = { Text("Add Catalog") },
text = { Text(stringResource(R.string.fab_add_catalog)) },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = {
editingCatalog = null
@ -1981,7 +1989,7 @@ fun OpdsTab(
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search catalog...") },
placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) },
modifier = Modifier.weight(1f).padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
@ -2017,7 +2025,7 @@ fun OpdsTab(
)
} else {
Text(
text = uiState.currentFeed?.title ?: "Loading...",
text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -2045,7 +2053,7 @@ fun OpdsTab(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("This feed is empty.")
Text(stringResource(R.string.feed_empty))
}
} else {
val facets = uiState.currentFeed?.facets ?: emptyList()
@ -2066,7 +2074,7 @@ fun OpdsTab(
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text("${groupName}: ${activeFacet?.title ?: "Select"}") },
label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
@ -2194,24 +2202,23 @@ fun OpdsTab(
showCatalogDialog = false
editingCatalog = null
},
title = { Text(if (isEditMode) "Edit Catalog" else "Add OPDS Catalog") },
title = { Text(if (isEditMode) stringResource(R.string.edit_catalog) else stringResource(R.string.add_opds_catalog)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("Catalog Name") },
label = { Text(stringResource(R.string.catalog_name)) },
singleLine = true
)
OutlinedTextField(
value = newUrl,
onValueChange = { newUrl = it },
label = { Text("URL") },
placeholder = { Text("e.g. http://192.168.1.50:8080/opds") },
label = { Text(stringResource(R.string.url)) },
placeholder = { Text(stringResource(R.string.url_placeholder)) },
singleLine = true
)
Text(
"Authentication (Optional)",
Text(stringResource(R.string.auth_optional),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
@ -2219,13 +2226,13 @@ fun OpdsTab(
OutlinedTextField(
value = newUsername,
onValueChange = { newUsername = it },
label = { Text("Username") },
label = { Text(stringResource(R.string.username)) },
singleLine = true
)
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("Password") },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
@ -2244,13 +2251,13 @@ fun OpdsTab(
editingCatalog = null
},
enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
) { Text("Save") }
) { Text(stringResource(R.string.action_save)) }
},
dismissButton = {
TextButton(onClick = {
showCatalogDialog = false
editingCatalog = null
}) { Text("Cancel") }
}) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -2259,14 +2266,14 @@ fun OpdsTab(
val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
AlertDialog(
onDismissRequest = { catalogToDelete = null },
title = { Text("Delete Catalog") },
title = { Text(stringResource(R.string.delete_catalog)) },
text = {
Column {
Text("Are you sure you want to delete '${catalogToDelete!!.title}'?")
Text(stringResource(R.string.delete_catalog_desc, catalogToDelete!!.title))
if (streamedBooksCount > 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Deleting this catalog will also permanently remove $streamedBooksCount streaming books associated with it from your library.",
stringResource(R.string.delete_catalog_warning, streamedBooksCount),
color = MaterialTheme.colorScheme.error
)
}
@ -2282,10 +2289,10 @@ fun OpdsTab(
catalogToDelete = null
},
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = { catalogToDelete = null }) { Text("Cancel") }
TextButton(onClick = { catalogToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -2314,8 +2321,7 @@ fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Un
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "Preset",
Text(stringResource(R.string.preset_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
@ -2419,12 +2425,12 @@ fun OpdsBookCard(
) {
Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Read")
Text(stringResource(R.string.action_read))
}
} else if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading...", style = MaterialTheme.typography.labelMedium)
Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
@ -2446,7 +2452,7 @@ fun OpdsBookCard(
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Stream")
Text(stringResource(R.string.action_stream))
}
}
@ -2465,11 +2471,11 @@ fun OpdsBookCard(
if (uniqueAcquisitions.isEmpty()) {
Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Unavailable")
Text(stringResource(R.string.action_unavailable))
} else {
Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Download")
Text(stringResource(R.string.action_download))
}
}
}
@ -2586,14 +2592,14 @@ fun OpdsBookDetailsSheet(
) {
Icon(Icons.Default.Check, contentDescription = "Read")
Spacer(modifier = Modifier.width(8.dp))
Text("Read", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.action_read), fontWeight = FontWeight.Bold)
}
}
if (isDownloading) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading...", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
@ -2618,14 +2624,13 @@ fun OpdsBookDetailsSheet(
) {
Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Stream Now", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.action_stream_now), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (uniqueAcquisitions.isNotEmpty()) {
Text(
"Download Format",
Text(stringResource(R.string.download_format),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -2643,7 +2648,7 @@ fun OpdsBookDetailsSheet(
}
}
} else {
Text("No supported formats available.", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.no_supported_formats), color = MaterialTheme.colorScheme.error)
}
if (entry.categories.isNotEmpty()) {
@ -2681,20 +2686,20 @@ fun OpdsBookDetailsSheet(
) {
entry.publisher?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("PUBLISHER", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.publisher), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
entry.published?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("PUBLISHED", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.published), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
val cleanDate = it.substringBefore("T")
Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
entry.language?.takeIf { it.isNotBlank() }?.let {
Column(modifier = Modifier.weight(1f)) {
Text("LANGUAGE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
@ -2703,7 +2708,7 @@ fun OpdsBookDetailsSheet(
}
if (!entry.summary.isNullOrBlank()) {
Text("Synopsis", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(entry.summary) {
val preProcessed = entry.summary

View file

@ -40,13 +40,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
object Home : BottomBarScreen("home", "Home", R.drawable.home)
object Library : BottomBarScreen("library", "Library", R.drawable.library_books)
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
object Library : BottomBarScreen("library", R.string.nav_library, R.drawable.library_books)
}
private val bottomBarItems = listOf(
@ -94,14 +95,10 @@ fun MainScreen(
NavigationBar {
bottomBarItems.forEachIndexed { index, screen ->
NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = screen.label) },
label = { Text(screen.label) },
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
label = { Text(stringResource(screen.stringResId)) },
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
)
}
}

View file

@ -494,7 +494,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
recentFilesRepository.deleteFilePermanently(ids)
withContext(Dispatchers.Main) {
showBanner("Removed ${filesToDelete.size} streaming books.")
showBanner(appContext.getString(R.string.banner_removed_streaming_books, filesToDelete.size))
}
}
}
@ -806,7 +806,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uploadNewFont(font)
}
}.onFailure {
showBanner("Failed to import font: ${it.message}", isError = true)
showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true)
}
_internalState.update { it.copy(isLoading = false) }
}
@ -848,7 +848,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
withContext(Dispatchers.Main) {
onDeleted()
showBanner("Text view deleted.")
showBanner(appContext.getString(R.string.banner_text_view_deleted))
}
}
}
@ -878,7 +878,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy(isRequestingDrivePermission = false, isSyncEnabled = false)
}
prefs.edit { putBoolean(KEY_SYNC_ENABLED, false) }
showBanner("Sync requires Google Drive permission.", isError = true)
showBanner(appContext.getString(R.string.error_sync_drive_permission), isError = true)
}
private fun verifyPurchaseWithBackend(
@ -890,9 +890,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (!isSilentMigrationCheck) {
_internalState.update {
it.copy(
bannerMessage = BannerMessage(
"An error occurred with the purchase.", isError = true
)
bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true)
)
}
}
@ -905,7 +903,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (result.isSuccess) {
Timber.i("Backend verification successful. Firestore will update the app.")
_internalState.update {
it.copy(bannerMessage = BannerMessage("Upgrade successful! Welcome to Pro."))
it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_upgrade_success)))
}
verifyDeviceForProUser()
} else {
@ -915,8 +913,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"Migration check: Purchase token is already claimed by another account. Silently ignoring."
)
} else {
val errorMessage =
"Purchase verification failed. Please contact support if you were charged."
val errorMessage = appContext.getString(R.string.error_purchase_verification)
Timber.e(exception, "Backend verification failed")
if (!isSilentMigrationCheck) {
_internalState.update {
@ -950,7 +947,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.w("Device has been revoked. Signing out.")
firestoreRepository.deleteDevice(currentUser.uid, deviceId) // Clean up
signOut()
showBanner("This device was removed from your account.")
showBanner(appContext.getString(R.string.banner_device_removed))
}
is com.aryan.reader.data.DeviceStatus.NotFound -> {
@ -962,7 +959,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.e(deviceStatus.exception, "Error checking device status.")
_internalState.update {
it.copy(
errorMessage = "Could not verify this device. Please check your connection."
errorMessage = appContext.getString(R.string.error_verify_device)
)
}
}
@ -998,7 +995,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.e("Failed to replace device.")
_internalState.update {
it.copy(
errorMessage = "Failed to update devices. Please try again.",
errorMessage = appContext.getString(R.string.error_update_devices),
isReplacingDevice = false
)
}
@ -1044,7 +1041,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) {
viewModelScope.launch {
_internalState.update {
it.copy(isLoading = true, bannerMessage = BannerMessage("Saving PDF..."))
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_pdf)))
}
try {
val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId)
@ -1060,13 +1057,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
textBoxes = textBoxes,
highlights = highlights
)
showBanner("PDF saved successfully.")
showBanner(appContext.getString(R.string.banner_pdf_saved))
} else {
showBanner("Failed to open file for saving.", isError = true)
showBanner(appContext.getString(R.string.error_open_file_saving), isError = true)
}
} catch (e: Exception) {
Timber.e(e, "Failed to save annotated PDF")
showBanner("Error saving PDF: ${e.localizedMessage}", isError = true)
showBanner(appContext.getString(R.string.error_saving_pdf, e.localizedMessage), isError = true)
} finally {
_internalState.update { it.copy(isLoading = false) }
}
@ -1076,7 +1073,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun saveOriginalPdf(sourceUri: Uri, destUri: Uri) {
viewModelScope.launch {
_internalState.update {
it.copy(isLoading = true, bannerMessage = BannerMessage("Saving original PDF..."))
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_pdf)))
}
try {
val contentResolver = appContext.contentResolver
@ -1085,10 +1082,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
input.copyTo(output)
}
}
showBanner("Original PDF saved successfully.")
showBanner(appContext.getString(R.string.banner_original_pdf_saved))
} catch (e: Exception) {
Timber.e(e, "Failed to save original PDF")
showBanner("Error saving PDF: ${e.localizedMessage}", isError = true)
showBanner(appContext.getString(R.string.error_saving_pdf, e.localizedMessage), isError = true)
} finally {
_internalState.update { it.copy(isLoading = false) }
}
@ -1190,14 +1187,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
putExtra(Intent.EXTRA_STREAM, contentUri)
putExtra(Intent.EXTRA_TITLE, filename)
putExtra(Intent.EXTRA_SUBJECT, "Sharing: $filename")
putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
clipData = ClipData.newRawUri(filename, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share PDF")
val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_chooser_title))
if (activityContext !is android.app.Activity) {
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@ -1206,7 +1203,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
} catch (e: Exception) {
Timber.e(e, "Share failed")
showBanner("Share failed: ${e.localizedMessage}", isError = true)
showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage), isError = true)
}
}
}
@ -1507,12 +1504,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentFolders = _internalState.value.syncedFolders
if (currentFolders.size >= MAX_FOLDER_LIMIT) {
showBanner("Limit reached: Maximum $MAX_FOLDER_LIMIT folders allowed.", isError = true)
showBanner(appContext.getString(R.string.error_folder_limit_reached, MAX_FOLDER_LIMIT), isError = true)
return
}
if (currentFolders.any { it.uriString == folderUri.toString() }) {
showBanner("This folder is already synced.", isError = true)
showBanner(appContext.getString(R.string.error_folder_already_synced), isError = true)
return
}
@ -1547,11 +1544,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest
)
showBanner("Folder added: $name")
showBanner(appContext.getString(R.string.banner_folder_added, name))
} catch (e: SecurityException) {
Timber.e(e, "Failed to take permissions for $folderUri")
showBanner("Failed to access folder permissions.", isError = true)
showBanner(appContext.getString(R.string.error_access_folder_permissions), isError = true)
}
}
}
@ -1581,7 +1578,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
}
showBanner("Folder removed.")
showBanner(appContext.getString(R.string.banner_folder_removed))
}
}
@ -1616,8 +1613,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (workInfo.state) {
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
if (showFeedback) {
val msg =
if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning)
_internalState.update {
it.copy(
isLoading = false,
@ -1633,7 +1629,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy(
isLoading = false,
isRefreshing = false,
bannerMessage = if (showFeedback) BannerMessage("Folder Sync: Scan complete.") else it.bannerMessage,
bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage,
lastFolderScanTime = System.currentTimeMillis(),
syncedFolders = loadSyncedFoldersFromPrefs()
)
@ -1645,7 +1641,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = if (showFeedback) "Sync failed." else it.errorMessage,
errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage,
bannerMessage = null
)
}
@ -1748,7 +1744,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun downloadBook(item: RecentFileItem, openWhenComplete: Boolean = false): Job {
if (!uiState.value.isSyncEnabled) {
_internalState.update { it.copy(errorMessage = "Enable sync to download files.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_enable_sync_download)) }
return viewModelScope.launch {}
}
if (uiState.value.downloadingBookIds.contains(item.bookId)) {
@ -1800,7 +1796,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Failed to download book ${item.bookId}")
_internalState.update {
it.copy(errorMessage = "Failed to download ${item.displayName}.")
it.copy(errorMessage = appContext.getString(R.string.error_download_failed, item.displayName))
}
} finally {
_internalState.update { state ->
@ -1812,13 +1808,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteAllCloudAndLocalData() {
if (!uiState.value.isSyncEnabled) {
_internalState.update { it.copy(errorMessage = "Enable sync to clear cloud data.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_enable_sync_clear_cloud)) }
return
}
if (!googleDriveRepository.isUserSignedInToDrive(appContext)) {
_internalState.update {
it.copy(errorMessage = "Not signed in, cannot clear cloud data.")
it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_clear_cloud))
}
return
}
@ -1826,7 +1822,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = true,
bannerMessage = BannerMessage("Clearing all cloud and local data...")
bannerMessage = BannerMessage(appContext.getString(R.string.banner_clearing_cloud_local_data))
)
}
@ -1855,9 +1851,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (success) {
_internalState.update {
it.copy(
isLoading = false, bannerMessage = BannerMessage(
"All cloud and local data cleared successfully."
)
isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_local_data_cleared))
)
}
} else {
@ -1866,7 +1860,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Failed to delete all cloud and local user data.")
_internalState.update {
it.copy(isLoading = false, errorMessage = "Error: Failed to clear all data.")
it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_clear_all_data))
}
}
}
@ -1913,13 +1907,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = false, bannerMessage = BannerMessage("All local data cleared.")
isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_local_data_cleared))
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to delete all user data.")
_internalState.update {
it.copy(isLoading = false, errorMessage = "Error: Failed to clear all data.")
it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_clear_all_data))
}
}
}
@ -1933,9 +1927,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (user == null) {
_internalState.update {
it.copy(
bannerMessage = BannerMessage(
"Sign in failed. Please try again.", isError = true
), isLoading = false
bannerMessage = BannerMessage(appContext.getString(R.string.error_sign_in_failed), isError = true), isLoading = false
)
}
} else {
@ -1950,9 +1942,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "An unexpected error occurred during sign-in.")
val errorMessage = if (e is NoCredentialException) {
"Could not find a Google account. This can happen on a fresh install, please try again in a moment."
appContext.getString(R.string.error_no_google_account)
} else {
"An error occurred during sign in. Please check your internet connection."
appContext.getString(R.string.error_sign_in_internet)
}
_internalState.update {
it.copy(
@ -2003,7 +1995,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
} ?: run {
showBanner("Please sign in to test device management.", isError = true)
showBanner(appContext.getString(R.string.error_sign_in_device_management), isError = true)
}
}
}
@ -2020,7 +2012,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun setSyncEnabled(enabled: Boolean) {
if (!uiState.value.isProUser) {
Timber.d("Sync toggle blocked for free user.")
_internalState.update { it.copy(errorMessage = "Sync is an Episteme Pro feature.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_sync_pro_feature)) }
return
}
@ -2054,14 +2046,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (!hasPermissions || currentUser == null) {
if (showBanner) _internalState.update {
it.copy(errorMessage = "Not signed in, cannot sync.")
it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_sync))
}
return@launch
}
if (showBanner) {
_internalState.update {
it.copy(bannerMessage = BannerMessage("Cloud Sync: Checking for updates..."))
it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_checking)))
}
}
@ -2281,7 +2273,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (showBanner) {
_internalState.update {
it.copy(
isLoading = false, bannerMessage = BannerMessage("Cloud Sync: Complete.")
isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_complete))
)
}
}
@ -2289,7 +2281,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("AnnotationSync").e(e, "Error during cloud sync")
if (showBanner) {
_internalState.update {
it.copy(isLoading = false, errorMessage = "Failed to sync library.")
it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_sync_library_failed))
}
}
}
@ -2654,14 +2646,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
// This path is now handled by onRecentFileClicked to preserve the bookId
// We find the book by URI to open it.
viewModelScope.launch {
val item = recentFilesRepository.getFileByUri(uri.toString())
if (item != null) {
openBook(uri, item.bookId, item.type, item.displayName)
} else {
_internalState.update { it.copy(errorMessage = "Could not find recent item.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_recent_item_not_found)) }
}
}
} else {
@ -2698,7 +2688,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
_internalState.update {
it.copy(isLoading = false, errorMessage = "Failed to import file.")
it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed))
}
}
}
@ -2736,7 +2726,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val uri = item.getUri() ?: run {
_internalState.update {
it.copy(
isLoading = false, errorMessage = "Could not find file location."
isLoading = false, errorMessage = appContext.getString(R.string.error_file_location_not_found)
)
}
stateUpdateDeferred.complete(false)
@ -2822,7 +2812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load generated text view.",
errorMessage = appContext.getString(R.string.error_load_generated_text_view),
selectedFileType = null
)
}
@ -2884,10 +2874,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (newItem != null) {
switchToFileSeamlessly(newItem, autoOpenPage)
} else {
showBanner("Failed to load generated text view.", true)
showBanner(appContext.getString(R.string.error_load_generated_text_view), true)
}
} else {
showBanner("Text view generation failed.", true)
showBanner(appContext.getString(R.string.error_text_view_generation_failed), true)
}
}
}
@ -3076,7 +3066,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Error parsing FB2 for URI: $uri")
_internalState.update {
it.copy(errorMessage = "Failed to load FB2: ${e.message}", isLoading = false)
it.copy(errorMessage = appContext.getString(R.string.error_load_fb2, e.message), isLoading = false)
}
}
}
@ -3131,7 +3121,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Error parsing file ($type) for URI: $uri")
_internalState.update {
it.copy(errorMessage = "Failed to load file: ${e.message}", isLoading = false)
it.copy(errorMessage = appContext.getString(R.string.error_load_file, e.message), isLoading = false)
}
}
}
@ -3271,7 +3261,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Error parsing MOBI for URI: $uri")
_internalState.update {
it.copy(errorMessage = "Failed to load MOBI: ${e.message}", isLoading = false)
it.copy(errorMessage = appContext.getString(R.string.error_load_mobi, e.message), isLoading = false)
}
}
}
@ -3318,7 +3308,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (e: Exception) {
Timber.e(e, "Error parsing EPUB for URI: $uri")
_internalState.update {
it.copy(errorMessage = "Failed to load EPUB: ${e.message}", isLoading = false)
it.copy(errorMessage = appContext.getString(R.string.error_load_epub, e.message), isLoading = false)
}
}
}
@ -3454,7 +3444,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FolderSync")
.i("LazyCleanup: File ${item.displayName} missing. Removing.")
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
showBanner("File deleted from folder. Removed from library.")
showBanner(appContext.getString(R.string.banner_file_deleted_from_folder))
return@launch
}
@ -3463,7 +3453,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
item.getUri()?.let { uri ->
openBook(uri, item.bookId, item.type, item.displayName)
} ?: run {
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found)) }
}
} else {
downloadBook(item, openWhenComplete = true)
@ -3477,7 +3467,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
item.getUri()?.let { uri ->
openBook(uri, item.bookId, item.type, item.displayName)
} ?: run {
_internalState.update { it.copy(errorMessage = "Could not find file location.") }
_internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found)) }
return
}
} else {
@ -3618,7 +3608,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (newName in currentShelves) {
Timber.w("Cannot rename shelf. A shelf with the name '$newName' already exists.")
_internalState.update {
it.copy(errorMessage = "A shelf with that name already exists.")
it.copy(errorMessage = appContext.getString(R.string.error_shelf_exists))
}
dismissRenameShelfDialog()
return
@ -3938,7 +3928,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = true,
bannerMessage = BannerMessage("Deleting from all devices...")
bannerMessage = BannerMessage(appContext.getString(R.string.banner_deleting_all_devices))
)
}
try {
@ -3974,7 +3964,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage("Deletion complete.")
bannerMessage = BannerMessage(appContext.getString(R.string.banner_deletion_complete))
)
}
} catch (e: Exception) {
@ -3986,7 +3976,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Cloud sync failed, deleted locally."
errorMessage = appContext.getString(R.string.error_cloud_sync_failed_deleted_locally)
)
}
}
@ -4003,7 +3993,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage("$totalRemoved book(s) removed from library.")
bannerMessage = BannerMessage(appContext.getString(R.string.banner_books_removed_library, totalRemoved))
)
}
}
@ -4118,7 +4108,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
withContext(Dispatchers.Main) {
showBanner("Reflow cache & generated text views cleared.")
showBanner(appContext.getString(R.string.banner_reflow_cache_cleared))
}
}
}

View file

@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@ -175,8 +176,7 @@ fun ProScreen(
shape = CircleShape
),
text = {
AutoSizeText(
"Free",
AutoSizeText(stringResource(R.string.tab_free),
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
@ -209,8 +209,7 @@ fun ProScreen(
tint = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
AutoSizeText(
"Episteme Pro",
AutoSizeText(stringResource(R.string.drawer_pro_unlocked),
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
@ -267,19 +266,16 @@ private fun FreeTierCard() {
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Free Plan",
Text(stringResource(R.string.free_plan),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "$0",
Text(stringResource(R.string.price_free),
style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp),
fontWeight = FontWeight.Bold
)
Text(
text = "Forever free",
Text(stringResource(R.string.forever_free),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -289,23 +285,20 @@ private fun FreeTierCard() {
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
FeatureListItem(iconRes = R.drawable.library_books, text = "Multiple Formats")
Text(
text = "Supports PDF, EPUB, MOBI, AZW3",
FeatureListItem(iconRes = R.drawable.library_books, text = stringResource(R.string.feature_multiple_formats))
Text(stringResource(R.string.feature_multiple_formats_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.text_to_speech, text = "Android Text-to-Speech")
Text(
text = "Listen to your books with built-in TTS",
FeatureListItem(iconRes = R.drawable.text_to_speech, text = stringResource(R.string.feature_tts))
Text(stringResource(R.string.feature_tts_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.dictionary, text = "Basic Dictionary")
Text(
text = "Look up single words quickly",
FeatureListItem(iconRes = R.drawable.dictionary, text = stringResource(R.string.feature_dict))
Text(stringResource(R.string.feature_dict_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
@ -325,7 +318,7 @@ private fun FreeTierCard() {
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Text("Current Plan", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.current_plan), fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
@ -386,8 +379,7 @@ private fun ProTierCard(
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Episteme Pro",
Text(stringResource(R.string.drawer_pro_unlocked),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
@ -404,8 +396,7 @@ private fun ProTierCard(
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(originalFormattedPrice)
}
append(" 50% OFF")
},
append(" " + stringResource(R.string.pro_sale_off)) },
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -416,16 +407,14 @@ private fun ProTierCard(
fontWeight = FontWeight.Bold
)
} else {
Text(
text = "Loading price...",
Text(stringResource(R.string.loading_price),
style = MaterialTheme.typography.displaySmall.copy(fontSize = 32.sp),
fontWeight = FontWeight.Bold
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "One-time payment",
Text(stringResource(R.string.one_time_payment),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -435,8 +424,7 @@ private fun ProTierCard(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Text(
text = "Lifetime Access",
Text(stringResource(R.string.lifetime_access),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
@ -461,7 +449,7 @@ private fun ProTierCard(
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Early Access Sale", style = MaterialTheme.typography.labelLarge)
Text(stringResource(R.string.early_access_sale), style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(16.dp))
}
@ -471,36 +459,31 @@ private fun ProTierCard(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Everything in Free, plus:",
Text(stringResource(R.string.pro_includes),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.cloud_sync, text = "Cloud Sync Across Devices")
Text(
text = "Keep your entire library, including book files and reading progress, synced across up to 4 devices.",
FeatureListItem(iconRes = R.drawable.cloud_sync, text = stringResource(R.string.feature_cloud_sync))
Text(stringResource(R.string.feature_cloud_sync_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.summarize, text = "Summarization")
Text(
text = "Get quick summaries of chapters or pages",
FeatureListItem(iconRes = R.drawable.summarize, text = stringResource(R.string.feature_summarize))
Text(stringResource(R.string.feature_summarize_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.dictionary, text = "Smart Dictionary")
Text(
text = "Search phrases and even paragraphs, not just single words",
FeatureListItem(iconRes = R.drawable.dictionary, text = stringResource(R.string.feature_smart_dict))
Text(stringResource(R.string.feature_smart_dict_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.chat_bubble, text = "Priority Feature Requests")
Text(
text = "Your suggestions get prioritized",
FeatureListItem(iconRes = R.drawable.chat_bubble, text = stringResource(R.string.feature_priority))
Text(stringResource(R.string.feature_priority_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
@ -526,8 +509,7 @@ private fun ProTierCard(
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Pro Features Unlocked!",
Text(stringResource(R.string.pro_unlocked),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
@ -547,7 +529,7 @@ private fun ProTierCard(
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
AutoSizeText("Sign in Required", style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
AutoSizeText(stringResource(R.string.sign_in_required), style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
}
}
proUpgradeState.isVerifying -> {
@ -565,7 +547,7 @@ private fun ProTierCard(
color = LocalContentColor.current
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Verifying purchase...")
Text(stringResource(R.string.verifying_purchase))
}
}
localPurchaseExistsForOtherAccount -> {
@ -582,7 +564,7 @@ private fun ProTierCard(
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText("Existing Purchase Found")
AutoSizeText(stringResource(R.string.existing_purchase_found))
}
}
productDetails != null -> {
@ -604,7 +586,7 @@ private fun ProTierCard(
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText("Get Lifetime Access", style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
AutoSizeText(stringResource(R.string.get_lifetime_access), style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
}
}
}
@ -614,8 +596,7 @@ private fun ProTierCard(
}
}
else -> {
Text(
text = "Upgrade currently unavailable. Please check your internet and try again.",
Text(stringResource(R.string.upgrade_unavailable),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -628,16 +609,14 @@ private fun ProTierCard(
Spacer(modifier = Modifier.height(16.dp)) // Increased spacing
when {
!isUserSignedIn -> {
Text(
text = "Please sign in to your Google account to purchase Episteme Pro.",
Text(stringResource(R.string.sign_in_to_purchase),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
proUpgradeState.isVerifying -> {
Text(
text = "This may take a few moments. Your Pro status will be updated automatically.",
Text(stringResource(R.string.verifying_purchase_desc),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
@ -691,10 +670,10 @@ fun ExistingPurchaseDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Existing Purchase Found") },
text = { Text("This device already has a Pro purchase, but it's linked to a different account. Please sign in to the account that was used for the original purchase to restore your Pro features.") },
title = { Text(stringResource(R.string.existing_purchase_found)) },
text = { Text(stringResource(R.string.dialog_existing_purchase_desc)) },
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_ok)) }
}
)
}
@ -704,10 +683,10 @@ fun EarlyAccessInfoDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Early Access Sale") },
text = { Text("You're getting Episteme Pro at a special discounted price during our early access period! This is a limited-time offer.") },
title = { Text(stringResource(R.string.early_access_sale)) },
text = { Text(stringResource(R.string.dialog_early_access_desc)) },
confirmButton = {
TextButton(onClick = onDismiss) { Text("Got It!") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_got_it)) }
}
)
}
@ -716,14 +695,14 @@ fun EarlyAccessInfoDialog(onDismiss: () -> Unit) {
fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(painter = painterResource(id = R.drawable.crown), contentDescription = null) }, // Using crown icon for Pro
title = { Text("Sign In Required") },
text = { Text("Please sign in to your Google account to purchase Episteme Pro and unlock all premium features.") },
icon = { Icon(painter = painterResource(id = R.drawable.crown), contentDescription = null) },
title = { Text(stringResource(R.string.sign_in_required)) },
text = { Text(stringResource(R.string.dialog_sign_in_required_desc)) },
confirmButton = {
TextButton(onClick = onSignInClick) { Text("Sign In") }
TextButton(onClick = onSignInClick) { Text(stringResource(R.string.drawer_sign_in)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Not Now") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_not_now)) }
}
)
}

View file

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// SharedComposables.kt
package com.aryan.reader
import android.content.Context
@ -31,7 +32,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -53,22 +53,18 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -76,6 +72,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -88,12 +85,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
@ -102,7 +99,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -146,34 +142,29 @@ fun formatFileSize(bytes: Long): String {
@Composable
fun LegalText(
modifier: Modifier = Modifier,
prefixText: String, // Changed from baseText
prefixText: String,
textAlign: TextAlign = TextAlign.Center
) {
val uriHandler = LocalUriHandler.current
val fullAgreementText = stringResource(R.string.legal_agreement_full, prefixText, stringResource(R.string.legal_terms_of_service), stringResource(R.string.legal_privacy_policy))
val termsText = stringResource(R.string.legal_terms_of_service)
val privacyText = stringResource(R.string.legal_privacy_policy)
val annotatedString = buildAnnotatedString {
append("$prefixText you agree to our ")
pushStringAnnotation(tag = "terms", annotation = TERMS_URL)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append("Terms of Service")
append(fullAgreementText)
val termsStartIndex = fullAgreementText.indexOf(termsText)
if (termsStartIndex >= 0) {
addStringAnnotation(tag = "terms", annotation = TERMS_URL, start = termsStartIndex, end = termsStartIndex + termsText.length)
addStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline), start = termsStartIndex, end = termsStartIndex + termsText.length)
}
pop()
append(" and acknowledge you have read our ")
pushStringAnnotation(tag = "privacy", annotation = PRIVACY_POLICY_URL)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append("Privacy Policy")
val privacyStartIndex = fullAgreementText.indexOf(privacyText)
if (privacyStartIndex >= 0) {
addStringAnnotation(tag = "privacy", annotation = PRIVACY_POLICY_URL, start = privacyStartIndex, end = privacyStartIndex + privacyText.length)
addStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline), start = privacyStartIndex, end = privacyStartIndex + privacyText.length)
}
pop()
append(".")
}
@Suppress("DEPRECATION")
@ -225,30 +216,30 @@ fun ContextualTopAppBar(
onDeleteClick: () -> Unit
) {
CustomTopAppBar(
title = { Text("$selectedItemCount selected") },
title = { Text(stringResource(R.string.items_selected_count, selectedItemCount)) },
navigationIcon = {
IconButton(onClick = onNavIconClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Clear Selection")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.clear_selection))
}
},
actions = {
if (onPinClick != null) {
IconButton(onClick = onPinClick) {
Icon(Icons.Filled.PushPin, contentDescription = "Pin/Unpin")
Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin))
}
}
if (selectedItemCount == 1 && onInfoClick != null) {
IconButton(onClick = onInfoClick) {
Icon(Icons.Filled.Info, contentDescription = "Info")
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
}
}
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = "Select All")
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
}
}
IconButton(onClick = onDeleteClick) {
Icon(Icons.Filled.Delete, contentDescription = "Delete")
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
)
@ -302,21 +293,21 @@ fun DeleteConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
isPermanentDelete: Boolean = false,
containsFolderItems: Boolean = false // New parameter
containsFolderItems: Boolean = false
) {
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
val title = if (isPermanentDelete) stringResource(R.string.dialog_delete_permanently) else stringResource(R.string.dialog_remove_from_recents)
val text = if (isPermanentDelete) {
if (containsFolderItems) {
"Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone."
stringResource(R.string.dialog_warning_folder_sync_delete)
} else {
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
stringResource(R.string.dialog_permanently_delete_desc, count)
}
} else {
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
stringResource(R.string.dialog_remove_recents_desc, count)
}
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
val confirmText = if (isPermanentDelete) stringResource(R.string.action_delete) else stringResource(R.string.action_remove)
AlertDialog(
onDismissRequest = onDismiss,
@ -336,7 +327,7 @@ fun DeleteConfirmationDialog(
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}
@ -407,7 +398,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"File Information",
stringResource(R.string.file_information),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
@ -415,7 +406,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
androidx.compose.material3.OutlinedTextField(
value = editingName,
onValueChange = { editingName = it },
label = { Text("Book Name") },
label = { Text(stringResource(R.string.book_name)) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp, max = 130.dp),
@ -425,14 +416,14 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(editingName))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Name", modifier = Modifier.size(20.dp))
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_name), modifier = Modifier.size(20.dp))
}
}
)
if (hasCustomName) {
Text(
text = "Original Name: $originalName",
text = stringResource(R.string.original_name, originalName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
@ -447,11 +438,11 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
modifier = Modifier.align(Alignment.End),
contentPadding = PaddingValues(0.dp)
) {
Text("Revert to Original")
Text(stringResource(R.string.revert_to_original))
}
} else if (originalName != item.displayName) {
Text(
text = "File Name: ${item.displayName}",
text = stringResource(R.string.file_name, item.displayName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
@ -463,18 +454,27 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRowDetailed("Author", it)
InfoRowDetailed(stringResource(R.string.author), it)
}
InfoRowDetailed("Format", item.type.name)
InfoRowDetailed("Size", formatFileSize(item.fileSize))
InfoRowDetailed("Added", formattedDate)
InfoRowDetailed(stringResource(R.string.format), item.type.name)
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
InfoRowDetailed(stringResource(R.string.added), formattedDate)
val pathTextFinal = if (isOpdsStream) {
stringResource(R.string.source_opds)
} else if (pathText == "In-App Storage") {
stringResource(R.string.source_in_app)
} else {
pathText.replace("Internal storage", stringResource(R.string.internal_storage))
}
InfoRowDetailed(
label = "Location",
value = pathText,
label = stringResource(R.string.location),
value = pathTextFinal,
maxLines = 4,
isScrollable = true,
onCopy = {
clipboardManager.setText(AnnotatedString(pathText))
clipboardManager.setText(AnnotatedString(pathTextFinal))
}
)
}
@ -485,7 +485,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
.padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = {
val finalName = editingName.trim()
@ -497,7 +497,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
}
}
onDismiss()
}) { Text("Save") }
}) { Text(stringResource(R.string.action_save)) }
}
}
}
@ -509,7 +509,7 @@ private fun InfoRowDetailed(
label: String,
value: String,
maxLines: Int = 1,
isScrollable: Boolean = false, // ADD THIS
isScrollable: Boolean = false,
onCopy: (() -> Unit)? = null
) {
Row(
@ -603,123 +603,101 @@ fun AboutDialog(onDismiss: () -> Unit) {
shape = RoundedCornerShape(24.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Episteme",
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (isOss) "Open Source Version" else "Playstore Version",
text = if (isOss) stringResource(R.string.about_oss_version) else stringResource(R.string.about_play_version),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(12.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Version ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.titleMedium,
text = stringResource(R.string.about_version_name, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Build ${BuildConfig.VERSION_CODE}",
text = stringResource(R.string.about_build_code, BuildConfig.VERSION_CODE.toString()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(20.dp))
if (isOss) {
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Code,
contentDescription = null,
modifier = Modifier.size(18.dp),
painter = painterResource(id = R.drawable.github),
contentDescription = stringResource(R.string.about_github),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Open Source",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
InfoRow(
icon = Icons.Outlined.Code,
text = "GitHub Repository",
subtitle = "Browse source code, star, and fork",
},
text = stringResource(R.string.about_github),
subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
)
InfoRow(
icon = Icons.Outlined.BugReport,
text = "Report an Issue",
subtitle = "File a bug or feature request",
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme/issues") }
)
} else {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Legal",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Policy,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
InfoRow(
icon = Icons.Outlined.Policy,
text = "Privacy Policy",
subtitle = "How we handle your data",
},
text = stringResource(R.string.about_privacy),
subtitle = stringResource(R.string.about_privacy_desc),
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
)
InfoRow(
icon = Icons.Outlined.Gavel,
text = "Terms of Service",
subtitle = "Usage terms and conditions",
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Gavel,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.about_terms),
subtitle = stringResource(R.string.about_terms_desc),
onClick = { uriHandler.openUri(TERMS_URL) }
)
InfoRow(
icon = Icons.Outlined.FileOpen,
text = "Licenses",
subtitle = "Libraries",
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_licenses),
subtitle = stringResource(R.string.about_licenses_desc),
onClick = { uriHandler.openUri(LICENSES_URL) }
)
}
@ -731,50 +709,40 @@ fun AboutDialog(onDismiss: () -> Unit) {
shape = RoundedCornerShape(50),
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text("Close", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.action_close), fontWeight = FontWeight.Medium)
}
}
)
}
@Composable
private fun InfoRow(
icon: ImageVector,
private fun AboutInfoRow(
icon: @Composable () -> Unit,
text: String,
subtitle: String? = null,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp),
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
icon()
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.Bold
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@ -782,10 +750,11 @@ private fun InfoRow(
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.ChevronRight,
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -798,7 +767,7 @@ fun EmptyState(
message: String,
onSelectFileClick: () -> Unit,
modifier: Modifier = Modifier,
primaryButtonText: String = "Select a File",
primaryButtonText: String = stringResource(R.string.empty_select_file),
secondaryButtonText: String? = null,
onSecondaryClick: (() -> Unit)? = null
) {
@ -858,19 +827,15 @@ fun SelectFileButton(onClick: () -> Unit, text: String) {
fun ClearCloudDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Clear All Synced Data?") },
text = { Text("Are you sure you want to permanently delete all of your book data from the cloud? This will also wipe your local library to prevent re-syncing. This action cannot be undone.") },
title = { Text(stringResource(R.string.clear_cloud_data_title)) },
text = { Text(stringResource(R.string.clear_cloud_data_desc)) },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("DELETE ALL DATA")
}
) { Text(stringResource(R.string.delete_all_data)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
)
}
@ -905,3 +870,23 @@ fun AutoSizeText(
}
)
}
@Composable
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
val containerColor = if (overlay) androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (overlay) androidx.compose.ui.graphics.Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
color = containerColor,
contentColor = contentColor
) {
Text(
text = type.name.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}

View file

@ -36,11 +36,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@ -83,7 +85,7 @@ fun DictionarySettingsDialog(
.padding(24.dp)
) {
Text(
text = "Lookup Settings",
text = stringResource(R.string.dict_lookup_settings),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 20.dp)
@ -98,7 +100,7 @@ fun DictionarySettingsDialog(
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Dictionary Engine",
text = stringResource(R.string.dict_dictionary_engine),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
@ -114,29 +116,29 @@ fun DictionarySettingsDialog(
onClick = { onToggleOnlineDictionary(true) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text("Smart (AI)")
Text(stringResource(R.string.dict_smart_ai))
}
SegmentedButton(
selected = !useOnlineDictionary,
onClick = { onToggleOnlineDictionary(false) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text("External App")
Text(stringResource(R.string.dict_external_app))
}
}
Text(
text = if (useOnlineDictionary)
"Uses AI for definitions. Will fallback to the external app below if offline or if the selected phrase is too long."
stringResource(R.string.dict_ai_description)
else
"Uses the selected app for dictionary lookups.",
stringResource(R.string.dict_external_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = if (useOnlineDictionary) "Fallback App" else "Dictionary App",
text = if (useOnlineDictionary) stringResource(R.string.dict_fallback_app) else stringResource(R.string.dict_dictionary_app),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
@ -146,13 +148,13 @@ fun DictionarySettingsDialog(
apps = dictionaryApps,
selectedPackageName = selectedDictionaryPackageName,
onSelect = onSelectDictionaryPackage,
placeholder = "Select an app"
placeholder = stringResource(R.string.dict_select_app)
)
}
}
} else {
Text(
text = "Dictionary",
text = stringResource(R.string.tooltip_dictionary),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
@ -161,7 +163,7 @@ fun DictionarySettingsDialog(
apps = dictionaryApps,
selectedPackageName = selectedDictionaryPackageName,
onSelect = onSelectDictionaryPackage,
placeholder = "Select an app"
placeholder = stringResource(R.string.dict_select_app)
)
}
@ -169,13 +171,13 @@ fun DictionarySettingsDialog(
// ── Translate ──
Text(
text = "Translate",
text = stringResource(R.string.dict_translate),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "App used for translating selected text.",
text = stringResource(R.string.dict_translate_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
@ -185,20 +187,20 @@ fun DictionarySettingsDialog(
apps = dictionaryApps,
selectedPackageName = selectedTranslatePackageName,
onSelect = onSelectTranslatePackage,
placeholder = "Select an app"
placeholder = stringResource(R.string.dict_select_app)
)
SectionDivider()
// ── Search ──
Text(
text = "Search",
text = stringResource(R.string.tooltip_search),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "App used for web searches.",
text = stringResource(R.string.dict_search_app_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
@ -208,7 +210,7 @@ fun DictionarySettingsDialog(
apps = searchApps,
selectedPackageName = selectedSearchPackageName,
onSelect = onSelectSearchPackage,
placeholder = "Select an app"
placeholder = stringResource(R.string.dict_select_app)
)
}
}
@ -272,7 +274,7 @@ private fun AppSelectionDropdown(
DropdownMenuItem(
text = {
Text(
"None",
stringResource(R.string.dict_none),
color = if (!hasSelection) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
@ -281,7 +283,7 @@ private fun AppSelectionDropdown(
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.content_desc_selected),
tint = MaterialTheme.colorScheme.primary
)
}
@ -327,7 +329,7 @@ private fun AppSelectionDropdown(
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.content_desc_selected),
tint = MaterialTheme.colorScheme.primary
)
}

View file

@ -19,13 +19,18 @@
*/
package com.aryan.reader.epubreader
import android.content.Context
import androidx.compose.foundation.layout.fillMaxWidth
import timber.log.Timber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.R
@ -137,6 +142,7 @@ suspend fun executeRecapLogic(
characterLimit: Int,
summaryCacheManager: SummaryCacheManager,
paginator: IPaginator?,
context: Context,
onProgressUpdate: (String) -> Unit,
onResultUpdate: (String) -> Unit,
onError: (String) -> Unit,
@ -216,6 +222,7 @@ suspend fun executeRecapLogic(
fetchRecap(
pastSummaries = pastSummaries,
currentText = finalContextText,
context = context,
onUpdate = { chunk -> onResultUpdate(chunk) },
onError = { error -> onError(error) },
onFinish = { onFinish() }
@ -250,7 +257,7 @@ fun EpubReaderAiOverlays(
) {
if (showSummarizationPopup) {
SummarizationPopup(
title = "Chapter Summary",
title = stringResource(R.string.ai_chapter_summary),
result = summarizationResult,
isLoading = isSummarizationLoading,
onDismiss = onDismissSummarization,
@ -260,7 +267,7 @@ fun EpubReaderAiOverlays(
if (showRecapPopup) {
SummarizationPopup(
title = "Story Recap (Beta)",
title = stringResource(R.string.ai_story_recap_beta),
result = recapResult,
isLoading = isRecapLoading,
onDismiss = onDismissRecap,
@ -272,16 +279,26 @@ fun EpubReaderAiOverlays(
AlertDialog(
onDismissRequest = onDismissSummarizationUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.summarize), contentDescription = null) },
title = { Text("Unlock Chapter Summarization") },
text = { Text("Get concise summaries of any chapter with Episteme Pro. Upgrade to start using this feature.") },
title = {
Text(
text = stringResource(R.string.ai_unlock_summarization),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
text = {
Text(
text = stringResource(R.string.ai_unlock_summarization_desc),
)
},
confirmButton = {
TextButton(onClick = {
onDismissSummarizationUpsell()
onNavigateToPro()
}) { Text("Learn More") }
}) { Text(stringResource(R.string.action_learn_more)) }
},
dismissButton = {
TextButton(onClick = onDismissSummarizationUpsell) { Text("Not Now") }
TextButton(onClick = onDismissSummarizationUpsell) { Text(stringResource(R.string.action_not_now)) }
}
)
}
@ -293,7 +310,6 @@ fun EpubReaderAiOverlays(
isLoading = isAiDefinitionLoading,
onDismiss = onDismissAiDefinition,
isMainTtsActive = isTtsSessionActive,
// Pass it down
onOpenExternalDictionary = {
selectedTextForAi?.let { text -> onOpenExternalDictionary(text) }
}
@ -304,16 +320,16 @@ fun EpubReaderAiOverlays(
AlertDialog(
onDismissRequest = onDismissDictionaryUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.ai), contentDescription = null) },
title = { Text("Unlock Smart Dictionary") },
text = { Text("Defining entire phrases and paragraphs up to 2000 characters is a Pro feature. Upgrade to get instant definitions for any selected text.") },
title = { Text(stringResource(R.string.ai_unlock_smart_dict)) },
text = { Text(stringResource(R.string.ai_unlock_smart_dict_desc)) },
confirmButton = {
TextButton(onClick = {
onDismissDictionaryUpsell()
onNavigateToPro()
}) { Text("Learn More") }
}) { Text(stringResource(R.string.action_learn_more)) }
},
dismissButton = {
TextButton(onClick = onDismissDictionaryUpsell) { Text("Not Now") }
TextButton(onClick = onDismissDictionaryUpsell) { Text(stringResource(R.string.action_not_now)) }
}
)
}

View file

@ -52,6 +52,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
@ -365,7 +366,7 @@ fun BookmarkButton(
) {
Icon(
painter = painterResource(id = R.drawable.bookmark),
contentDescription = "Bookmark",
contentDescription = stringResource(R.string.content_desc_bookmark_icon),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
@ -406,14 +407,14 @@ fun PaletteManagerDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Customize Palette", style = MaterialTheme.typography.titleMedium) },
title = { Text(stringResource(R.string.dialog_customize_palette), style = MaterialTheme.typography.titleMedium) },
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Text("Tap a slot to edit:", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.palette_tap_slot_to_edit), style = MaterialTheme.typography.bodySmall)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
@ -427,7 +428,7 @@ fun PaletteManagerDialog(
.background(colorEnum.color, CircleShape)
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, // Thin ring if selected
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
)
.clip(CircleShape)
@ -436,7 +437,7 @@ fun PaletteManagerDialog(
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected Slot",
contentDescription = stringResource(R.string.content_desc_selected_slot),
tint = if (colorEnum == HighlightColor.WHITE) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
@ -448,7 +449,7 @@ fun PaletteManagerDialog(
HorizontalDivider()
// 2. Bottom Grid: Available Colors
Text("Select a color for the slot:", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.palette_select_color_for_slot), style = MaterialTheme.typography.bodySmall)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 40.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),

View file

@ -211,15 +211,15 @@ fun EpubReaderTopBar(
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings"
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
)
}
TooltipIconButton(
text = "Theme",
description = "Theme Settings",
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = "Theme Settings")
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
@ -228,7 +228,7 @@ fun EpubReaderTopBar(
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }
) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
}
DropdownMenu(
@ -237,7 +237,7 @@ fun EpubReaderTopBar(
) {
if (onToggleReflow != null) {
DropdownMenuItem(
text = { Text("View Original PDF") },
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
onClick = {
showMoreMenu = false
onToggleReflow()
@ -256,7 +256,7 @@ fun EpubReaderTopBar(
onDeleteReflow?.let {
HorizontalDivider()
DropdownMenuItem(
text = { Text("Delete Text View") },
text = { Text(stringResource(R.string.menu_delete_text_view)) },
onClick = {
showMoreMenu = false
it()
@ -275,26 +275,26 @@ fun EpubReaderTopBar(
}
DropdownMenuItem(
text = { Text("Reading Mode: Vertical") },
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = "Selected") }
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
DropdownMenuItem(
text = { Text("Reading Mode: Paginated") },
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = "Selected") }
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(if (isBookmarked) "Remove bookmark" else "Bookmark this page") },
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
onClick = {
showMoreMenu = false
onToggleBookmark()
@ -302,20 +302,20 @@ fun EpubReaderTopBar(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Tap to Turn Pages") },
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onToggleTapToNavigate(!tapToNavigateEnabled)
showMoreMenu = false
},
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
)
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) "Volume Button Scrolling"
else "Volume Button Page Turn"
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(R.string.menu_volume_button_scrolling)
else stringResource(R.string.menu_volume_button_page_turn)
)
},
enabled = true,
@ -323,33 +323,33 @@ fun EpubReaderTopBar(
onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false
},
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Realistic Page Turns") },
text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
showMoreMenu = false
},
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Keep Screen On") },
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = {
onToggleKeepScreenOn(!isKeepScreenOn)
showMoreMenu = false
},
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = "Enabled") }
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Visual Options") },
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = {
showMoreMenu = false
onOpenVisualOptions()
@ -361,7 +361,7 @@ fun EpubReaderTopBar(
HorizontalDivider()
DropdownMenuItem(
text = { Text("Auto Scroll") },
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
showMoreMenu = false
@ -372,7 +372,7 @@ fun EpubReaderTopBar(
HorizontalDivider()
DropdownMenuItem(
text = { Text("TTS Voice Settings") },
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
@ -384,7 +384,7 @@ fun EpubReaderTopBar(
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("TTS Settings (Debug)") },
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
onClick = {
showMoreMenu = false
onOpenTtsSettings()
@ -445,28 +445,28 @@ fun EpubReaderBottomBar(
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider")
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer
) {
Icon(imageVector = Icons.Default.Menu, contentDescription = "Chapters Menu")
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
}
TooltipIconButton(
text = stringResource(R.string.tooltip_format),
description = stringResource(R.string.tooltip_format_desc),
onClick = onToggleFormat
) {
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = "Text Formatting")
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting))
}
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch
) {
Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
Icon(imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
}
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
@ -485,7 +485,7 @@ fun EpubReaderBottomBar(
onDismissRequest = { showAiFeaturesMenu = false }
) {
DropdownMenuItem(
text = { Text("Chapter Summarization") },
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = {
showAiFeaturesMenu = false
onSummarize()
@ -494,7 +494,7 @@ fun EpubReaderBottomBar(
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem(
text = { Text("Recap (Beta)") },
text = { Text(stringResource(R.string.menu_recap_beta)) },
onClick = {
showAiFeaturesMenu = false
onRecap()
@ -519,7 +519,7 @@ fun EpubReaderBottomBar(
) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts)
)
}
if (isTtsSessionActive) {
@ -537,7 +537,7 @@ fun EpubReaderBottomBar(
) {
Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) "Pause TTS" else "Resume TTS"
contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) else stringResource(R.string.content_desc_resume_tts)
)
}
}
@ -589,7 +589,7 @@ fun EpubReaderPageSlider(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Exit slider navigation"
contentDescription = stringResource(R.string.content_desc_exit_slider)
)
}
@ -681,7 +681,7 @@ fun EpubReaderPageSlider(
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = "Start page thumbnail",
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
@ -918,7 +918,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = "Expand",
contentDescription = stringResource(R.string.content_desc_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -934,7 +934,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
contentDescription = if (isPlaying) stringResource(R.string.content_desc_pause_playback) else stringResource(R.string.content_desc_start_playback),
modifier = Modifier.size(20.dp)
)
}
@ -967,13 +967,13 @@ fun AutoScrollControls(
.padding(4.dp)
) {
Text(
text = if (isLocalMode) "Local Speed" else "Global Speed",
text = if (isLocalMode) stringResource(R.string.auto_scroll_local_speed) else stringResource(R.string.auto_scroll_global_speed),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Select Mode",
contentDescription = stringResource(R.string.content_desc_select_mode),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
@ -986,8 +986,8 @@ fun AutoScrollControls(
DropdownMenuItem(
text = {
Column {
Text("Global Speed", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Applies to all files", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.auto_scroll_global_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_applies_all_files), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = {
@ -1002,8 +1002,8 @@ fun AutoScrollControls(
DropdownMenuItem(
text = {
Column {
Text("Local Speed", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Saved for this file only", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.auto_scroll_local_speed), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_saved_for_file), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = {
@ -1024,7 +1024,7 @@ fun AutoScrollControls(
) {
Icon(
painter = painterResource(id = R.drawable.music_note),
contentDescription = if (isMusicianMode) "Disable Musician Mode" else "Enable Musician Mode",
contentDescription = if (isMusicianMode) stringResource(R.string.content_desc_disable_musician_mode) else stringResource(R.string.content_desc_enable_musician_mode),
modifier = Modifier.size(18.dp),
tint = if (isMusicianMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
@ -1035,7 +1035,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = Icons.Default.SwapHoriz,
contentDescription = "Swap Controls",
contentDescription = stringResource(R.string.content_desc_swap_controls),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -1046,7 +1046,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Collapse",
contentDescription = stringResource(R.string.content_desc_collapse),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -1057,7 +1057,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
contentDescription = stringResource(R.string.action_close),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
@ -1085,7 +1085,7 @@ fun AutoScrollControls(
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
contentDescription = if (isPlaying) stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.content_desc_start_playback),
modifier = Modifier.size(24.dp)
)
}
@ -1110,13 +1110,13 @@ fun AutoScrollControls(
horizontalArrangement = Arrangement.SpaceBetween
) {
SpeedDropdown(
label = "Min",
label = stringResource(R.string.label_min),
currentValue = minSpeed,
options = speedOptions,
onValueChange = onMinSpeedChange
)
SpeedDropdown(
label = "Max",
label = stringResource(R.string.label_max),
currentValue = maxSpeed,
options = speedOptions,
onValueChange = onMaxSpeedChange
@ -1193,7 +1193,7 @@ fun AutoScrollControls(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(minSpeed)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Remove, "Slower")
Icon(Icons.Default.Remove, stringResource(R.string.content_desc_slower))
}
Text(
text = "%.1fx".format(speed),
@ -1204,7 +1204,7 @@ fun AutoScrollControls(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(safeMax)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Add, "Faster")
Icon(Icons.Default.Add, stringResource(R.string.content_desc_faster))
}
}
}

View file

@ -82,11 +82,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastSumBy
import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
@ -224,17 +226,17 @@ fun EpubReaderDrawerSheet(
Tab(
selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
text = { Text("Chapters") }
text = { Text(stringResource(R.string.tab_chapters)) }
)
Tab(
selected = drawerPagerState.currentPage == 1,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(1) } },
text = { Text("Bookmarks") }
text = { Text(stringResource(R.string.tab_bookmarks)) }
)
Tab(
selected = drawerPagerState.currentPage == 2,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text("Highlights") }
text = { Text(stringResource(R.string.tab_highlights)) }
)
}
@ -442,7 +444,7 @@ private fun TocTreeItem(
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
contentDescription = if (isExpanded) stringResource(R.string.content_desc_collapse) else stringResource(R.string.content_desc_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -477,7 +479,7 @@ private fun BookmarksList(
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any bookmarks yet.",
stringResource(R.string.no_bookmarks_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
@ -531,7 +533,7 @@ private fun BookmarksList(
IconButton(onClick = { bookmarkMenuExpandedFor = bookmark }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options for bookmark"
contentDescription = stringResource(R.string.content_desc_more_options_bookmark)
)
}
DropdownMenu(
@ -539,14 +541,14 @@ private fun BookmarksList(
onDismissRequest = { bookmarkMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.menu_rename)) },
onClick = {
showRenameBookmarkDialog = bookmark
bookmarkMenuExpandedFor = null
}
)
DropdownMenuItem(
text = { Text("Delete") },
text = { Text(stringResource(R.string.action_delete)) },
onClick = {
showDeleteConfirmDialogFor = bookmark
bookmarkMenuExpandedFor = null
@ -573,12 +575,12 @@ private fun BookmarksList(
AlertDialog(
onDismissRequest = { showRenameBookmarkDialog = null },
title = { Text("Rename Bookmark") },
title = { Text(stringResource(R.string.dialog_rename_bookmark)) },
text = {
androidx.compose.material3.OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("New Name") },
label = { Text(stringResource(R.string.label_new_name)) },
placeholder = {
Text(
text = currentName,
@ -601,12 +603,12 @@ private fun BookmarksList(
showRenameBookmarkDialog = null
}
) {
Text("Save")
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = { showRenameBookmarkDialog = null }) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)
@ -615,8 +617,8 @@ private fun BookmarksList(
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialogFor = null },
title = { Text("Delete Bookmark?") },
text = { Text("Are you sure you want to permanently delete this bookmark?") },
title = { Text(stringResource(R.string.dialog_delete_bookmark)) },
text = { Text(stringResource(R.string.dialog_delete_bookmark_desc)) },
confirmButton = {
TextButton(
onClick = {
@ -624,12 +626,12 @@ private fun BookmarksList(
showDeleteConfirmDialogFor = null
}
) {
Text("Delete")
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmDialogFor = null }) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)
@ -646,7 +648,7 @@ private fun HighlightsList(
) {
if (userHighlights.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text("No highlights yet.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
Text(stringResource(R.string.no_highlights_yet), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
}
} else {
var highlightMenuExpandedFor by remember { mutableStateOf<UserHighlight?>(null) }
@ -663,7 +665,7 @@ private fun HighlightsList(
items = userHighlights.sortedBy { it.chapterIndex },
key = { it.id }
) { highlight ->
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: "Unknown Chapter"
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: stringResource(R.string.unknown_chapter)
ListItem(
headlineContent = {
@ -694,7 +696,7 @@ private fun HighlightsList(
IconButton(onClick = { highlightMenuExpandedFor = highlight }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Options"
contentDescription = stringResource(R.string.content_desc_options)
)
}
DropdownMenu(
@ -702,7 +704,7 @@ private fun HighlightsList(
onDismissRequest = { highlightMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Delete") },
text = { Text(stringResource(R.string.action_delete)) },
onClick = {
showHighlightDeleteDialogFor = highlight
highlightMenuExpandedFor = null
@ -726,8 +728,8 @@ private fun HighlightsList(
showHighlightDeleteDialogFor?.let { highlightToDelete ->
AlertDialog(
onDismissRequest = { showHighlightDeleteDialogFor = null },
title = { Text("Delete Highlight?") },
text = { Text("Are you sure you want to permanently delete this highlight?") },
title = { Text(stringResource(R.string.dialog_delete_highlight)) },
text = { Text(stringResource(R.string.dialog_delete_highlight_desc)) },
confirmButton = {
TextButton(
onClick = {
@ -735,12 +737,12 @@ private fun HighlightsList(
showHighlightDeleteDialogFor = null
}
) {
Text("Delete")
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = { showHighlightDeleteDialogFor = null }) {
Text("Cancel")
Text(stringResource(R.string.action_cancel))
}
}
)

View file

@ -41,6 +41,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -120,18 +121,22 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@ -144,6 +149,7 @@ import com.aryan.reader.BuiltInThemes
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
@ -157,6 +163,7 @@ import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock
@ -661,15 +668,13 @@ fun EpubReaderHost(
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = word,
onUpdate = { chunk ->
text = word, onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
aiDefinitionResult =
AiDefinitionResult(definition = currentDefinition + chunk)
}, onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = { isAiDefinitionLoading = false }
}, onFinish = { isAiDefinitionLoading = false }, context = context
)
}
} else {
@ -911,7 +916,7 @@ fun EpubReaderHost(
}
}
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val configuration = LocalConfiguration.current
var lastOrientation by remember { mutableIntStateOf(configuration.orientation) }
LaunchedEffect(configuration.orientation) {
@ -1122,8 +1127,8 @@ fun EpubReaderHost(
var foundIdx = -1
for (i in chunks.indices) {
val c = chunks[i]
val cPath = com.aryan.reader.paginatedreader.CfiUtils.getPath(c.sourceCfi)
val bPath = com.aryan.reader.paginatedreader.CfiUtils.getPath(baseCfi)
val cPath = CfiUtils.getPath(c.sourceCfi)
val bPath = CfiUtils.getPath(baseCfi)
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) {
foundIdx = i
break
@ -1309,9 +1314,10 @@ fun EpubReaderHost(
characterLimit = charLimit,
summaryCacheManager = summaryCacheManager,
paginator = paginator,
context = context,
onProgressUpdate = { recapProgressMessage = it },
onResultUpdate = { chunk ->
isRecapLoading = false // Start showing content
isRecapLoading = false
val current = recapResult?.summary ?: ""
recapResult = SummarizationResult(summary = current + chunk)
},
@ -1440,7 +1446,7 @@ fun EpubReaderHost(
}
}
val pageInfoBottomPadding by androidx.compose.animation.core.animateDpAsState(
val pageInfoBottomPadding by animateDpAsState(
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
label = "PageInfoBottomPadding"
)
@ -2002,8 +2008,9 @@ fun EpubReaderHost(
if (systemUiMode == SystemUiMode.HIDDEN) {
0.dp
} else {
val insets = androidx.core.view.ViewCompat.getRootWindowInsets(view)
val ignoringVisibilityTopPx = insets?.getInsetsIgnoringVisibility(androidx.core.view.WindowInsetsCompat.Type.statusBars())?.top ?: 0
val insets = ViewCompat.getRootWindowInsets(view)
val ignoringVisibilityTopPx = insets?.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.statusBars())?.top ?: 0
val ignoringVisibilityTop = with(density) { ignoringVisibilityTopPx.toDp() }
if (ignoringVisibilityTop > 0.dp) {
@ -2093,7 +2100,7 @@ fun EpubReaderHost(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No chapters available for this book.")
Text(stringResource(R.string.no_chapters_available))
}
} else {
AnimatedContent(
@ -3496,7 +3503,7 @@ fun EpubReaderHost(
onDeleteReflow = onDeleteReflow
)
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
val autoScrollPadding by animateDpAsState(
targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp,
label = "AutoScrollPadding"
)
@ -3854,7 +3861,7 @@ fun EpubReaderHost(
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Navigating to position...",
text = stringResource(R.string.navigating_to_position),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
@ -3865,8 +3872,8 @@ fun EpubReaderHost(
if (showPermissionRationaleDialog) {
AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false },
title = { Text("Permission Required") },
text = { Text("To show playback controls while the app is in the background, please grant the notification permission.") },
title = { Text(stringResource(R.string.dialog_permission_required)) },
text = { Text(stringResource(R.string.dialog_permission_notification_desc)) },
confirmButton = {
TextButton(
onClick = {
@ -3874,7 +3881,7 @@ fun EpubReaderHost(
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
) {
Text("Continue")
Text(stringResource(R.string.action_continue))
}
},
dismissButton = {
@ -3884,7 +3891,7 @@ fun EpubReaderHost(
startTts()
}
) {
Text("Not now")
Text(stringResource(R.string.action_not_now))
}
}
)
@ -3894,11 +3901,11 @@ fun EpubReaderHost(
AlertDialog(
onDismissRequest = { showJustifyWarningDialog = false },
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Justified Text Limitation") },
text = { Text("Using Justified alignment in Paginated Mode may cause text selection and highlights to be inaccurate due to layout limitations.") },
title = { Text(stringResource(R.string.dialog_justified_text_limitation)) },
text = { Text(stringResource(R.string.dialog_justified_text_limitation_desc)) },
confirmButton = {
TextButton(onClick = { showJustifyWarningDialog = false }) {
Text("I Understand")
Text(stringResource(R.string.action_i_understand))
}
}
)
@ -3919,7 +3926,7 @@ fun EpubReaderHost(
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Navigating to chapter...",
stringResource(R.string.navigating_to_chapter),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)

View file

@ -97,6 +97,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.ui.res.stringResource
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align"
@ -389,13 +390,13 @@ fun ReaderTextFormatPanel(
.padding(4.dp)
) {
Text(
text = if (isLocalMode) "Local Format" else "Global Format",
text = if (isLocalMode) stringResource(R.string.format_local) else stringResource(R.string.format_global),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Select Mode",
contentDescription = stringResource(R.string.content_desc_select_mode),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
@ -405,8 +406,8 @@ fun ReaderTextFormatPanel(
DropdownMenuItem(
text = {
Column {
Text("Global Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Applies to all files", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.format_global), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_applies_all_files), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = { onLocalModeToggle(false); showModeMenu = false },
@ -416,8 +417,8 @@ fun ReaderTextFormatPanel(
DropdownMenuItem(
text = {
Column {
Text("Local Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Saved for this file only", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.format_local), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.auto_scroll_saved_for_file), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = { onLocalModeToggle(true); showModeMenu = false },
@ -428,10 +429,10 @@ fun ReaderTextFormatPanel(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onReset, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text("Reset")
Text(stringResource(R.string.action_reset))
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Close", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
Icon(Icons.Default.Close, stringResource(R.string.action_close), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
}
}
@ -560,15 +561,15 @@ fun FontSelectionSheetContent(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Select Font", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.select_font), style = MaterialTheme.typography.titleMedium)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text("Presets") })
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text("Imported") })
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text(stringResource(R.string.tab_presets)) })
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text(stringResource(R.string.tab_imported)) })
}
Box(modifier = Modifier.heightIn(min = 200.dp, max = 400.dp)) {
@ -582,7 +583,7 @@ fun FontSelectionSheetContent(
Text(font.displayName, fontFamily = getComposeFontFamily(font, null))
},
trailingContent = {
if (isSelected) Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary)
if (isSelected) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), tint = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable { onFontSelected(font, null) },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
@ -599,14 +600,14 @@ fun FontSelectionSheetContent(
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Import from Files")
Text(stringResource(R.string.button_import_from_files))
}
}
if (customFonts.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"No imported fonts yet.",
stringResource(R.string.no_imported_fonts_yet),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 32.dp)
)
@ -627,7 +628,7 @@ fun FontSelectionSheetContent(
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.content_desc_selected),
tint = MaterialTheme.colorScheme.primary
)
}
@ -674,16 +675,16 @@ fun VisualOptionsSheet(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Visual Options", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(16.dp))
// System UI
Text("System UI (Status & Navigation Bars)", style = MaterialTheme.typography.titleMedium)
Text("Control the visibility of the device's system bars.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(12.dp))
OptionSegmentedControl(
options = SystemUiMode.entries,
@ -695,8 +696,8 @@ fun VisualOptionsSheet(
Spacer(modifier = Modifier.height(24.dp))
// Progress Bar
Text("Progress Bar", style = MaterialTheme.typography.titleMedium)
Text("The reading progress and chapter indicator at the bottom of the screen.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.visual_options_progress_bar), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_progress_bar_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(12.dp))
OptionSegmentedControl(
options = PageInfoMode.entries,
@ -721,8 +722,8 @@ fun VisualOptionsSheet(
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text("Seamless Chapter Transition", style = MaterialTheme.typography.titleMedium)
Text("Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })

View file

@ -49,6 +49,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -61,7 +62,7 @@ import timber.log.Timber
private fun launchEmailFeedback(context: android.content.Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:epistemereader@gmail.com".toUri()
putExtra(Intent.EXTRA_SUBJECT, "Feedback: Episteme Reader")
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.feedback_email_subject))
}
try {
context.startActivity(intent)
@ -82,7 +83,7 @@ fun FeedbackScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Help & Feedback") },
title = { Text(stringResource(R.string.drawer_help_feedback)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -109,16 +110,14 @@ fun FeedbackScreen(
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Get in Touch",
Text(text = stringResource(R.string.get_in_touch),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Found a bug, have a feature request, or just want to say hi? Let us know on GitHub or send us an email.",
Text(text = stringResource(R.string.feedback_desc),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -128,8 +127,8 @@ fun FeedbackScreen(
Spacer(modifier = Modifier.height(48.dp))
FeedbackOptionCard(
title = "GitHub Issues",
description = "Report bugs, request features, and track development progress.",
title = stringResource(R.string.github_issues),
description = stringResource(R.string.github_issues_desc),
icon = {
Icon(
painter = painterResource(id = R.drawable.github),
@ -146,8 +145,8 @@ fun FeedbackScreen(
Spacer(modifier = Modifier.height(16.dp))
FeedbackOptionCard(
title = "Email Support",
description = "Contact us directly via email for any other inquiries.",
title = stringResource(R.string.email_support),
description = stringResource(R.string.email_support_desc),
icon = {
Icon(
imageVector = Icons.Outlined.Email,

View file

@ -145,6 +145,7 @@ import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
@ -259,6 +260,7 @@ import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
@ -276,6 +278,7 @@ import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PdfHighlightRepository
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
@ -294,6 +297,7 @@ import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -314,6 +318,9 @@ import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@ -370,13 +377,13 @@ private fun loadPdfThemeId(context: Context): String {
}
val PdfBuiltInThemes = listOf(
com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
com.aryan.reader.ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
com.aryan.reader.ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
com.aryan.reader.ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
com.aryan.reader.ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
com.aryan.reader.ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
com.aryan.reader.ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
)
object PdfiumCoreProvider {
@ -1453,7 +1460,7 @@ fun PdfViewerScreen(
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
val textBoxRepository = remember(context) { PdfTextBoxRepository(context) }
val highlightRepository = remember(context) { com.aryan.reader.pdf.data.PdfHighlightRepository(context) }
val highlightRepository = remember(context) { PdfHighlightRepository(context) }
var allAnnotations by remember { mutableStateOf<Map<Int, List<PdfAnnotation>>>(emptyMap()) }
@ -1932,14 +1939,14 @@ fun PdfViewerScreen(
val dx = (currentPoint.x - startPoint.x) * aspectRatio
val dy = (currentPoint.y - startPoint.y)
val angleRad = kotlin.math.atan2(dy, dx)
val angleDeg = (angleRad * 180 / kotlin.math.PI)
val absAngle = kotlin.math.abs(angleDeg)
val angleRad = atan2(dy, dx)
val angleDeg = (angleRad * 180 / PI)
val absAngle = abs(angleDeg)
val threshold = 10.0
val isHorizontal = absAngle < threshold || kotlin.math.abs(absAngle - 180.0) < threshold
val isVertical = kotlin.math.abs(absAngle - 90.0) < threshold
val isHorizontal = absAngle < threshold || abs(absAngle - 180.0) < threshold
val isVertical = abs(absAngle - 90.0) < threshold
if (isHorizontal) {
currentPoint.copy(y = startPoint.y)
@ -2215,7 +2222,7 @@ fun PdfViewerScreen(
}
Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage")
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) {
if (e is CancellationException) {
Timber.tag("PdfPositionDebug").w("UI: Restoration cancelled (likely new recomposition)")
} else {
Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.")
@ -2544,19 +2551,16 @@ fun PdfViewerScreen(
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = text,
onUpdate = { chunk ->
text = text, onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(
definition = currentDefinition + chunk
)
},
onError = { error ->
}, onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = {
}, onFinish = {
isAiDefinitionLoading = false
}
}, context = context
)
}
} else {
@ -3562,7 +3566,7 @@ fun PdfViewerScreen(
pagerState.currentPage
) {
if (paginationDraggingOriginPage != null) {
val distance = kotlin.math.abs(pagerState.currentPage - paginationDraggingOriginPage)
val distance = abs(pagerState.currentPage - paginationDraggingOriginPage)
(distance + 1).coerceAtLeast(1)
} else {
1
@ -4155,7 +4159,7 @@ fun PdfViewerScreen(
}
) { pageIndex ->
val isVisiblePage = remember(pagerState.currentPage, pageIndex) {
kotlin.math.abs(pagerState.currentPage - pageIndex) <= 1
abs(pagerState.currentPage - pageIndex) <= 1
}
val isPageBookmarked by remember(bookmarks, pageIndex) {
derivedStateOf {
@ -5675,7 +5679,7 @@ fun PdfViewerScreen(
)
}
Spacer(Modifier.height(8.dp))
androidx.compose.material3.LinearProgressIndicator(
LinearProgressIndicator(
progress = { reflowProgressValue },
modifier = Modifier.fillMaxWidth().height(6.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="book_count">
<item quantity="one">%1$d book</item>
<item quantity="other">%1$d books</item>
</plurals>
<plurals name="book_word">
<item quantity="one">book</item>
<item quantity="other">books</item>
</plurals>
<plurals name="shelf_count">
<item quantity="one">%1$d shelf</item>
<item quantity="other">%1$d shelves</item>
</plurals>
<plurals name="search_results_count">
<item quantity="one">%1$d result found</item>
<item quantity="other">%1$d results found</item>
</plurals>
</resources>

View file

@ -1,6 +1,249 @@
<resources>
<string name="app_name">Episteme</string>
<!-- General Actions & Words -->
<string name="action_cancel">Cancel</string>
<string name="action_save">Save</string>
<string name="action_delete">Delete</string>
<string name="action_remove">Remove</string>
<string name="action_ok">OK</string>
<string name="action_close">Close</string>
<string name="action_add">Add</string>
<string name="action_rename">Rename</string>
<string name="action_back">Back</string>
<string name="action_search">Search</string>
<string name="action_clear">Clear</string>
<string name="action_apply">Apply</string>
<string name="tab_free">Free</string>
<string name="action_save_catalog">Save</string>
<!-- Shared Composables & Dialogs -->
<string name="legal_agreement_full">%1$s you agree to our %2$s and acknowledge you have read our %3$s.</string>
<string name="legal_terms_of_service">Terms of Service</string>
<string name="legal_privacy_policy">Privacy Policy</string>
<string name="legal_licenses">Licenses</string>
<string name="items_selected_count">%1$d selected</string>
<string name="clear_selection">Clear Selection</string>
<string name="pin_unpin">Pin/Unpin</string>
<string name="info">Info</string>
<string name="select_all">Select All</string>
<string name="dialog_delete_permanently">Delete File(s) Permanently</string>
<string name="dialog_remove_from_recents">Remove from Recents</string>
<string name="dialog_warning_folder_sync_delete">Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone.</string>
<string name="dialog_permanently_delete_desc">Do you want to permanently delete %1$d selected file(s) from your device? This action cannot be undone.</string>
<string name="dialog_remove_recents_desc">Do you want to remove %1$d selected file(s) from the recent files list? It will reappear if you open it again from the library.</string>
<string name="file_information">File Information</string>
<string name="book_name">Book Name</string>
<string name="copy_name">Copy Name</string>
<string name="original_name">Original Name: %1$s</string>
<string name="revert_to_original">Revert to Original</string>
<string name="file_name">File Name: %1$s</string>
<string name="author">Author</string>
<string name="format">Format</string>
<string name="size">Size</string>
<string name="added">Added</string>
<string name="location">Location</string>
<string name="source_opds">Source: OPDS Stream</string>
<string name="source_in_app">In-App Storage</string>
<string name="internal_storage">Internal storage</string>
<string name="about_title">About Episteme</string>
<string name="about_version">Version: %1$s (Build: %2$d)</string>
<string name="empty_select_file">Select a File</string>
<string name="clear_cloud_data_title">Clear All Synced Data?</string>
<string name="clear_cloud_data_desc">Are you sure you want to permanently delete all of your book data from the cloud? This will also wipe your local library to prevent re-syncing. This action cannot be undone.</string>
<string name="delete_all_data">DELETE ALL DATA</string>
<!-- Navigation & Home -->
<string name="nav_home">Home</string>
<string name="nav_library">Library</string>
<string name="recent_files">Recent Files</string>
<string name="your_library_empty">Your Library is Empty</string>
<string name="your_library_empty_desc">Select a file to read, or sync a local folder to automatically import books.</string>
<string name="no_recent_files">No Recent Files</string>
<string name="no_recent_files_desc">Open a file from your library to see it here.</string>
<string name="setup_folder_sync">Setup Folder Sync</string>
<string name="sync_folder">Sync Folder</string>
<string name="local_folder">Local Folder</string>
<string name="opds_stream">OPDS Stream</string>
<string name="pinned">Pinned</string>
<string name="progress_complete">%1$d%% complete</string>
<string name="not_available_locally">Not available locally</string>
<!-- Drawer Menu -->
<string name="drawer_sign_in">Sign in with Google</string>
<string name="drawer_by_signing_in">By signing in,</string>
<string name="drawer_pro_unlocked">Episteme Pro</string>
<string name="drawer_upgrade_pro">Upgrade to Episteme Pro</string>
<string name="drawer_sync_library">Sync Library</string>
<string name="drawer_backup_local_folders">Cloud sync for Local Folders</string>
<string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string>
<string name="drawer_custom_fonts">Custom Fonts</string>
<string name="drawer_help_feedback">Help &amp; Feedback</string>
<string name="drawer_sign_out">Sign Out</string>
<!-- Top Bar Options -->
<string name="options_recent_limit">Recent Files Limit</string>
<string name="options_no_limit">No limit</string>
<string name="options_files_limit">%1$d files</string>
<string name="options_clear_book_cache">Clear Book Cache</string>
<string name="options_clear_reflow_cache">Clear Reflow Cache</string>
<!-- Library Screen -->
<string name="library_title">Library</string>
<string name="search_placeholder">Search title or author…</string>
<string name="filter_types">Types: %1$s</string>
<string name="filter_folders">Folders: %1$d</string>
<string name="filter_status">Status: %1$s</string>
<string name="tab_all_books">All Books</string>
<string name="tab_shelves">Shelves</string>
<string name="tab_folders">Folders</string>
<string name="tab_catalogs">Catalogs</string>
<string name="no_results_found">No results found for \"%1$s\"</string>
<string name="library_empty_desc">Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.</string>
<string name="fab_add_file">Add file</string>
<string name="fab_new_shelf">New shelf</string>
<string name="create_new_shelf">Create New Shelf</string>
<string name="shelf_name_hint">Shelf Name</string>
<string name="action_create">Create</string>
<string name="menu_rename_shelf">Rename shelf</string>
<string name="menu_delete_shelf">Delete shelf</string>
<string name="fab_add_books">Add books</string>
<string name="shelf_empty">This shelf is empty</string>
<string name="add_to_shelf">Add to %1$s</string>
<string name="fab_add_count">ADD (%1$d)</string>
<string name="no_unshelved_books">No unshelved books to add</string>
<string name="all_books_in_shelf">All books are already in this shelf</string>
<string name="dialog_rename_shelf">Rename Shelf</string>
<string name="dialog_delete_shelf">Delete Shelf?</string>
<string name="dialog_delete_shelf_desc">Are you sure you want to delete the \'%1$s\' shelf? All books will be moved to Unshelved.</string>
<string name="dialog_remove_from_shelf">Remove from Shelf?</string>
<string name="dialog_remove_from_shelf_desc">Are you sure you want to remove %1$d %2$s from the \'%3$s\' shelf? The book(s) will remain in your library and appear under Unshelved.</string>
<string name="dialog_delete_shelves">Delete %1$s?</string>
<string name="dialog_delete_shelves_desc">Are you sure you want to delete the %1$d selected %2$s? All books within will be moved to Unshelved.</string>
<!-- Folder Sync -->
<string name="sync_local_folders">Sync Local Folders</string>
<string name="sync_folders_desc">Connect local folders to create a live library. Episteme will monitor files and sync progress.</string>
<string name="fab_add_folder">Add Folder</string>
<string name="scan_all">Scan All</string>
<string name="scanning">Scanning…</string>
<string name="sync_meta">Sync Meta</string>
<string name="last_sync">LAST SYNC</string>
<string name="books_count">BOOKS</string>
<string name="menu_edit_filters">Edit Filters</string>
<string name="menu_remove_folder">Remove Folder</string>
<string name="filter_file_types">Filter File Types</string>
<string name="filter_file_types_desc">Select the file types you want to sync from this folder:</string>
<string name="filter_library">Filter Library</string>
<string name="filter_file_type">File Type</string>
<string name="filter_source_folder">Source Folder</string>
<string name="filter_read_status">Read Status</string>
<string name="clear_all">Clear All</string>
<!-- OPDS -->
<string name="fab_add_catalog">Add Catalog</string>
<string name="search_catalog_placeholder">Search catalog…</string>
<string name="feed_empty">This feed is empty.</string>
<string name="status_downloading">Downloading…</string>
<string name="status_loading">Loading…</string>
<string name="action_stream">Stream</string>
<string name="action_unavailable">Unavailable</string>
<string name="action_download">Download</string>
<string name="download_format">Download Format</string>
<string name="action_stream_now">Stream Now</string>
<string name="action_read">Read</string>
<string name="no_supported_formats">No supported formats available.</string>
<string name="publisher">PUBLISHER</string>
<string name="published">PUBLISHED</string>
<string name="language">LANGUAGE</string>
<string name="synopsis">Synopsis</string>
<string name="edit_catalog">Edit Catalog</string>
<string name="add_opds_catalog">Add OPDS Catalog</string>
<string name="catalog_name">Catalog Name</string>
<string name="url">URL</string>
<string name="url_placeholder">http://192.168.1.50:8080/opds</string>
<string name="auth_optional">Authentication (Optional)</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="delete_catalog">Delete Catalog</string>
<string name="delete_catalog_desc">Are you sure you want to delete \'%1$s\'?</string>
<string name="delete_catalog_warning">Deleting this catalog will also permanently remove %1$d streaming books associated with it from your library.</string>
<string name="preset_label">Preset</string>
<!-- Pro Screen -->
<string name="free_plan">Free Plan</string>
<string name="price_free">$0</string>
<string name="forever_free">Forever free</string>
<string name="feature_multiple_formats">Multiple Formats</string>
<string name="feature_multiple_formats_desc">Supports PDF, EPUB, MOBI, AZW3</string>
<string name="feature_tts">Android Text-to-Speech</string>
<string name="feature_tts_desc">Listen to your books with built-in TTS</string>
<string name="feature_dict">Basic Dictionary</string>
<string name="feature_dict_desc">Look up single words quickly</string>
<string name="current_plan">Current Plan</string>
<string name="pro_sale_off">50%% OFF</string>
<string name="loading_price">Loading price…</string>
<string name="one_time_payment">One-time payment</string>
<string name="lifetime_access">Lifetime Access</string>
<string name="early_access_sale">Early Access Sale</string>
<string name="pro_includes">Everything in Free, plus:</string>
<string name="feature_cloud_sync">Cloud Sync Across Devices</string>
<string name="feature_cloud_sync_desc">Keep your entire library, including book files and reading progress, synced across up to 4 devices.</string>
<string name="feature_summarize">Summarization</string>
<string name="feature_summarize_desc">Get quick summaries of chapters or pages</string>
<string name="feature_smart_dict">Smart Dictionary</string>
<string name="feature_smart_dict_desc">Search phrases and even paragraphs, not just single words</string>
<string name="feature_priority">Priority Feature Requests</string>
<string name="feature_priority_desc">Your suggestions get prioritized</string>
<string name="pro_unlocked">Pro Features Unlocked!</string>
<string name="sign_in_required">Sign in Required</string>
<string name="verifying_purchase">Verifying purchase…</string>
<string name="existing_purchase_found">Existing Purchase Found</string>
<string name="get_lifetime_access">Get Lifetime Access</string>
<string name="upgrade_unavailable">Upgrade currently unavailable. Please check your internet and try again.</string>
<string name="sign_in_to_purchase">Please sign in to your Google account to purchase Episteme Pro.</string>
<string name="verifying_purchase_desc">This may take a few moments. Your Pro status will be updated automatically.</string>
<string name="dialog_existing_purchase_desc">This device already has a Pro purchase, but it\'s linked to a different account. Please sign in to the account that was used for the original purchase to restore your Pro features.</string>
<string name="dialog_early_access_desc">You\'re getting Episteme Pro at a special discounted price during our early access period! This is a limited-time offer.</string>
<string name="dialog_sign_in_required_desc">Please sign in to your Google account to purchase Episteme Pro and unlock all premium features.</string>
<string name="action_not_now">Not Now</string>
<string name="action_got_it">Got It!</string>
<!-- Fonts -->
<string name="custom_fonts">Custom Fonts</string>
<string name="import_font">Import Font</string>
<string name="no_custom_fonts">No Custom Fonts</string>
<string name="import_fonts_desc">Import TTF or OTF files to use them in your books.</string>
<string name="font_preview_text">Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:</string>
<string name="font_preview_error">Preview unavailable (Invalid font file)</string>
<string name="dialog_delete_font">Delete Font?</string>
<string name="dialog_delete_font_desc">Are you sure you want to delete \'%1$s\'? This will remove it from all your devices if sync is on.</string>
<!-- Feedback -->
<string name="get_in_touch">Get in Touch</string>
<string name="feedback_desc">Found a bug, have a feature request, or just want to say hi? Let us know on GitHub or send us an email.</string>
<string name="github_issues">GitHub Issues</string>
<string name="github_issues_desc">Report bugs, request features, and track development progress.</string>
<string name="email_support">Email Support</string>
<string name="email_support_desc">Contact us directly via email for any other inquiries.</string>
<!-- Dialogs -->
<string name="dialog_unlock_pro">Unlock Episteme Pro</string>
<string name="dialog_unlock_pro_desc">Sync across devices is a Pro feature. Unlock all pro features with a single, one-time purchase.</string>
<string name="action_upgrade">Upgrade</string>
<string name="dialog_confirm_sign_out">Confirm Sign Out</string>
<string name="dialog_confirm_sign_out_desc">Are you sure you want to sign out?</string>
<string name="device_limit_reached">Device Limit Reached</string>
<string name="device_limit_reached_desc">To use Episteme Pro on this device, please remove one of your existing registered devices.</string>
<string name="last_seen">Last seen: %1$s</string>
<string name="dialog_destructive_action">Confirm Destructive Action</string>
<string name="dialog_destructive_action_desc">This will permanently delete all your books and reading progress from this device AND from your Google Drive account. This action cannot be undone. Are you sure?</string>
<string name="dialog_clear_book_cache">Clear Book Cache</string>
<string name="dialog_clear_book_cache_desc">This will clear all processed pages in pagination mode. This helps fix layout issues but will require books to be re-processed next time you open them.</string>
<string name="action_confirm_clear">Confirm &amp; Clear</string>
<string name="dialog_clear_reflow_cache">Clear Reflow Cache</string>
<string name="dialog_clear_reflow_cache_desc">This will delete all generated \'Text View\' versions of your PDFs and clear their associated images/HTML cache. Your original PDFs will remain untouched.</string>
<!-- Tooltip titles -->
<string name="tooltip_back">Back</string>
<string name="tooltip_dictionary">Dictionary</string>
@ -58,4 +301,328 @@
<string name="tooltip_hide_results_desc">Collapse the search results panel</string>
<string name="tooltip_prev_result_desc">Jump to the previous search match in the document</string>
<string name="tooltip_next_result_desc">Jump to the next search match in the document</string>
<string name="action_sign_in">Sign In</string>
<string name="action_select_folder">Select Folder</string>
<string name="action_select">Select</string>
<string name="legal_footer_combined">Privacy Policy • Terms of Service • Licenses</string>
<string name="app_name_oss">Episteme OSS</string>
<string name="debug_show_device_management">[Debug] Show Device Management</string>
<string name="debug_clear_cloud_local_data">[Debug] Clear Cloud &amp; Local Data</string>
<string name="debug_fps">FPS: %1$d</string>
<string name="error_folder_selection_unsupported">Your device doesn\'t support folder selection. You can still import files individually.</string>
<string name="error_no_file_manager">No file manager found. Please install a file manager app.</string>
<string name="feedback_email_subject">Feedback: Episteme Reader</string>
<string name="banner_downloaded">Downloaded %1$s</string>
<string name="filter_facet">%1$s: %2$s</string>
<string name="never">Never</string>
<string name="folder_filter_count">%1$s: %2$d</string>
<!-- ViewModel Messages: Banners & Errors -->
<string name="banner_removed_streaming_books">Removed %1$d streaming books.</string>
<string name="error_import_font">Failed to import font: %1$s</string>
<string name="banner_text_view_deleted">Text view deleted.</string>
<string name="error_sync_drive_permission">Sync requires Google Drive permission.</string>
<string name="error_purchase_general">An error occurred with the purchase.</string>
<string name="banner_upgrade_success">Upgrade successful! Welcome to Pro.</string>
<string name="error_purchase_verification">Purchase verification failed. Please contact support if you were charged.</string>
<string name="banner_device_removed">This device was removed from your account.</string>
<string name="error_verify_device">Could not verify this device. Please check your connection.</string>
<string name="error_update_devices">Failed to update devices. Please try again.</string>
<string name="banner_saving_pdf">Saving PDF…</string>
<string name="banner_pdf_saved">PDF saved successfully.</string>
<string name="error_open_file_saving">Failed to open file for saving.</string>
<string name="error_saving_pdf">Error saving PDF: %1$s</string>
<string name="banner_saving_original_pdf">Saving original PDF…</string>
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
<string name="share_subject">Sharing: %1$s</string>
<string name="share_chooser_title">Share PDF</string>
<string name="error_share_failed">Share failed: %1$s</string>
<string name="error_folder_limit_reached">Limit reached: Maximum %1$d folders allowed.</string>
<string name="error_folder_already_synced">This folder is already synced.</string>
<string name="banner_folder_added">Folder added: %1$s</string>
<string name="error_access_folder_permissions">Failed to access folder permissions.</string>
<string name="banner_folder_removed">Folder removed.</string>
<string name="banner_folder_sync_updating">Folder Sync: Updating metadata…</string>
<string name="banner_folder_sync_scanning">Scanning folder for new books…</string>
<string name="banner_folder_sync_complete">Folder Sync: Scan complete.</string>
<string name="error_sync_failed">Sync failed.</string>
<string name="error_enable_sync_download">Enable sync to download files.</string>
<string name="error_download_failed">Failed to download %1$s.</string>
<string name="error_enable_sync_clear_cloud">Enable sync to clear cloud data.</string>
<string name="error_not_signed_in_clear_cloud">Not signed in, cannot clear cloud data.</string>
<string name="banner_clearing_cloud_local_data">Clearing all cloud and local data…</string>
<string name="banner_cloud_local_data_cleared">All cloud and local data cleared successfully.</string>
<string name="error_clear_all_data">Error: Failed to clear all data.</string>
<string name="banner_local_data_cleared">All local data cleared.</string>
<string name="error_sign_in_failed">Sign in failed. Please try again.</string>
<string name="error_no_google_account">Could not find a Google account. This can happen on a fresh install, please try again in a moment.</string>
<string name="error_sign_in_internet">An error occurred during sign in. Please check your internet connection.</string>
<string name="error_sign_in_device_management">Please sign in to test device management.</string>
<string name="error_sync_pro_feature">Sync is an Episteme Pro feature.</string>
<string name="error_not_signed_in_sync">Not signed in, cannot sync.</string>
<string name="banner_cloud_sync_checking">Cloud Sync: Checking for updates…</string>
<string name="banner_cloud_sync_complete">Cloud Sync: Complete.</string>
<string name="error_sync_library_failed">Failed to sync library.</string>
<string name="error_recent_item_not_found">Could not find recent item.</string>
<string name="error_import_file_failed">Failed to import file.</string>
<string name="error_file_location_not_found">Could not find file location.</string>
<string name="error_load_generated_text_view">Failed to load generated text view.</string>
<string name="error_text_view_generation_failed">Text view generation failed.</string>
<string name="error_load_fb2">Failed to load FB2: %1$s</string>
<string name="error_load_file">Failed to load file: %1$s</string>
<string name="error_load_mobi">Failed to load MOBI: %1$s</string>
<string name="error_load_epub">Failed to load EPUB: %1$s</string>
<string name="banner_file_deleted_from_folder">File deleted from folder. Removed from library.</string>
<string name="error_shelf_exists">A shelf with that name already exists.</string>
<string name="banner_deleting_all_devices">Deleting from all devices…</string>
<string name="banner_deletion_complete">Deletion complete.</string>
<string name="error_cloud_sync_failed_deleted_locally">Cloud sync failed, deleted locally.</string>
<string name="banner_books_removed_library">%1$d book(s) removed from library.</string>
<string name="banner_reflow_cache_cleared">Reflow cache &amp; generated text views cleared.</string>
<!-- Common.kt: SearchTopBar & SearchNavigationControls -->
<string name="search_in_book">Search in book…</string>
<string name="content_desc_close_search">Close Search</string>
<string name="content_desc_clear_search">Clear Search</string>
<string name="content_desc_hide_results">Hide Results</string>
<string name="content_desc_show_results">Show Results</string>
<string name="content_desc_prev_result">Previous Search Result</string>
<string name="content_desc_next_result">Next Search Result</string>
<string name="search_no_results_simple">No results found.</string>
<!-- Common.kt: SummarizationPopup -->
<string name="generating_summary">Generating summary…</string>
<string name="action_stop">Stop</string>
<string name="action_read_aloud">Read aloud</string>
<string name="action_copy">Copy</string>
<string name="no_summary_generated">No summary could be generated.</string>
<!-- Common.kt: AiDefinitionPopup -->
<string name="ai_thinking">Thinking…</string>
<string name="content_desc_open_dictionary">Open in Dictionary App</string>
<string name="ai_no_definition">AI could not provide a definition.</string>
<string name="ai_asking_about">Asking AI about \'%1$s\'…</string>
<!-- Common.kt: TtsSettingsSheet -->
<string name="tts_settings">Text-to-Speech Settings</string>
<string name="tts_stop_to_change_settings">Please stop playback to change settings.</string>
<string name="tts_synthesis_mode">Synthesis Mode</string>
<string name="tts_mode_on_device">On-Device</string>
<string name="tts_mode_cloud_hq">Cloud (HQ)</string>
<string name="tts_voice_selection">Voice Selection</string>
<string name="tts_play_sample">Play Sample</string>
<!-- Common.kt: DeviceVoiceSettingsSheet -->
<string name="tts_device_voice_settings">On-Device Voice Settings</string>
<string name="content_desc_close_settings">Close Settings</string>
<string name="tts_system_default">System Default</string>
<string name="tts_system_default_desc">Matches your Android system settings</string>
<string name="content_desc_selected">Selected</string>
<string name="tts_loading_voices">Loading voices…</string>
<string name="tts_no_voices">No voices available on this device.</string>
<string name="tts_specific_voices">Specific Voices</string>
<string name="tts_available_voices_count">Available Voices (%1$d)</string>
<string name="tts_no_voices_for_language">No voices found for this language.</string>
<string name="tts_voice_variant">Variant: %1$s</string>
<string name="tts_voice_sample_text">This is a sample of %1$s.</string>
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
<string name="reading_themes">Reading Themes</string>
<string name="theme_presets">Presets</string>
<string name="theme_my_themes">My Themes</string>
<string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string>
<string name="theme_new">New Theme</string>
<string name="theme_edit">Edit Theme</string>
<string name="theme_name">Theme Name</string>
<string name="theme_preview_quote">So many books, so little time.</string>
<string name="theme_preview_author">- Frank Zappa</string>
<string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string>
<string name="theme_page_color">Page Color</string>
<string name="theme_text_color">Text Color</string>
<string name="theme_color_live_preview">Live Preview</string>
<string name="theme_color_preview_text">Reading is dreaming.</string>
<string name="theme_color_hex">Hex</string>
<!-- Common.kt: RGB Inputs -->
<string name="color_r">R</string>
<string name="color_g">G</string>
<string name="color_b">B</string>
<!-- Common.kt: fetchAiDefinition -->
<string name="error_text_empty">Text is empty.</string>
<string name="error_ai_empty_definition">AI returned an empty definition.</string>
<string name="error_could_not_get_definition">Could not get definition.</string>
<string name="error_unknown_server">An unknown server error occurred.</string>
<string name="error_network_check_connection">Network error. Check connection.</string>
<!-- Common.kt: fetchRecap -->
<string name="error_not_enough_context">Not enough context for a recap.</string>
<string name="error_parse_recap">Failed to parse recap.</string>
<string name="error_network_recap">Network error during recap generation.</string>
<!-- DictionarySettingsDialog.kt -->
<string name="dict_lookup_settings">Lookup Settings</string>
<string name="dict_dictionary_engine">Dictionary Engine</string>
<string name="dict_smart_ai">Smart (AI)</string>
<string name="dict_external_app">External App</string>
<string name="dict_ai_description">Uses AI for definitions. Will fall back to the external app below if offline or if the selected phrase is too long.</string>
<string name="dict_external_description">Uses the selected app for dictionary lookups.</string>
<string name="dict_fallback_app">Fallback App</string>
<string name="dict_dictionary_app">Dictionary App</string>
<string name="dict_select_app">Select an app</string>
<string name="dict_translate">Translate</string>
<string name="dict_translate_description">App used for translating selected text.</string>
<string name="dict_search_app">Search App</string>
<string name="dict_search_app_description">App used for web searches.</string>
<string name="dict_none">None</string>
<!-- EpubReaderAi.kt -->
<string name="ai_error_book_content_empty">The book content is empty.</string>
<string name="ai_error_parse_summary">Failed to parse summary from server response.</string>
<string name="ai_error_fetch_summary">Could not fetch summary.</string>
<string name="ai_error_with_code">Error: %1$d. %2$s</string>
<string name="ai_error_network_server">Network error. Please check connection and server status.</string>
<string name="ai_analyzing_chapter">Analyzing Chapter %1$d…</string>
<string name="ai_reading_position">Reading current position…</string>
<string name="ai_generating_recap">Generating Recap…</string>
<string name="ai_chapter_summary">Chapter Summary</string>
<string name="ai_story_recap_beta">Story Recap (Beta)</string>
<string name="ai_unlock_summarization">Unlock Chapter Summarization</string>
<string name="ai_unlock_summarization_desc">Get concise summaries of any chapter with Episteme Pro. Upgrade to start using this feature.</string>
<string name="action_learn_more">Learn More</string>
<string name="ai_unlock_smart_dict">Unlock Smart Dictionary</string>
<string name="ai_unlock_smart_dict_desc">Defining entire phrases and paragraphs up to 2000 characters is a Pro feature. Upgrade to get instant definitions for any selected text.</string>
<!-- EpubReaderAnnotations.kt -->
<string name="content_desc_bookmark_icon">Bookmark</string>
<string name="content_desc_selected_slot">Selected Slot</string>
<string name="dialog_customize_palette">Customize Palette</string>
<string name="palette_tap_slot_to_edit">Tap a slot to edit:</string>
<string name="palette_select_color_for_slot">Select a color for the slot:</string>
<!-- EpubReaderContent.kt -->
<string name="chapter_empty">This chapter is empty.</string>
<string name="chapter_not_found">Chapter not found</string>
<string name="error_loading_chapter">Error loading chapter</string>
<!-- EpubReaderControls.kt -->
<string name="content_desc_dictionary_settings">Dictionary Settings</string>
<string name="tooltip_theme">Theme</string>
<string name="tooltip_theme_desc">Theme Settings</string>
<string name="content_desc_more_options">More Options</string>
<string name="menu_view_original_pdf">View Original PDF</string>
<string name="menu_delete_text_view">Delete Text View</string>
<string name="menu_reading_mode_vertical">Reading Mode: Vertical</string>
<string name="menu_reading_mode_paginated">Reading Mode: Paginated</string>
<string name="content_desc_enabled">Enabled</string>
<string name="menu_remove_bookmark">Remove bookmark</string>
<string name="menu_bookmark_this_page">Bookmark this page</string>
<string name="menu_tap_to_turn_pages">Tap to Turn Pages</string>
<string name="menu_volume_button_scrolling">Volume Button Scrolling</string>
<string name="menu_volume_button_page_turn">Volume Button Page Turn</string>
<string name="menu_realistic_page_turns">Realistic Page Turns</string>
<string name="menu_keep_screen_on">Keep Screen On</string>
<string name="menu_visual_options">Visual Options</string>
<string name="menu_auto_scroll">Auto Scroll</string>
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
<string name="content_desc_navigate_slider">Navigate with slider</string>
<string name="content_desc_chapters_menu">Chapters Menu</string>
<string name="content_desc_text_formatting">Text Formatting</string>
<string name="menu_chapter_summarization">Chapter Summarization</string>
<string name="menu_recap_beta">Recap (Beta)</string>
<string name="content_desc_stop_tts">Stop TTS</string>
<string name="content_desc_start_tts">Start TTS</string>
<string name="content_desc_pause_tts">Pause TTS</string>
<string name="content_desc_resume_tts">Resume TTS</string>
<string name="content_desc_exit_slider">Exit slider navigation</string>
<string name="content_desc_start_page_thumbnail">Start page thumbnail</string>
<string name="content_desc_expand">Expand</string>
<string name="content_desc_collapse">Collapse</string>
<string name="content_desc_pause_playback">Pause</string>
<string name="content_desc_start_playback">Play</string>
<string name="auto_scroll_local_speed">Local Speed</string>
<string name="auto_scroll_global_speed">Global Speed</string>
<string name="content_desc_select_mode">Select Mode</string>
<string name="auto_scroll_applies_all_files">Applies to all files</string>
<string name="auto_scroll_saved_for_file">Saved for this file only</string>
<string name="content_desc_disable_musician_mode">Disable Musician Mode</string>
<string name="content_desc_enable_musician_mode">Enable Musician Mode</string>
<string name="content_desc_swap_controls">Swap Controls</string>
<string name="label_min">Min</string>
<string name="label_max">Max</string>
<string name="content_desc_slower">Slower</string>
<string name="content_desc_faster">Faster</string>
<string name="page_of_pages">Page %1$d of %2$d</string>
<!-- EpubReaderDrawer.kt -->
<string name="tab_chapters">Chapters</string>
<string name="tab_bookmarks">Bookmarks</string>
<string name="tab_highlights">Highlights</string>
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
<string name="bookmark_page_of">Page %1$d of %2$d</string>
<string name="content_desc_more_options_bookmark">More options for bookmark</string>
<string name="menu_rename">Rename</string>
<string name="dialog_rename_bookmark">Rename Bookmark</string>
<string name="label_new_name">New Name</string>
<string name="dialog_delete_bookmark">Delete Bookmark?</string>
<string name="dialog_delete_bookmark_desc">Are you sure you want to permanently delete this bookmark?</string>
<string name="no_highlights_yet">No highlights yet.</string>
<string name="unknown_chapter">Unknown Chapter</string>
<string name="content_desc_options">Options</string>
<string name="dialog_delete_highlight">Delete Highlight?</string>
<string name="dialog_delete_highlight_desc">Are you sure you want to permanently delete this highlight?</string>
<!-- EpubReaderScreen.kt -->
<string name="banner_original_pdf_not_found">Original PDF not found.</string>
<string name="error_book_content_not_found">Error: Book content not found. Path: %1$s</string>
<string name="toast_select_dictionary_first">Please select a dictionary app first.</string>
<string name="toast_select_translate_first">Please select a translate app first.</string>
<string name="toast_select_search_first">Please select a search app first.</string>
<string name="no_chapters_available">No chapters available for this book.</string>
<string name="navigating_to_position">Navigating to position…</string>
<string name="dialog_permission_required">Permission Required</string>
<string name="dialog_permission_notification_desc">To show playback controls while the app is in the background, please grant the notification permission.</string>
<string name="action_continue">Continue</string>
<string name="dialog_justified_text_limitation">Justified Text Limitation</string>
<string name="dialog_justified_text_limitation_desc">Using Justified alignment in Paginated Mode may cause text selection and highlights to be inaccurate due to layout limitations.</string>
<string name="action_i_understand">I Understand</string>
<string name="navigating_to_chapter">Navigating to chapter…</string>
<string name="toast_select_offline_dict_first">Select an offline dictionary first.</string>
<string name="banner_book_not_paginated">Book is not paginated yet.</string>
<string name="banner_wait_for_load">Wait for book to load fully.</string>
<string name="release_for_previous_chapter">Release for Previous Chapter</string>
<string name="release_for_next_chapter">Release for Next Chapter</string>
<string name="pull_further_percent">Pull further… (%1$d%%)</string>
<string name="chapter">Chapter</string>
<string name="error_could_not_get_chapter_content">Could not get chapter content.</string>
<string name="error_could_not_determine_chapter">Could not determine current chapter.</string>
<string name="error_webview_not_available">WebView not available.</string>
<string name="page_number_of_total">Page %1$d/%2$d</string>
<!-- EpubReaderSettings.kt -->
<string name="format_local">Local Format</string>
<string name="format_global">Global Format</string>
<string name="action_reset">Reset</string>
<string name="label_size">Size</string>
<string name="label_spacing">Spacing</string>
<string name="select_font">Select Font</string>
<string name="tab_presets">Presets</string>
<string name="tab_imported">Imported</string>
<string name="button_import_from_files">Import from Files</string>
<string name="no_imported_fonts_yet">No imported fonts yet.</string>
<string name="visual_options_title">Visual Options</string>
<string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string>
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
<string name="visual_options_progress_bar">Progress Bar</string>
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator at the bottom of the screen.</string>
<string name="visual_options_seamless_chapter">Seamless Chapter Transition</string>
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
<!-- ExternalDictionaryHelper.kt -->
<string name="dict_app_label_search">Search</string>
<string name="error_opening_dictionary">Error opening dictionary</string>
<string name="error_opening_translate">Error opening translate app</string>
<string name="error_opening_search">Error opening search app</string>
</resources>