Text lookup upgrade (#66)

* Added `TooltipIconButton` and integrated tooltips for tool icons in app bars

* Added external translate and search support for EPUB and PDF readers

* docs: refine project overview and update build instructions in readme
This commit is contained in:
Aryan 2026-03-14 13:01:46 +05:30 committed by GitHub
parent 74e2cec415
commit f2c5ae25d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1172 additions and 506 deletions

View file

@ -36,7 +36,6 @@ import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -57,6 +56,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -85,7 +85,6 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
import com.aryan.reader.countWords
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
@ -333,6 +332,8 @@ fun ChapterWebView(
isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
@ -887,121 +888,82 @@ fun ChapterWebView(
)
}
// 2. Delete Option (Only for existing highlights)
if (state.isExistingHighlight && state.cfi != null) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
// LOGGING START
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
// 1. IMPROVED LOOKUP: Check if the clicked CFI exists within any split CFI string
val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
}
if (highlightToDelete == null) {
Timber.e("Kotlin: ERROR - Lookup failed. CFI '${state.cfi}' not found in any highlight.")
} else {
Timber.d("Kotlin: SUCCESS - Found highlight object. Full CFI: '${highlightToDelete.cfi}', Color: ${highlightToDelete.color.id}")
val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi ->
Timber.d("Kotlin: Requesting JS removal for part: '$partCfi'")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
null
)
}
onHighlightDeleted(highlightToDelete.cfi)
}
state.finishActionModeCallback()
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Text(
text = "Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
// 2. Copy Option
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
clipboard.setPrimaryClip(clip)
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CopyAll,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Copy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
IconButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
clipboard.setPrimaryClip(clip)
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
customMenuState = null
}) {
Icon(Icons.Default.CopyAll, contentDescription = "Copy")
}
// 3. Dictionary Option (Preserving Logic)
if (state.selectedText.length <= 2000) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
onWordSelectedForAiDefinition(textToDefine)
}
customMenuState = null
if (state.selectedText.length <= 2000) {
IconButton(onClick = {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
onWordSelectedForAiDefinition(textToDefine)
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Dictionary",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
customMenuState = null
}) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary")
}
IconButton(onClick = {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
onTranslate(textToDefine)
}
customMenuState = null
}) {
Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate")
}
IconButton(onClick = {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
onSearch(textToDefine)
}
customMenuState = null
}) {
Icon(painterResource(id = R.drawable.search), contentDescription = "Search")
}
}
if (state.isExistingHighlight && state.cfi != null) {
IconButton(onClick = {
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
}
if (highlightToDelete != null) {
val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi ->
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
null
)
}
onHighlightDeleted(highlightToDelete.cfi)
}
state.finishActionModeCallback()
customMenuState = null
}) {
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error)
}
}
}
}

View file

@ -1,30 +1,29 @@
// DictionarySettingsDialog.kt
package com.aryan.reader.epubreader
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -35,19 +34,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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)
@Composable
fun DictionarySettingsDialog(
isVisible: Boolean,
@ -55,244 +51,292 @@ fun DictionarySettingsDialog(
isProUser: Boolean,
useOnlineDictionary: Boolean,
onToggleOnlineDictionary: (Boolean) -> Unit,
selectedPackageName: String?,
onSelectPackage: (String) -> Unit
selectedDictionaryPackageName: String?,
onSelectDictionaryPackage: (String) -> Unit,
selectedTranslatePackageName: String?,
onSelectTranslatePackage: (String) -> Unit,
selectedSearchPackageName: String?,
onSelectSearchPackage: (String) -> Unit
) {
if (!isVisible) return
val context = LocalContext.current
var availableApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
LaunchedEffect(Unit) {
availableApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp)
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
// Header
Text(
text = "Dictionary Settings",
text = "Lookup Settings",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 20.dp)
)
// ── Dictionary ──
if (BuildConfig.FLAVOR != "oss") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(12.dp),
color = if (useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
border = BorderStroke(
1.dp,
if (useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleOnlineDictionary(true) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = null,
tint = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "AI Smart Dictionary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
Text(
text = "Definitions powered by AI.",
style = MaterialTheme.typography.bodySmall,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (useOnlineDictionary) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Dictionary Engine",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
// External App Option
Surface(
shape = RoundedCornerShape(12.dp),
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
border = BorderStroke(
1.dp,
if (!useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleOnlineDictionary(false) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "External App",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
Text(
text = "Launch an offline dictionary or search app.",
style = MaterialTheme.typography.bodySmall,
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
)
SegmentedButton(
selected = useOnlineDictionary,
onClick = { onToggleOnlineDictionary(true) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text("Smart (AI)")
}
if (!useOnlineDictionary) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
SegmentedButton(
selected = !useOnlineDictionary,
onClick = { onToggleOnlineDictionary(false) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text("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."
else
"Uses the selected app for dictionary lookups.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = if (useOnlineDictionary) "Fallback App" else "Dictionary App",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
AppSelectionDropdown(
apps = dictionaryApps,
selectedPackageName = selectedDictionaryPackageName,
onSelect = onSelectDictionaryPackage,
placeholder = "Select an app"
)
}
}
Spacer(modifier = Modifier.height(24.dp))
} else {
Text(
text = if (useOnlineDictionary) "Fallback External App (Used when offline)" else "Select External App",
text = "Dictionary",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
} else {
// OSS FLAVOR UI (Dedicated to external apps)
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(12.dp))
Text(
text = "Choose an external app to define selected words.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
AppSelectionDropdown(
apps = dictionaryApps,
selectedPackageName = selectedDictionaryPackageName,
onSelect = onSelectDictionaryPackage,
placeholder = "Select an app"
)
}
// App List
if (availableApps.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
"No supported dictionary apps found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
SectionDivider()
// ── Translate ──
Text(
text = "Translate",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "App used for translating selected text.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
)
AppSelectionDropdown(
apps = dictionaryApps,
selectedPackageName = selectedTranslatePackageName,
onSelect = onSelectTranslatePackage,
placeholder = "Select an app"
)
SectionDivider()
// ── Search ──
Text(
text = "Search",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "App used for web searches.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
)
AppSelectionDropdown(
apps = searchApps,
selectedPackageName = selectedSearchPackageName,
onSelect = onSelectSearchPackage,
placeholder = "Select an app"
)
}
}
}
}
@Composable
private fun SectionDivider() {
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppSelectionDropdown(
apps: List<ExternalDictionaryApp>,
selectedPackageName: String?,
onSelect: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val selectedApp = apps.find { it.packageName == selectedPackageName }
val hasSelection = !selectedPackageName.isNullOrEmpty() && selectedApp != null
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier
) {
OutlinedTextField(
value = if (hasSelection) selectedApp.label else "",
onValueChange = {},
readOnly = true,
singleLine = true,
placeholder = { Text(placeholder) },
leadingIcon = if (hasSelection && selectedApp.icon != null) {
{
Image(
bitmap = selectedApp.icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// None option
DropdownMenuItem(
text = {
Text(
"None",
color = if (!hasSelection) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
},
trailingIcon = if (!hasSelection) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp) // Bound height so it doesn't take over screen
.background(MaterialTheme.colorScheme.surfaceContainerLowest, RoundedCornerShape(12.dp))
) {
items(availableApps) { app ->
val isSelected = app.packageName == selectedPackageName
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectPackage(app.packageName) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Dynamic Icon handling
if (app.packageName == ExternalDictionaryHelper.GOOGLE_SEARCH_PKG) {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(24.dp)
)
}
} else if (app.icon != null) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp))
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = app.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
}
}
} else null,
onClick = {
onSelect("")
expanded = false
}
)
if (apps.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
}
apps.forEach { app ->
val isSelected = app.packageName == selectedPackageName
DropdownMenuItem(
text = { Text(app.label) },
leadingIcon = {
if (app.icon != null) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Box(
modifier = Modifier
.size(24.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center,
content = {}
)
}
},
trailingIcon = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
} else null,
onClick = {
onSelect(app.packageName)
expanded = false
}
)
}
}
}

View file

@ -108,6 +108,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
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
@ -120,6 +121,7 @@ import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
@ -182,7 +184,11 @@ fun EpubReaderTopBar(
onCloseSearch = onCloseSearch
)
} else {
IconButton(onClick = onNavigateBack) {
TooltipIconButton(
text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc),
onClick = onNavigateBack
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Spacer(Modifier.width(8.dp))
@ -193,7 +199,11 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onOpenDictionarySettings) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onOpenDictionarySettings
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings"
@ -201,7 +211,11 @@ fun EpubReaderTopBar(
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMoreMenu = true }) {
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }
) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
@ -392,19 +406,33 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
IconButton(
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider")
}
IconButton(onClick = onOpenDrawer) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer
) {
Icon(imageVector = Icons.Default.Menu, contentDescription = "Chapters Menu")
}
IconButton(onClick = onToggleFormat) {
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")
}
IconButton(onClick = onToggleSearch) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch
) {
Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
}
@ -412,7 +440,11 @@ fun EpubReaderBottomBar(
if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showAiFeaturesMenu = true }) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }
) {
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
}
DropdownMenu(
@ -441,14 +473,32 @@ fun EpubReaderBottomBar(
}
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onToggleTts) {
TooltipIconButton(
text = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop)
else
stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop_desc)
else
stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
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"
)
}
if (isTtsSessionActive) {
IconButton(
TooltipIconButton(
text = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause)
else
stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
) {

View file

@ -291,6 +291,8 @@ private fun saveTtsMode(context: Context, modeName: String) {
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
@ -313,6 +315,26 @@ private fun saveExternalDictPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
private fun loadExternalTranslatePackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
}
private fun saveExternalTranslatePackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
}
private fun loadExternalSearchPackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
}
private fun saveExternalSearchPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -573,6 +595,12 @@ fun EpubReaderHost(
var selectedDictPackage by remember {
mutableStateOf(loadExternalDictPackage(context))
}
var selectedTranslatePackage by remember {
mutableStateOf(loadExternalTranslatePackage(context))
}
var selectedSearchPackage by remember {
mutableStateOf(loadExternalSearchPackage(context))
}
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -605,7 +633,7 @@ fun EpubReaderHost(
showDictionaryUpsellDialog = true
}
} else {
if (selectedDictPackage != null) {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word)
} else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show()
@ -614,6 +642,24 @@ fun EpubReaderHost(
}
}
val onTranslateLookup = { text: String ->
if (!selectedTranslatePackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchTranslate(context, selectedTranslatePackage!!, text)
} else {
Toast.makeText(context, "Please select a translate app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val onSearchLookup = { text: String ->
if (!selectedSearchPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchSearch(context, selectedSearchPackage!!, text)
} else {
Toast.makeText(context, "Please select a search app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(null) }
@ -2256,6 +2302,12 @@ fun EpubReaderHost(
onWordSelectedForAiDefinition = { text ->
onDictionaryLookup(text)
},
onTranslate = { text ->
onTranslateLookup(text)
},
onSearch = { text ->
onSearchLookup(text)
},
onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization")
scope.launch {
@ -2591,6 +2643,12 @@ fun EpubReaderHost(
onWordSelectedForAiDefinition = { text ->
onDictionaryLookup(text)
},
onTranslate = { text ->
onTranslateLookup(text)
},
onSearch = { text ->
onSearchLookup(text)
},
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
onHighlightCreated = { cfi, text, colorId ->
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
@ -3532,7 +3590,7 @@ fun EpubReaderHost(
onNavigateToPro = onNavigateToPro,
isTtsSessionActive = isTtsSessionActive,
onOpenExternalDictionary = { text ->
if (selectedDictPackage != null) {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
@ -3707,10 +3765,20 @@ fun EpubReaderHost(
useOnlineDictionary = newState
saveUseOnlineDict(context, newState)
},
selectedPackageName = selectedDictPackage,
onSelectPackage = { pkg ->
selectedDictionaryPackageName = selectedDictPackage,
onSelectDictionaryPackage = { pkg ->
selectedDictPackage = pkg
saveExternalDictPackage(context, pkg)
},
selectedTranslatePackageName = selectedTranslatePackage,
onSelectTranslatePackage = { pkg ->
selectedTranslatePackage = pkg
saveExternalTranslatePackage(context, pkg)
},
selectedSearchPackageName = selectedSearchPackage,
onSelectSearchPackage = { pkg ->
selectedSearchPackage = pkg
saveExternalSearchPackage(context, pkg)
}
)
}

View file

@ -6,9 +6,11 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.widget.Toast
import timber.log.Timber
import androidx.core.net.toUri
data class ExternalDictionaryApp(
val label: String,
@ -23,7 +25,6 @@ object ExternalDictionaryHelper {
"com.samsung.android.samsungpassautofill",
"com.samsung.android.samsungpass",
"com.samsung.android.app.pass",
"com.google.android.gms",
"com.truecaller",
"com.adobe.reader",
"com.reddit.frontpage"
@ -77,18 +78,15 @@ object ExternalDictionaryHelper {
)
)
return sortedApps
return apps.sortedBy { it.label }
}
fun launchDictionary(context: Context, packageName: String, query: String) {
if (packageName.isEmpty()) return
val pm = context.packageManager
try {
if (packageName == GOOGLE_SEARCH_PKG) {
val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, query)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(searchIntent)
launchSearch(context, packageName, query)
return
}
@ -133,6 +131,89 @@ object ExternalDictionaryHelper {
}
}
fun launchTranslate(context: Context, packageName: String, query: String) {
if (packageName.isEmpty()) return
val pm = context.packageManager
try {
if (packageName == GOOGLE_SEARCH_PKG) {
launchSearch(context, packageName, query)
return
}
// Google Translate specific intent
if (packageName == "com.google.android.apps.translate") {
val translateIntent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (translateIntent.resolveActivity(pm) != null) {
context.startActivity(translateIntent)
return
}
}
// Generic text processing intent
val processTextIntent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (processTextIntent.resolveActivity(pm) != null) {
context.startActivity(processTextIntent)
return
}
launchGenericSend(context, packageName, query)
} catch (e: Exception) {
Timber.e(e, "Failed to launch translate app: $packageName")
Toast.makeText(context, "Error opening translate app", Toast.LENGTH_SHORT).show()
}
}
fun launchSearch(context: Context, packageName: String, query: String) {
try {
if (packageName == GOOGLE_SEARCH_PKG) {
val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, query)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(searchIntent)
return
}
val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, query)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (searchIntent.resolveActivity(context.packageManager) != null) {
context.startActivity(searchIntent)
return
}
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
data = "https://www.google.com/search?q=${Uri.encode(query)}".toUri()
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (viewIntent.resolveActivity(context.packageManager) != null) {
context.startActivity(viewIntent)
return
}
launchGenericSend(context, packageName, query)
} catch (e: Exception) {
Timber.e(e, "Failed to launch search app: $packageName")
Toast.makeText(context, "Error opening search app", Toast.LENGTH_SHORT).show()
}
}
private fun launchGenericSend(context: Context, packageName: String, query: String) {
val sendIntent = Intent(Intent.ACTION_SEND)
sendIntent.type = "text/plain"
@ -151,4 +232,52 @@ object ExternalDictionaryHelper {
}
}
}
fun getAvailableSearchApps(context: Context): List<ExternalDictionaryApp> {
val pm = context.packageManager
val apps = mutableListOf<ExternalDictionaryApp>()
val addedPackages = mutableSetOf<String>()
val webSearchIntent = Intent(Intent.ACTION_WEB_SEARCH)
val searchResolvers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.queryIntentActivities(webSearchIntent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION") pm.queryIntentActivities(webSearchIntent, 0)
}
searchResolvers.forEach { ri ->
val pkg = ri.activityInfo.packageName
if (!PACKAGE_BLOCKLIST.contains(pkg) && addedPackages.add(pkg)) {
apps.add(
ExternalDictionaryApp(
label = ri.loadLabel(pm).toString(),
packageName = pkg,
icon = ri.loadIcon(pm)
)
)
}
}
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri()).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
}
val browserResolvers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.queryIntentActivities(browserIntent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION") pm.queryIntentActivities(browserIntent, 0)
}
browserResolvers.forEach { ri ->
val pkg = ri.activityInfo.packageName
if (!PACKAGE_BLOCKLIST.contains(pkg) && addedPackages.add(pkg)) {
apps.add(
ExternalDictionaryApp(
label = ri.loadLabel(pm).toString(),
packageName = pkg,
icon = ri.loadIcon(pm)
)
)
}
}
return apps.sortedBy { it.label }
}
}