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

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

View file

@ -389,6 +389,8 @@ internal fun PdfPageComposable(
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onTranslateText: (String) -> Unit,
onSearchText: (String) -> Unit,
ttsHighlightData: TtsHighlightData?,
onLinkClicked: (String) -> 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 = {
customMenuState = null
coroutineScope.launch {
@ -4523,6 +4555,8 @@ private fun PdfPageRenderer(
onMenuDismiss: () -> Unit,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
onTranslate: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
onShowUpsellDialog: () -> Unit,
isProUser: Boolean,
@ -4934,6 +4968,8 @@ private fun PdfPageRenderer(
onDismiss = onMenuDismiss,
onCopy = onCopy,
onAiDefine = onAiDefine,
onTranslate = onTranslate,
onSearch = onSearch,
onSelectAll = onSelectAll,
onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}")

View file

@ -193,6 +193,8 @@ internal fun PdfVerticalReader(
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onTranslateText: (String) -> Unit,
onSearchText: (String) -> Unit,
ttsHighlightData: TtsHighlightData?,
ttsReadingPage: Int?,
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) {
{ localOffset: Offset ->
Timber.tag("PdfZoomDebug").d(
@ -1456,6 +1466,8 @@ internal fun PdfVerticalReader(
isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
onTranslateText = onTranslateTextLambda,
onSearchText = onSearchTextLambda,
ttsHighlightData = pageTtsData,
onLinkClicked = onLinkClicked,
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.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString
@ -244,6 +245,7 @@ import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.SummarizationResult
import com.aryan.reader.TtsSettingsSheet
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 PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
@ -344,6 +348,26 @@ private fun saveExternalDictPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
private fun loadExternalTranslatePackage(context: Context): String? {
val prefs = context.getSharedPreferences(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) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_MUSICIAN_MODE_KEY, isEnabled) }
@ -780,6 +804,8 @@ fun PdfViewerScreen(
var showDictionarySettingsSheet by remember { mutableStateOf(false) }
var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(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) }
@ -1127,7 +1153,7 @@ fun PdfViewerScreen(
withContext(NonCancellable) {
saveMutex.withLock {
withContext(Dispatchers.IO) {
var didSave = false
@Suppress("VariableNeverRead") var didSave = false
if (force || annotsHash != lastSavedHashes[0]) {
annotationRepository.saveAnnotations(bookId, annots)
@ -2131,7 +2157,7 @@ fun PdfViewerScreen(
showDictionaryUpsellDialog = true
}
} else {
if (selectedDictPackage != null) {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
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 onInternalLinkNavStable = remember(displayMode) {
@ -3874,6 +3922,8 @@ fun PdfViewerScreen(
}
},
onWordSelectedForAiDefinition = onDictionaryLookupStable,
onTranslateText = onTranslateTextStable,
onSearchText = onSearchTextStable,
onOcrStateChange = onOcrStateChange,
onLinkClicked = { url -> clickedLinkUrl = url },
onInternalLinkClicked = onInternalLinkNav,
@ -4244,6 +4294,8 @@ fun PdfViewerScreen(
isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialogStable,
onWordSelectedForAiDefinition = onDictionaryLookupStable,
onTranslateText = onTranslateTextStable,
onSearchText = onSearchTextStable,
ttsHighlightData = ttsHighlightData,
ttsReadingPage = ttsPageData?.pageIndex,
userHighlights = userHighlights,
@ -4774,7 +4826,11 @@ fun PdfViewerScreen(
focusManager.clearFocus()
})
} else {
IconButton(onClick = { saveStateAndExit() }) {
TooltipIconButton(
text = stringResource(R.string.tooltip_back),
description = stringResource(R.string.tooltip_back_desc),
onClick = { saveStateAndExit() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
@ -4804,7 +4860,17 @@ fun PdfViewerScreen(
.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(
painter = painterResource(id = R.drawable.dark_mode),
contentDescription = if (isPdfDarkMode) "Disable Dark Mode"
@ -4814,10 +4880,20 @@ fun PdfViewerScreen(
)
}
IconButton(onClick = {
isScrollLocked = !isScrollLocked
savePdfScrollLocked(context, bookId, isScrollLocked)
}) {
TooltipIconButton(
text = if (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(
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning",
@ -4825,10 +4901,14 @@ fun PdfViewerScreen(
)
}
IconButton(onClick = {
isFullScreen = true
savePdfFullScreen(context, bookId, true)
}) {
TooltipIconButton(
text = stringResource(R.string.tooltip_fullscreen),
description = stringResource(R.string.tooltip_fullscreen_desc),
onClick = {
isFullScreen = true
savePdfFullScreen(context, bookId, true)
}
) {
Icon(
imageVector = Icons.Default.Fullscreen,
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(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings",
@ -4845,7 +4929,7 @@ fun PdfViewerScreen(
}
if (BuildConfig.DEBUG) {
IconButton(onClick = { showPenPlayground = true }) {
TooltipIconButton(text = "Pen Playground", onClick = { showPenPlayground = true }) {
Icon(
imageVector = Icons.Default.Star,
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
coroutineScope.launch(Dispatchers.IO) {
@ -4890,7 +4974,11 @@ fun PdfViewerScreen(
Box {
var showMoreMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMoreMenu = true }) {
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options"
@ -5350,7 +5438,9 @@ fun PdfViewerScreen(
horizontalArrangement = Arrangement.SpaceAround
) {
// Slider Navigation Trigger
IconButton(
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = {
val currentPage = if (displayMode == DisplayMode.PAGINATION) {
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() } },
enabled = !(ttsState.isPlaying || ttsState.isLoading),
modifier = Modifier.testTag("TocButton")
@ -5381,7 +5473,9 @@ fun PdfViewerScreen(
}
// Search Button
IconButton(
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = {
executeWithOcrCheck {
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 = {
val newState = !showAllTextHighlights
if (newState) {
if (isHighlightingLoading) return@IconButton
if (isHighlightingLoading) return@TooltipIconButton
showAllTextHighlights = true
isHighlightingLoading = true
} else {
@ -5424,7 +5526,11 @@ fun PdfViewerScreen(
// AI feat
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showAiFeaturesMenu = true }) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features"
@ -5458,7 +5564,15 @@ fun PdfViewerScreen(
}
// 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 = {
val newEditMode = !isEditMode
val currentActivePage = richTextController?.activePageIndex ?: -1
@ -5486,7 +5600,15 @@ fun PdfViewerScreen(
}
// 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 = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
@ -5526,7 +5648,15 @@ fun PdfViewerScreen(
// TTS Pause/Resume Button
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 = {
if (ttsState.isPlaying) {
ttsController.pause()
@ -6264,7 +6394,7 @@ fun PdfViewerScreen(
isMainTtsActive = isTtsSessionActive,
onOpenExternalDictionary = {
selectedTextForAi?.let { text ->
if (selectedDictPackage != null) {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
@ -6413,10 +6543,20 @@ fun PdfViewerScreen(
useOnlineDictionary = newState
saveUseOnlineDict(context, newState)
},
selectedPackageName = selectedDictPackage,
onSelectPackage = { pkg ->
selectedDictionaryPackageName = selectedDictPackage,
onSelectDictionaryPackage = { pkg ->
selectedDictPackage = pkg
saveExternalDictPackage(context, pkg)
},
selectedTranslatePackageName = selectedTranslatePackage,
onSelectTranslatePackage = { pkg ->
selectedTranslatePackage = pkg
saveExternalTranslatePackage(context, pkg)
},
selectedSearchPackageName = selectedSearchPackage,
onSelectSearchPackage = { pkg ->
selectedSearchPackage = pkg
saveExternalSearchPackage(context, pkg)
}
)
}