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

@ -18,7 +18,7 @@
## Overview ## Overview
Episteme Reader is an offline-first application designed for reading various document formats. It leverages native Android technologies and C++ libraries to provide a performant reading experience with customization capabilities. Episteme Reader is an offline-first, privacy-focused document and e-book reader that supports PDF, EPUB, MOBI, AZW3, Markdown, HTML, and plain text.
> **Note:** This is the Open Source (OSS) edition of Episteme Reader. The version available on the Google Play Store is built from this core but includes additional proprietary features. > **Note:** This is the Open Source (OSS) edition of Episteme Reader. The version available on the Google Play Store is built from this core but includes additional proprietary features.
@ -61,10 +61,14 @@ Episteme Reader is an offline-first application designed for reading various doc
``` ```
2. **Build:** 2. **Build:**
Open in Android Studio and run the `ossDebug` variant. Open in Android Studio and run the `ossDebug` variant, or build from the command line:
```bash ```bash
./gradlew assembleOssDebug ./gradlew assembleOssDebug
``` ```
The APK will be generated at:
```
app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk
```
## Open Source Libraries ## Open Source Libraries

View file

@ -17,12 +17,19 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.TooltipDefaults
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -105,6 +112,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
@ -242,6 +250,90 @@ fun rememberSearchState(
} }
} }
private val activeTooltipState = mutableStateOf<androidx.compose.material3.TooltipState?>(null)
@Composable
fun TooltipIconButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
description: String? = null,
content: @Composable () -> Unit
) {
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
LaunchedEffect(tooltipState.isVisible) {
if (tooltipState.isVisible) {
val previous = activeTooltipState.value
if (previous != null && previous !== tooltipState) {
previous.dismiss()
}
activeTooltipState.value = tooltipState
} else {
if (activeTooltipState.value === tooltipState) {
activeTooltipState.value = null
}
}
}
TooltipBox(
positionProvider = if (description != null)
TooltipDefaults.rememberRichTooltipPositionProvider()
else
TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
if (description != null) {
RichTooltip(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
content()
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
},
colors = TooltipDefaults.richTooltipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall
)
}
} else {
PlainTooltip {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
content()
Text(text)
}
}
}
},
state = tooltipState
) {
IconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
content()
}
}
}
@Composable @Composable
fun SearchTopBar( fun SearchTopBar(
searchState: SearchState, searchState: SearchState,
@ -265,7 +357,11 @@ fun SearchTopBar(
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onCloseSearch) { TooltipIconButton(
text = stringResource(R.string.tooltip_close_search),
description = stringResource(R.string.tooltip_close_search_desc),
onClick = onCloseSearch
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close Search" contentDescription = "Close Search"
@ -297,7 +393,11 @@ fun SearchTopBar(
) )
if (searchState.searchQuery.isNotEmpty()) { if (searchState.searchQuery.isNotEmpty()) {
IconButton(onClick = { searchState.onQueryChange("") }) { TooltipIconButton(
text = stringResource(R.string.tooltip_clear_search),
description = stringResource(R.string.tooltip_clear_search_desc),
onClick = { searchState.onQueryChange("") }
) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
contentDescription = "Clear Search" contentDescription = "Clear Search"
@ -305,10 +405,20 @@ fun SearchTopBar(
} }
} }
IconButton(onClick = { TooltipIconButton(
searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel text = if (searchState.showSearchResultsPanel)
focusManager.clearFocus() stringResource(R.string.tooltip_hide_results)
}) { else
stringResource(R.string.tooltip_show_results),
description = if (searchState.showSearchResultsPanel)
stringResource(R.string.tooltip_hide_results_desc)
else
stringResource(R.string.tooltip_show_results_desc),
onClick = {
searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel
focusManager.clearFocus()
}
) {
Icon( Icon(
imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown,
contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results" contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results"
@ -333,7 +443,9 @@ fun SearchNavigationControls(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)
) { ) {
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_prev_result),
description = stringResource(R.string.tooltip_prev_result_desc),
onClick = { onNavigate(searchState.currentSearchResultIndex - 1) }, onClick = { onNavigate(searchState.currentSearchResultIndex - 1) },
enabled = searchState.currentSearchResultIndex > 0 enabled = searchState.currentSearchResultIndex > 0
) { ) {
@ -346,7 +458,9 @@ fun SearchNavigationControls(
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)
) )
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_next_result),
description = stringResource(R.string.tooltip_next_result_desc),
onClick = { onNavigate(searchState.currentSearchResultIndex + 1) }, onClick = { onNavigate(searchState.currentSearchResultIndex + 1) },
enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1 enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1
) { ) {
@ -1020,7 +1134,6 @@ suspend fun fetchRecap(
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TtsSettingsSheet( fun TtsSettingsSheet(
isVisible: Boolean, isVisible: Boolean,

View file

@ -471,7 +471,7 @@ fun LibraryScreenContent(
val isBookContextualModeActive = selectedItems.isNotEmpty() val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
val tabTitles = listOf("All Books", "Shelves", "Folder") val tabTitles = listOf("All Books", "Shelves", "Folders")
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
LaunchedEffect(isSearchActive) { LaunchedEffect(isSearchActive) {

View file

@ -36,7 +36,6 @@ import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -85,7 +85,6 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.countWords
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
@ -333,6 +332,8 @@ fun ChapterWebView(
isOss: Boolean = false, isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit, onContentReadyForSummarization: suspend (String) -> Unit,
currentFontFamily: ReaderFont, currentFontFamily: ReaderFont,
customFontPath: String? = null, 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() HorizontalDivider()
// 2. Copy Option
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .padding(horizontal = 8.dp, vertical = 8.dp),
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager horizontalArrangement = Arrangement.SpaceEvenly,
val clip = ClipData.newPlainText("Copied Text", state.selectedText) verticalAlignment = Alignment.CenterVertically
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)
) { ) {
Icon( IconButton(onClick = {
imageVector = Icons.Default.CopyAll, val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
contentDescription = null, val clip = ClipData.newPlainText("Copied Text", state.selectedText)
tint = MaterialTheme.colorScheme.onSurface, clipboard.setPrimaryClip(clip)
modifier = Modifier.size(20.dp) state.finishActionModeCallback()
) localWebViewRef?.clearFocus()
Text( localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
text = "Copy", customMenuState = null
style = MaterialTheme.typography.bodyMedium, }) {
color = MaterialTheme.colorScheme.onSurface Icon(Icons.Default.CopyAll, contentDescription = "Copy")
) }
}
// 3. Dictionary Option (Preserving Logic) if (state.selectedText.length <= 2000) {
if (state.selectedText.length <= 2000) { IconButton(onClick = {
HorizontalDivider() val textToDefine = state.selectedText
Row( if (textToDefine.isNotBlank()) {
modifier = Modifier onWordSelectedForAiDefinition(textToDefine)
.fillMaxWidth()
.clickable {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
onWordSelectedForAiDefinition(textToDefine)
}
customMenuState = null
} }
.padding(horizontal = 16.dp, vertical = 12.dp), customMenuState = null
verticalAlignment = Alignment.CenterVertically, }) {
horizontalArrangement = Arrangement.spacedBy(12.dp) Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary")
) { }
Icon( IconButton(onClick = {
painter = painterResource(id = R.drawable.dictionary), val textToDefine = state.selectedText
contentDescription = null, if (textToDefine.isNotBlank()) {
tint = MaterialTheme.colorScheme.onSurface, onTranslate(textToDefine)
modifier = Modifier.size(20.dp) }
) customMenuState = null
Text( }) {
text = "Dictionary", Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate")
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onSurface 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 package com.aryan.reader.epubreader
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,19 +34,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.R
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DictionarySettingsDialog( fun DictionarySettingsDialog(
isVisible: Boolean, isVisible: Boolean,
@ -55,244 +51,292 @@ fun DictionarySettingsDialog(
isProUser: Boolean, isProUser: Boolean,
useOnlineDictionary: Boolean, useOnlineDictionary: Boolean,
onToggleOnlineDictionary: (Boolean) -> Unit, onToggleOnlineDictionary: (Boolean) -> Unit,
selectedPackageName: String?, selectedDictionaryPackageName: String?,
onSelectPackage: (String) -> Unit onSelectDictionaryPackage: (String) -> Unit,
selectedTranslatePackageName: String?,
onSelectTranslatePackage: (String) -> Unit,
selectedSearchPackageName: String?,
onSelectSearchPackage: (String) -> Unit
) { ) {
if (!isVisible) return if (!isVisible) return
val context = LocalContext.current 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) { LaunchedEffect(Unit) {
availableApps = ExternalDictionaryHelper.getAvailableDictionaries(context) dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
} }
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp) .verticalScroll(rememberScrollState())
.padding(24.dp)
) { ) {
// Header
Text( Text(
text = "Dictionary Settings", text = "Lookup Settings",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 20.dp)
) )
// ── Dictionary ──
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Surface(
Surface( shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, modifier = Modifier.fillMaxWidth()
border = BorderStroke( ) {
1.dp, Column(modifier = Modifier.padding(16.dp)) {
if (useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant Text(
), text = "Dictionary Engine",
modifier = Modifier style = MaterialTheme.typography.labelLarge,
.fillMaxWidth() color = MaterialTheme.colorScheme.primary,
.clickable { onToggleOnlineDictionary(true) } modifier = Modifier.padding(bottom = 8.dp)
) { )
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
)
}
}
}
// External App Option SingleChoiceSegmentedButtonRow(
Surface( modifier = Modifier
shape = RoundedCornerShape(12.dp), .fillMaxWidth()
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, .padding(bottom = 8.dp)
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
) { ) {
Icon( SegmentedButton(
painter = painterResource(id = R.drawable.dictionary), selected = useOnlineDictionary,
contentDescription = null, onClick = { onToggleOnlineDictionary(true) },
tint = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
modifier = Modifier.size(24.dp) ) {
) Text("Smart (AI)")
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
)
} }
if (!useOnlineDictionary) { SegmentedButton(
Icon( selected = !useOnlineDictionary,
imageVector = Icons.Default.Check, onClick = { onToggleOnlineDictionary(false) },
contentDescription = "Selected", shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
tint = MaterialTheme.colorScheme.primary ) {
) 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"
)
} }
} }
} else {
Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = if (useOnlineDictionary) "Fallback External App (Used when offline)" else "Select External App", text = "Dictionary",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
AppSelectionDropdown(
} else { apps = dictionaryApps,
// OSS FLAVOR UI (Dedicated to external apps) selectedPackageName = selectedDictionaryPackageName,
Surface( onSelect = onSelectDictionaryPackage,
color = MaterialTheme.colorScheme.secondaryContainer, placeholder = "Select an app"
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
)
}
}
} }
// App List SectionDivider()
if (availableApps.isEmpty()) {
Box( // ── Translate ──
modifier = Modifier Text(
.fillMaxWidth() text = "Translate",
.padding(24.dp), style = MaterialTheme.typography.labelLarge,
contentAlignment = Alignment.Center color = MaterialTheme.colorScheme.primary,
) { modifier = Modifier.padding(bottom = 4.dp)
Text( )
"No supported dictionary apps found.", Text(
style = MaterialTheme.typography.bodyMedium, text = "App used for translating selected text.",
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center 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 { } else null,
LazyColumn( onClick = {
modifier = Modifier onSelect("")
.fillMaxWidth() expanded = false
.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))
}
}
} }
)
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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.RenderMode
import com.aryan.reader.SearchState import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
@ -182,7 +184,11 @@ fun EpubReaderTopBar(
onCloseSearch = onCloseSearch onCloseSearch = onCloseSearch
) )
} else { } 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") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@ -193,7 +199,11 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = onOpenDictionarySettings) { TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onOpenDictionarySettings
) {
Icon( Icon(
painter = painterResource(id = R.drawable.dictionary), painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings" contentDescription = "Dictionary Settings"
@ -201,7 +211,11 @@ fun EpubReaderTopBar(
} }
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } 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") Icon(Icons.Default.MoreVert, contentDescription = "More Options")
} }
@ -392,19 +406,33 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceAround
) { ) {
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider, onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) { ) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider") 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") 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") 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") Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
} }
@ -412,7 +440,11 @@ fun EpubReaderBottomBar(
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss") {
Box { Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) } 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") Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
} }
DropdownMenu( DropdownMenu(
@ -441,14 +473,32 @@ fun EpubReaderBottomBar(
} }
Box { Box {
Row(verticalAlignment = Alignment.CenterVertically) { 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( Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), 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) "Stop TTS" else "Start TTS"
) )
} }
if (isTtsSessionActive) { 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, onClick = onPlayPauseTts,
enabled = !ttsState.isLoading 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_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" 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 { private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false @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) } 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) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
fun EpubReaderScreen( fun EpubReaderScreen(
@ -573,6 +595,12 @@ fun EpubReaderHost(
var selectedDictPackage by remember { var selectedDictPackage by remember {
mutableStateOf(loadExternalDictPackage(context)) mutableStateOf(loadExternalDictPackage(context))
} }
var selectedTranslatePackage by remember {
mutableStateOf(loadExternalTranslatePackage(context))
}
var selectedSearchPackage by remember {
mutableStateOf(loadExternalSearchPackage(context))
}
var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -605,7 +633,7 @@ fun EpubReaderHost(
showDictionaryUpsellDialog = true showDictionaryUpsellDialog = true
} }
} else { } else {
if (selectedDictPackage != null) { if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word) ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word)
} else { } else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show() 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) } val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) } var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(null) } var recapResult by remember { mutableStateOf<SummarizationResult?>(null) }
@ -2256,6 +2302,12 @@ fun EpubReaderHost(
onWordSelectedForAiDefinition = { text -> onWordSelectedForAiDefinition = { text ->
onDictionaryLookup(text) onDictionaryLookup(text)
}, },
onTranslate = { text ->
onTranslateLookup(text)
},
onSearch = { text ->
onSearchLookup(text)
},
onContentReadyForSummarization = { content -> onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization") Timber.d("Content received for summarization")
scope.launch { scope.launch {
@ -2591,6 +2643,12 @@ fun EpubReaderHost(
onWordSelectedForAiDefinition = { text -> onWordSelectedForAiDefinition = { text ->
onDictionaryLookup(text) onDictionaryLookup(text)
}, },
onTranslate = { text ->
onTranslateLookup(text)
},
onSearch = { text ->
onSearchLookup(text)
},
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) }, userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
onHighlightCreated = { cfi, text, colorId -> onHighlightCreated = { cfi, text, colorId ->
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
@ -3532,7 +3590,7 @@ fun EpubReaderHost(
onNavigateToPro = onNavigateToPro, onNavigateToPro = onNavigateToPro,
isTtsSessionActive = isTtsSessionActive, isTtsSessionActive = isTtsSessionActive,
onOpenExternalDictionary = { text -> onOpenExternalDictionary = { text ->
if (selectedDictPackage != null) { if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else { } else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
@ -3707,10 +3765,20 @@ fun EpubReaderHost(
useOnlineDictionary = newState useOnlineDictionary = newState
saveUseOnlineDict(context, newState) saveUseOnlineDict(context, newState)
}, },
selectedPackageName = selectedDictPackage, selectedDictionaryPackageName = selectedDictPackage,
onSelectPackage = { pkg -> onSelectDictionaryPackage = { pkg ->
selectedDictPackage = pkg selectedDictPackage = pkg
saveExternalDictPackage(context, 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.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import timber.log.Timber import timber.log.Timber
import androidx.core.net.toUri
data class ExternalDictionaryApp( data class ExternalDictionaryApp(
val label: String, val label: String,
@ -23,7 +25,6 @@ object ExternalDictionaryHelper {
"com.samsung.android.samsungpassautofill", "com.samsung.android.samsungpassautofill",
"com.samsung.android.samsungpass", "com.samsung.android.samsungpass",
"com.samsung.android.app.pass", "com.samsung.android.app.pass",
"com.google.android.gms",
"com.truecaller", "com.truecaller",
"com.adobe.reader", "com.adobe.reader",
"com.reddit.frontpage" "com.reddit.frontpage"
@ -77,18 +78,15 @@ object ExternalDictionaryHelper {
) )
) )
return sortedApps return apps.sortedBy { it.label }
} }
fun launchDictionary(context: Context, packageName: String, query: String) { fun launchDictionary(context: Context, packageName: String, query: String) {
if (packageName.isEmpty()) return
val pm = context.packageManager val pm = context.packageManager
try { try {
if (packageName == GOOGLE_SEARCH_PKG) { if (packageName == GOOGLE_SEARCH_PKG) {
val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply { launchSearch(context, packageName, query)
putExtra(SearchManager.QUERY, query)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(searchIntent)
return 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) { private fun launchGenericSend(context: Context, packageName: String, query: String) {
val sendIntent = Intent(Intent.ACTION_SEND) val sendIntent = Intent(Intent.ACTION_SEND)
sendIntent.type = "text/plain" 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 }
}
} }

View file

@ -69,6 +69,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -505,6 +506,8 @@ fun PaginatedReaderScreen(
isOss: Boolean = false, isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
@ -802,6 +805,8 @@ fun PaginatedReaderScreen(
isOss = isOss, isOss = isOss,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog, onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition, onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
onTranslate = onTranslate,
onSearch = onSearch,
userHighlights = userHighlights, userHighlights = userHighlights,
onHighlightCreated = onHighlightCreated, onHighlightCreated = onHighlightCreated,
onHighlightDeleted = onHighlightDeleted, onHighlightDeleted = onHighlightDeleted,
@ -1372,6 +1377,8 @@ internal fun PaginatedReaderContent(
isOss: Boolean, isOss: Boolean,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onGetChapterInfo: (Int) -> Pair<String, Int?>?, onGetChapterInfo: (Int) -> Pair<String, Int?>?,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
@ -2531,6 +2538,14 @@ internal fun PaginatedReaderContent(
state.onCopy() state.onCopy()
isForDictionary = false isForDictionary = false
state.onHide() state.onHide()
}, onTranslate = {
state.onCopy() // we don't necessarily need copy to get text, but follow dictionary pattern if needed, wait menuState has selectedText!
// Actually PaginatedMenuState has `selectedText`? Let's check.
onTranslate(capturedTextForAction ?: "")
state.onHide()
}, onSearch = {
onSearch(capturedTextForAction ?: "")
state.onHide()
}, onHighlight = { color -> }, onHighlight = { color ->
Timber.d("Menu: Highlight option clicked. Color: ${color.id}") Timber.d("Menu: Highlight option clicked. Color: ${color.id}")
isForHighlight = true isForHighlight = true
@ -2768,6 +2783,12 @@ internal fun PaginatedReaderContent(
onShowDictionaryUpsellDialog() onShowDictionaryUpsellDialog()
} }
activeSelection = null activeSelection = null
}, onTranslate = {
onTranslate(sel.text)
activeSelection = null
}, onSearch = {
onSearch(sel.text)
activeSelection = null
}, onHighlight = { color -> }, onHighlight = { color ->
Timber.d( Timber.d(
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
@ -2834,6 +2855,14 @@ internal fun PaginatedReaderContent(
} }
activeHighlightForMenu = null activeHighlightForMenu = null
}, },
onTranslate = {
onTranslate(highlight.text)
activeHighlightForMenu = null
},
onSearch = {
onSearch(highlight.text)
activeHighlightForMenu = null
},
onHighlight = { color -> onHighlight = { color ->
Timber.d("Menu: Updating highlight color to ${color.id}") Timber.d("Menu: Updating highlight color to ${color.id}")
onHighlightDeleted(highlight.cfi) onHighlightDeleted(highlight.cfi)
@ -2911,6 +2940,8 @@ private fun PaginatedTextSelectionMenu(
onCopy: () -> Unit, onCopy: () -> Unit,
onSelectAll: (() -> Unit)?, onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit, onDictionary: () -> Unit,
onTranslate: () -> Unit,
onSearch: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?, onHighlight: ((HighlightColor) -> Unit)?,
onDelete: (() -> Unit)?, onDelete: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean, @Suppress("unused") isProUser: Boolean,
@ -2955,99 +2986,71 @@ private fun PaginatedTextSelectionMenu(
HorizontalDivider() HorizontalDivider()
} }
// 2. Delete Option // 2. Action Icons Row (Horizontal)
if (onDelete != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onDelete)
.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(
"Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
}
// 3. Copy Option
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onCopy) .padding(horizontal = 8.dp, vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( IconButton(onClick = onCopy) {
painter = painterResource(id = R.drawable.copy),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
"Copy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 4. Select All Option
if (onSelectAll != null) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSelectAll)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon( Icon(
painter = painterResource(id = R.drawable.select_all), painter = painterResource(id = R.drawable.copy),
contentDescription = null, contentDescription = "Copy",
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp) modifier = Modifier.size(24.dp)
)
Text(
"Select All",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
} }
}
// 5. Dictionary Option IconButton(onClick = onDictionary) {
HorizontalDivider() Icon(
Row( painter = painterResource(id = R.drawable.dictionary),
modifier = Modifier contentDescription = "Dictionary",
.fillMaxWidth() tint = MaterialTheme.colorScheme.onSurface,
.clickable(onClick = onDictionary) modifier = Modifier.size(24.dp)
.padding(horizontal = 16.dp, vertical = 12.dp), )
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { IconButton(onClick = onTranslate) {
Icon( Icon(
painter = painterResource(id = R.drawable.dictionary), painter = painterResource(id = R.drawable.translate),
contentDescription = null, contentDescription = "Translate",
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp) modifier = Modifier.size(24.dp)
) )
Text( }
"Dictionary",
style = MaterialTheme.typography.bodyMedium, IconButton(onClick = onSearch) {
color = MaterialTheme.colorScheme.onSurface Icon(
) painter = painterResource(id = R.drawable.search),
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
if (onSelectAll != null) {
IconButton(onClick = onSelectAll) {
Icon(
painter = painterResource(id = R.drawable.select_all),
contentDescription = "Select All",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
)
}
}
} }
} }
} }

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -45,6 +46,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -188,6 +190,8 @@ internal fun PdfSelectionMenuPopup(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCopy: (String) -> Unit, onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit, onAiDefine: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onColorSelected: (PdfHighlightColor) -> Unit, onColorSelected: (PdfHighlightColor) -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
@ -281,51 +285,74 @@ internal fun PdfSelectionMenuPopup(
} }
HorizontalDivider() HorizontalDivider()
} }
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.height(48.dp) // Fixed height for the sleek row
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) { ) {
Box( // Copy
modifier = Modifier.weight(1f) androidx.compose.material3.IconButton(
.clickable { onCopy(menuState.selectedText) }.padding(vertical = 12.dp), onClick = { onCopy(menuState.selectedText) }
contentAlignment = Alignment.Center) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(
Icon( Icons.Default.CopyAll,
Icons.Default.CopyAll, contentDescription = "Copy",
contentDescription = null, modifier = Modifier.size(24.dp)
modifier = Modifier.size(20.dp) )
)
Text("Copy", style = MaterialTheme.typography.labelSmall)
}
} }
// Dictionary
if (menuState.selectedText.length <= 2000) { if (menuState.selectedText.length <= 2000) {
Box( androidx.compose.material3.IconButton(
modifier = Modifier.weight(1f) onClick = { onAiDefine(menuState.selectedText) }
.clickable { onAiDefine(menuState.selectedText) } ) {
.padding(vertical = 12.dp), contentAlignment = Alignment.Center) { Icon(
Column(horizontalAlignment = Alignment.CenterHorizontally) { painter = painterResource(id = R.drawable.dictionary),
Icon( contentDescription = "Dictionary",
painter = painterResource(id = R.drawable.dictionary), modifier = Modifier.size(24.dp)
contentDescription = null, )
modifier = Modifier.size(20.dp)
)
Text("Dictionary", style = MaterialTheme.typography.labelSmall)
}
} }
} }
// Translate
if (menuState.selectedText.length <= 2000) {
androidx.compose.material3.IconButton(
onClick = { onTranslate(menuState.selectedText) }
) {
Icon(
painter = painterResource(id = R.drawable.translate),
contentDescription = "Translate",
modifier = Modifier.size(24.dp)
)
}
}
// Search
if (menuState.selectedText.length <= 2000) {
androidx.compose.material3.IconButton(
onClick = { onSearch(menuState.selectedText) }
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(24.dp)
)
}
}
// Select All
if (!menuState.isExistingHighlight) { if (!menuState.isExistingHighlight) {
Box(modifier = Modifier.weight(1f).clickable { onSelectAll() } androidx.compose.material3.IconButton(
.padding(vertical = 12.dp), contentAlignment = Alignment.Center) { onClick = { onSelectAll() }
Column(horizontalAlignment = Alignment.CenterHorizontally) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.select_all), painter = painterResource(id = R.drawable.select_all),
contentDescription = null, contentDescription = "Select All",
modifier = Modifier.size(20.dp) modifier = Modifier.size(24.dp)
) )
Text("Select All", style = MaterialTheme.typography.labelSmall)
}
} }
} }
} }

View file

@ -389,6 +389,8 @@ internal fun PdfPageComposable(
isProUser: Boolean, isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslateText: (String) -> Unit,
onSearchText: (String) -> Unit,
ttsHighlightData: TtsHighlightData?, ttsHighlightData: TtsHighlightData?,
onLinkClicked: (String) -> Unit, onLinkClicked: (String) -> Unit,
onInternalLinkClicked: (Int) -> Unit, onInternalLinkClicked: (Int) -> Unit,
@ -3685,6 +3687,36 @@ internal fun PdfPageComposable(
) )
} }
}, },
onTranslate = { textToTranslate ->
onTranslateText(textToTranslate.trim())
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
},
onSearch = { textToSearch ->
onSearchText(textToSearch.trim())
customMenuState = null
selectionCharRange.value = null
coroutineScope.launch {
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
null,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation
)
}
},
onSelectAll = { onSelectAll = {
customMenuState = null customMenuState = null
coroutineScope.launch { coroutineScope.launch {
@ -4523,6 +4555,8 @@ private fun PdfPageRenderer(
onMenuDismiss: () -> Unit, onMenuDismiss: () -> Unit,
onCopy: (String) -> Unit, onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit, onAiDefine: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onShowUpsellDialog: () -> Unit, onShowUpsellDialog: () -> Unit,
isProUser: Boolean, isProUser: Boolean,
@ -4934,6 +4968,8 @@ private fun PdfPageRenderer(
onDismiss = onMenuDismiss, onDismiss = onMenuDismiss,
onCopy = onCopy, onCopy = onCopy,
onAiDefine = onAiDefine, onAiDefine = onAiDefine,
onTranslate = onTranslate,
onSearch = onSearch,
onSelectAll = onSelectAll, onSelectAll = onSelectAll,
onColorSelected = { color -> onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}") Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}")

View file

@ -193,6 +193,8 @@ internal fun PdfVerticalReader(
isProUser: Boolean, isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslateText: (String) -> Unit,
onSearchText: (String) -> Unit,
ttsHighlightData: TtsHighlightData?, ttsHighlightData: TtsHighlightData?,
ttsReadingPage: Int?, ttsReadingPage: Int?,
onLinkClicked: (String) -> Unit, onLinkClicked: (String) -> Unit,
@ -1332,6 +1334,14 @@ internal fun PdfVerticalReader(
} }
} }
val onTranslateTextLambda = remember(onTranslateText) {
{ text: String -> onTranslateText(text) }
}
val onSearchTextLambda = remember(onSearchText) {
{ text: String -> onSearchText(text) }
}
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) { val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
{ localOffset: Offset -> { localOffset: Offset ->
Timber.tag("PdfZoomDebug").d( Timber.tag("PdfZoomDebug").d(
@ -1456,6 +1466,8 @@ internal fun PdfVerticalReader(
isProUser = isProUser, isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog, onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition, onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
onTranslateText = onTranslateTextLambda,
onSearchText = onSearchTextLambda,
ttsHighlightData = pageTtsData, ttsHighlightData = pageTtsData,
onLinkClicked = onLinkClicked, onLinkClicked = onLinkClicked,
onInternalLinkClicked = onInternalLinkClicked, onInternalLinkClicked = onInternalLinkClicked,

View file

@ -201,6 +201,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -244,6 +245,7 @@ import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationPopup
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords import com.aryan.reader.countWords
@ -322,6 +324,8 @@ private const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled" private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary" 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_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 { private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
@ -344,6 +348,26 @@ private fun saveExternalDictPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) } prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
} }
private fun loadExternalTranslatePackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
}
private fun saveExternalTranslatePackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
}
private fun loadExternalSearchPackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
}
private fun saveExternalSearchPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
private fun savePdfMusicianMode(context: Context, isEnabled: Boolean) { private fun savePdfMusicianMode(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) } prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) }
@ -780,6 +804,8 @@ fun PdfViewerScreen(
var showDictionarySettingsSheet by remember { mutableStateOf(false) } var showDictionarySettingsSheet by remember { mutableStateOf(false) }
var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(context)) } var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(context)) }
var selectedDictPackage by remember { mutableStateOf(loadExternalDictPackage(context)) } var selectedDictPackage by remember { mutableStateOf(loadExternalDictPackage(context)) }
var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) }
var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
@ -1127,7 +1153,7 @@ fun PdfViewerScreen(
withContext(NonCancellable) { withContext(NonCancellable) {
saveMutex.withLock { saveMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var didSave = false @Suppress("VariableNeverRead") var didSave = false
if (force || annotsHash != lastSavedHashes[0]) { if (force || annotsHash != lastSavedHashes[0]) {
annotationRepository.saveAnnotations(bookId, annots) annotationRepository.saveAnnotations(bookId, annots)
@ -2131,7 +2157,7 @@ fun PdfViewerScreen(
showDictionaryUpsellDialog = true showDictionaryUpsellDialog = true
} }
} else { } else {
if (selectedDictPackage != null) { if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else { } else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show()
@ -2142,6 +2168,28 @@ fun PdfViewerScreen(
} }
} }
val onTranslateTextStable = remember(selectedTranslatePackage) {
{ 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 onSearchTextStable = remember(selectedSearchPackage) {
{ 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 onLinkClickedStable = remember { { url: String -> clickedLinkUrl = url } } val onLinkClickedStable = remember { { url: String -> clickedLinkUrl = url } }
val onInternalLinkNavStable = remember(displayMode) { val onInternalLinkNavStable = remember(displayMode) {
@ -3874,6 +3922,8 @@ fun PdfViewerScreen(
} }
}, },
onWordSelectedForAiDefinition = onDictionaryLookupStable, onWordSelectedForAiDefinition = onDictionaryLookupStable,
onTranslateText = onTranslateTextStable,
onSearchText = onSearchTextStable,
onOcrStateChange = onOcrStateChange, onOcrStateChange = onOcrStateChange,
onLinkClicked = { url -> clickedLinkUrl = url }, onLinkClicked = { url -> clickedLinkUrl = url },
onInternalLinkClicked = onInternalLinkNav, onInternalLinkClicked = onInternalLinkNav,
@ -4244,6 +4294,8 @@ fun PdfViewerScreen(
isProUser = isProUser, isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialogStable, onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialogStable,
onWordSelectedForAiDefinition = onDictionaryLookupStable, onWordSelectedForAiDefinition = onDictionaryLookupStable,
onTranslateText = onTranslateTextStable,
onSearchText = onSearchTextStable,
ttsHighlightData = ttsHighlightData, ttsHighlightData = ttsHighlightData,
ttsReadingPage = ttsPageData?.pageIndex, ttsReadingPage = ttsPageData?.pageIndex,
userHighlights = userHighlights, userHighlights = userHighlights,
@ -4774,7 +4826,11 @@ fun PdfViewerScreen(
focusManager.clearFocus() focusManager.clearFocus()
}) })
} else { } else {
IconButton(onClick = { saveStateAndExit() }) { TooltipIconButton(
text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc),
onClick = { saveStateAndExit() }
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back" contentDescription = "Back"
@ -4804,7 +4860,17 @@ fun PdfViewerScreen(
.testTag("PageNumberIndicator") .testTag("PageNumberIndicator")
) )
IconButton(onClick = { isPdfDarkMode = !isPdfDarkMode }) { TooltipIconButton(
text = if (isPdfDarkMode)
stringResource(R.string.tooltip_dark_mode_off)
else
stringResource(R.string.tooltip_dark_mode_on),
description = if (isPdfDarkMode)
stringResource(R.string.tooltip_dark_mode_off_desc)
else
stringResource(R.string.tooltip_dark_mode_on_desc),
onClick = { isPdfDarkMode = !isPdfDarkMode }
) {
Icon( Icon(
painter = painterResource(id = R.drawable.dark_mode), painter = painterResource(id = R.drawable.dark_mode),
contentDescription = if (isPdfDarkMode) "Disable Dark Mode" contentDescription = if (isPdfDarkMode) "Disable Dark Mode"
@ -4814,10 +4880,20 @@ fun PdfViewerScreen(
) )
} }
IconButton(onClick = { TooltipIconButton(
isScrollLocked = !isScrollLocked text = if (isScrollLocked)
savePdfScrollLocked(context, bookId, isScrollLocked) stringResource(R.string.tooltip_unlock_pan)
}) { else
stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked)
stringResource(R.string.tooltip_unlock_pan_desc)
else
stringResource(R.string.tooltip_lock_pan_desc),
onClick = {
isScrollLocked = !isScrollLocked
savePdfScrollLocked(context, bookId, isScrollLocked)
}
) {
Icon( Icon(
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning",
@ -4825,10 +4901,14 @@ fun PdfViewerScreen(
) )
} }
IconButton(onClick = { TooltipIconButton(
isFullScreen = true text = stringResource(R.string.tooltip_fullscreen),
savePdfFullScreen(context, bookId, true) description = stringResource(R.string.tooltip_fullscreen_desc),
}) { onClick = {
isFullScreen = true
savePdfFullScreen(context, bookId, true)
}
) {
Icon( Icon(
imageVector = Icons.Default.Fullscreen, imageVector = Icons.Default.Fullscreen,
contentDescription = "Enter Full Screen", contentDescription = "Enter Full Screen",
@ -4836,7 +4916,11 @@ fun PdfViewerScreen(
) )
} }
IconButton(onClick = { showDictionarySettingsSheet = true }) { TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = { showDictionarySettingsSheet = true }
) {
Icon( Icon(
painter = painterResource(id = R.drawable.dictionary), painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings", contentDescription = "Dictionary Settings",
@ -4845,7 +4929,7 @@ fun PdfViewerScreen(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
IconButton(onClick = { showPenPlayground = true }) { TooltipIconButton(text = "Pen Playground", onClick = { showPenPlayground = true }) {
Icon( Icon(
imageVector = Icons.Default.Star, imageVector = Icons.Default.Star,
contentDescription = "Open Pen Playground", contentDescription = "Open Pen Playground",
@ -4853,7 +4937,7 @@ fun PdfViewerScreen(
) )
} }
IconButton(onClick = { TooltipIconButton(text = "Import SVG", onClick = {
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
@ -4890,7 +4974,11 @@ fun PdfViewerScreen(
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } 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( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = "More Options" contentDescription = "More Options"
@ -5350,7 +5438,9 @@ fun PdfViewerScreen(
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceAround
) { ) {
// Slider Navigation Trigger // Slider Navigation Trigger
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = { onClick = {
val currentPage = if (displayMode == DisplayMode.PAGINATION) { val currentPage = if (displayMode == DisplayMode.PAGINATION) {
pagerState.currentPage pagerState.currentPage
@ -5369,7 +5459,9 @@ fun PdfViewerScreen(
) )
} }
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = { coroutineScope.launch { drawerState.open() } }, onClick = { coroutineScope.launch { drawerState.open() } },
enabled = !(ttsState.isPlaying || ttsState.isLoading), enabled = !(ttsState.isPlaying || ttsState.isLoading),
modifier = Modifier.testTag("TocButton") modifier = Modifier.testTag("TocButton")
@ -5381,7 +5473,9 @@ fun PdfViewerScreen(
} }
// Search Button // Search Button
IconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = { onClick = {
executeWithOcrCheck { executeWithOcrCheck {
searchState.isSearchActive = true searchState.isSearchActive = true
@ -5397,11 +5491,19 @@ fun PdfViewerScreen(
) )
} }
IconButton( TooltipIconButton(
text = if (showAllTextHighlights)
stringResource(R.string.tooltip_highlights_off)
else
stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights)
stringResource(R.string.tooltip_highlights_off_desc)
else
stringResource(R.string.tooltip_highlights_desc),
onClick = { onClick = {
val newState = !showAllTextHighlights val newState = !showAllTextHighlights
if (newState) { if (newState) {
if (isHighlightingLoading) return@IconButton if (isHighlightingLoading) return@TooltipIconButton
showAllTextHighlights = true showAllTextHighlights = true
isHighlightingLoading = true isHighlightingLoading = true
} else { } else {
@ -5424,7 +5526,11 @@ fun PdfViewerScreen(
// AI feat // AI feat
Box { Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) } 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( Icon(
painter = painterResource(id = R.drawable.ai), painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features" contentDescription = "AI Features"
@ -5458,7 +5564,15 @@ fun PdfViewerScreen(
} }
// Edit Button // Edit Button
IconButton( TooltipIconButton(
text = if (isEditMode)
stringResource(R.string.tooltip_edit_mode_exit)
else
stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode)
stringResource(R.string.tooltip_edit_mode_exit_desc)
else
stringResource(R.string.tooltip_edit_mode_desc),
onClick = { onClick = {
val newEditMode = !isEditMode val newEditMode = !isEditMode
val currentActivePage = richTextController?.activePageIndex ?: -1 val currentActivePage = richTextController?.activePageIndex ?: -1
@ -5486,7 +5600,15 @@ fun PdfViewerScreen(
} }
// TTS // TTS
IconButton( 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 = { onClick = {
if (isTtsSessionActive) { if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS") Timber.d("TTS button clicked: Stopping TTS")
@ -5526,7 +5648,15 @@ fun PdfViewerScreen(
// TTS Pause/Resume Button // TTS Pause/Resume Button
if (isTtsSessionActive) { 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 = { onClick = {
if (ttsState.isPlaying) { if (ttsState.isPlaying) {
ttsController.pause() ttsController.pause()
@ -6264,7 +6394,7 @@ fun PdfViewerScreen(
isMainTtsActive = isTtsSessionActive, isMainTtsActive = isTtsSessionActive,
onOpenExternalDictionary = { onOpenExternalDictionary = {
selectedTextForAi?.let { text -> selectedTextForAi?.let { text ->
if (selectedDictPackage != null) { if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else { } else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
@ -6413,10 +6543,20 @@ fun PdfViewerScreen(
useOnlineDictionary = newState useOnlineDictionary = newState
saveUseOnlineDict(context, newState) saveUseOnlineDict(context, newState)
}, },
selectedPackageName = selectedDictPackage, selectedDictionaryPackageName = selectedDictPackage,
onSelectPackage = { pkg -> onSelectDictionaryPackage = { pkg ->
selectedDictPackage = pkg selectedDictPackage = pkg
saveExternalDictPackage(context, pkg) saveExternalDictPackage(context, pkg)
},
selectedTranslatePackageName = selectedTranslatePackage,
onSelectTranslatePackage = { pkg ->
selectedTranslatePackage = pkg
saveExternalTranslatePackage(context, pkg)
},
selectedSearchPackageName = selectedSearchPackage,
onSelectSearchPackage = { pkg ->
selectedSearchPackage = pkg
saveExternalSearchPackage(context, pkg)
} }
) )
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M476,880L658,400L742,400L924,880L840,880L797,758L603,758L560,880L476,880ZM160,760L104,704L306,502Q271,467 242.5,422Q214,377 190,320L274,320Q294,359 314,388Q334,417 362,446Q395,413 430.5,353.5Q466,294 484,240L40,240L40,160L320,160L320,80L400,80L400,160L680,160L680,240L564,240Q543,312 501,388Q459,464 418,504L514,602L484,684L362,559L160,760ZM628,688L772,688L700,484L628,688Z"/>
</vector>

View file

@ -1,3 +1,61 @@
<resources> <resources>
<string name="app_name">Episteme</string> <string name="app_name">Episteme</string>
</resources>
<!-- Tooltip titles -->
<string name="tooltip_back">Back</string>
<string name="tooltip_dictionary">Dictionary</string>
<string name="tooltip_more_options">More Options</string>
<string name="tooltip_slider">Page Slider</string>
<string name="tooltip_toc">Table of Contents</string>
<string name="tooltip_format">Text Format</string>
<string name="tooltip_search">Search</string>
<string name="tooltip_ai">AI Features</string>
<string name="tooltip_tts_start">Start Text-to-Speech</string>
<string name="tooltip_tts_stop">Stop Text-to-Speech</string>
<string name="tooltip_tts_pause">Pause</string>
<string name="tooltip_tts_resume">Resume</string>
<string name="tooltip_dark_mode_on">Enable Dark Mode</string>
<string name="tooltip_dark_mode_off">Disable Dark Mode</string>
<string name="tooltip_lock_pan">Lock Panning</string>
<string name="tooltip_unlock_pan">Unlock Panning</string>
<string name="tooltip_fullscreen">Full Screen</string>
<string name="tooltip_highlights">Show Highlights</string>
<string name="tooltip_highlights_off">Hide Highlights</string>
<string name="tooltip_edit_mode">Annotation Mode</string>
<string name="tooltip_edit_mode_exit">Exit Annotation Mode</string>
<string name="tooltip_close_search">Close Search</string>
<string name="tooltip_clear_search">Clear Search</string>
<string name="tooltip_show_results">Show Results</string>
<string name="tooltip_hide_results">Hide Results</string>
<string name="tooltip_prev_result">Previous Result</string>
<string name="tooltip_next_result">Next Result</string>
<!-- Tooltip descriptions -->
<string name="tooltip_back_desc">Exit the reader and return to the home screen</string>
<string name="tooltip_dictionary_desc">Choose your preferred app for word lookups</string>
<string name="tooltip_more_options_desc">Access reading mode, bookmarks, and advanced settings</string>
<string name="tooltip_slider_desc">Drag to jump quickly to any page in the document</string>
<string name="tooltip_toc_desc">Browse chapters and navigate to any section</string>
<string name="tooltip_format_desc">Adjust font, size, line height, alignment, and custom fonts</string>
<string name="tooltip_search_desc">Find any word or phrase in this book</string>
<string name="tooltip_ai_desc">Summarize the current chapter or page using AI</string>
<string name="tooltip_tts_start_desc">Read the book aloud using your device\'s voice engine</string>
<string name="tooltip_tts_stop_desc">Stop the current read-aloud session</string>
<string name="tooltip_tts_pause_desc">Pause the current read-aloud playback</string>
<string name="tooltip_tts_resume_desc">Resume paused read-aloud playback</string>
<string name="tooltip_dark_mode_on_desc">Invert PDF colors for dark mode</string>
<string name="tooltip_dark_mode_off_desc">Disable dark mode and restore the original PDF colors</string>
<string name="tooltip_lock_pan_desc">Lock horizontal panning on the page</string>
<string name="tooltip_unlock_pan_desc">Unlock panning to re-enable pinch-to-zoom and drag gestures</string>
<string name="tooltip_fullscreen_desc">Hide all UI controls for an immersive, distraction-free reading view</string>
<string name="tooltip_highlights_desc">Visually mark selectable text regions across the current page</string>
<string name="tooltip_highlights_off_desc">Remove the selectable text overlay from the page</string>
<string name="tooltip_edit_mode_desc">Add ink or text annotations</string>
<string name="tooltip_edit_mode_exit_desc">Finish editing and return to normal reading view</string>
<string name="tooltip_close_search_desc">Exit search and go back to the reader</string>
<string name="tooltip_clear_search_desc">Erase your current search query and start over</string>
<string name="tooltip_show_results_desc">Expand the panel to see all search matches</string>
<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>
</resources>