From f2c5ae25d705078402cc751a7f1b67044f28c552 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 14 Mar 2026 13:01:46 +0530 Subject: [PATCH] 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 --- README.md | 8 +- app/src/main/java/com/aryan/reader/Common.kt | 131 ++++- .../java/com/aryan/reader/LibraryScreen.kt | 2 +- .../aryan/reader/epubreader/ChapterWebView.kt | 182 +++---- .../epubreader/DictionarySettingsDialog.kt | 472 ++++++++++-------- .../reader/epubreader/EpubReaderControls.kt | 70 ++- .../reader/epubreader/EpubReaderScreen.kt | 76 ++- .../epubreader/ExternalDictionaryHelper.kt | 143 +++++- .../reader/paginatedreader/PaginatedReader.kt | 171 +++---- .../java/com/aryan/reader/pdf/PdfHelper.kt | 99 ++-- .../com/aryan/reader/pdf/PdfPageComposable.kt | 36 ++ .../com/aryan/reader/pdf/PdfVerticalReader.kt | 12 + .../com/aryan/reader/pdf/PdfViewerScreen.kt | 196 ++++++-- app/src/main/res/drawable-nodpi/search.xml | 10 + app/src/main/res/drawable-nodpi/translate.xml | 10 + app/src/main/res/values/strings.xml | 60 ++- 16 files changed, 1172 insertions(+), 506 deletions(-) create mode 100644 app/src/main/res/drawable-nodpi/search.xml create mode 100644 app/src/main/res/drawable-nodpi/translate.xml diff --git a/README.md b/README.md index 6bf152b..41e4a40 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## 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. @@ -61,10 +61,14 @@ Episteme Reader is an offline-first application designed for reading various doc ``` 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 ./gradlew assembleOssDebug ``` + The APK will be generated at: + ``` + app/build/outputs/apk/oss/debug/Episteme-oss-v{version}-oss-debug.apk + ``` ## Open Source Libraries diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 3af20ba..4f5f287 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -17,12 +17,19 @@ * * mail: epistemereader@gmail.com */ +@file:kotlin.OptIn(ExperimentalMaterial3Api::class) + package com.aryan.reader import android.content.Context import androidx.annotation.OptIn import androidx.compose.foundation.background 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.layout.Arrangement 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.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -242,6 +250,90 @@ fun rememberSearchState( } } +private val activeTooltipState = mutableStateOf(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 fun SearchTopBar( searchState: SearchState, @@ -265,7 +357,11 @@ fun SearchTopBar( .padding(horizontal = 4.dp), 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( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search" @@ -297,7 +393,11 @@ fun SearchTopBar( ) 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( Icons.Default.Close, contentDescription = "Clear Search" @@ -305,10 +405,20 @@ fun SearchTopBar( } } - IconButton(onClick = { - searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel - focusManager.clearFocus() - }) { + TooltipIconButton( + text = if (searchState.showSearchResultsPanel) + 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( imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results" @@ -333,7 +443,9 @@ fun SearchNavigationControls( verticalAlignment = Alignment.CenterVertically, 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) }, enabled = searchState.currentSearchResultIndex > 0 ) { @@ -346,7 +458,9 @@ fun SearchNavigationControls( 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) }, enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1 ) { @@ -1020,7 +1134,6 @@ suspend fun fetchRecap( } @OptIn(UnstableApi::class) -@kotlin.OptIn(ExperimentalMaterial3Api::class) @Composable fun TtsSettingsSheet( isVisible: Boolean, diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 4a0ae56..b621ed1 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -471,7 +471,7 @@ fun LibraryScreenContent( val isBookContextualModeActive = selectedItems.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty() var showSortMenu by remember { mutableStateOf(false) } - val tabTitles = listOf("All Books", "Shelves", "Folder") + val tabTitles = listOf("All Books", "Shelves", "Folders") val searchFocusRequester = remember { FocusRequester() } LaunchedEffect(isSearchActive) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 92eb347..18a8d52 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -36,7 +36,6 @@ import android.webkit.WebViewClient import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,6 +56,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -85,7 +85,6 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.core.net.toUri import com.aryan.reader.R -import com.aryan.reader.countWords import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.json.JSONObject @@ -333,6 +332,8 @@ fun ChapterWebView( isOss: Boolean = false, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, + onTranslate: (String) -> Unit, + onSearch: (String) -> Unit, onContentReadyForSummarization: suspend (String) -> Unit, currentFontFamily: ReaderFont, customFontPath: String? = null, @@ -887,121 +888,82 @@ fun ChapterWebView( ) } - // 2. Delete Option (Only for existing highlights) - if (state.isExistingHighlight && state.cfi != null) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - // LOGGING START - Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'") - - // 1. IMPROVED LOOKUP: Check if the clicked CFI exists within any split CFI string - val highlightToDelete = userHighlights.find { h -> - h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) - } - - if (highlightToDelete == null) { - Timber.e("Kotlin: ERROR - Lookup failed. CFI '${state.cfi}' not found in any highlight.") - } else { - Timber.d("Kotlin: SUCCESS - Found highlight object. Full CFI: '${highlightToDelete.cfi}', Color: ${highlightToDelete.color.id}") - - val cssClassToDelete = highlightToDelete.color.cssClass - val allCfiParts = highlightToDelete.cfi.split("|") - - allCfiParts.forEach { partCfi -> - Timber.d("Kotlin: Requesting JS removal for part: '$partCfi'") - localWebViewRef?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", - null - ) - } - - onHighlightDeleted(highlightToDelete.cfi) - } - - state.finishActionModeCallback() - customMenuState = null - } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Text( - text = "Remove", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } - } - HorizontalDivider() - - // 2. Copy Option Row( modifier = Modifier .fillMaxWidth() - .clickable { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", state.selectedText) - clipboard.setPrimaryClip(clip) - state.finishActionModeCallback() - localWebViewRef?.clearFocus() - localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) - customMenuState = null - } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.CopyAll, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Text( - text = "Copy", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", state.selectedText) + clipboard.setPrimaryClip(clip) + state.finishActionModeCallback() + localWebViewRef?.clearFocus() + localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) + customMenuState = null + }) { + Icon(Icons.Default.CopyAll, contentDescription = "Copy") + } - // 3. Dictionary Option (Preserving Logic) - if (state.selectedText.length <= 2000) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val textToDefine = state.selectedText - if (textToDefine.isNotBlank()) { - onWordSelectedForAiDefinition(textToDefine) - } - customMenuState = null + if (state.selectedText.length <= 2000) { + IconButton(onClick = { + val textToDefine = state.selectedText + if (textToDefine.isNotBlank()) { + onWordSelectedForAiDefinition(textToDefine) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Text( - text = "Dictionary", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) + customMenuState = null + }) { + Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary") + } + IconButton(onClick = { + val textToDefine = state.selectedText + if (textToDefine.isNotBlank()) { + onTranslate(textToDefine) + } + customMenuState = null + }) { + Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate") + } + IconButton(onClick = { + val textToDefine = state.selectedText + if (textToDefine.isNotBlank()) { + onSearch(textToDefine) + } + customMenuState = null + }) { + Icon(painterResource(id = R.drawable.search), contentDescription = "Search") + } + } + + if (state.isExistingHighlight && state.cfi != null) { + IconButton(onClick = { + Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'") + val highlightToDelete = userHighlights.find { h -> + h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) + } + + if (highlightToDelete != null) { + val cssClassToDelete = highlightToDelete.color.cssClass + val allCfiParts = highlightToDelete.cfi.split("|") + + allCfiParts.forEach { partCfi -> + localWebViewRef?.evaluateJavascript( + "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", + null + ) + } + + onHighlightDeleted(highlightToDelete.cfi) + } + + state.finishActionModeCallback() + customMenuState = null + }) { + Icon(Icons.Default.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error) + } } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt index cf97922..53e5ac1 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt @@ -1,30 +1,29 @@ -// DictionarySettingsDialog.kt package com.aryan.reader.epubreader -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,19 +34,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.graphics.drawable.toBitmap import com.aryan.reader.BuildConfig -import com.aryan.reader.R @Suppress("KotlinConstantConditions") +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DictionarySettingsDialog( isVisible: Boolean, @@ -55,244 +51,292 @@ fun DictionarySettingsDialog( isProUser: Boolean, useOnlineDictionary: Boolean, onToggleOnlineDictionary: (Boolean) -> Unit, - selectedPackageName: String?, - onSelectPackage: (String) -> Unit + selectedDictionaryPackageName: String?, + onSelectDictionaryPackage: (String) -> Unit, + selectedTranslatePackageName: String?, + onSelectTranslatePackage: (String) -> Unit, + selectedSearchPackageName: String?, + onSelectSearchPackage: (String) -> Unit ) { if (!isVisible) return val context = LocalContext.current - var availableApps by remember { mutableStateOf>(emptyList()) } + var dictionaryApps by remember { mutableStateOf>(emptyList()) } + var searchApps by remember { mutableStateOf>(emptyList()) } LaunchedEffect(Unit) { - availableApps = ExternalDictionaryHelper.getAvailableDictionaries(context) + dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context) + searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context) } Dialog(onDismissRequest = onDismiss) { Surface( - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 24.dp) + .verticalScroll(rememberScrollState()) + .padding(24.dp) ) { - // Header Text( - text = "Dictionary Settings", + text = "Lookup Settings", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 20.dp) ) + // ── Dictionary ── if (BuildConfig.FLAVOR != "oss") { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - shape = RoundedCornerShape(12.dp), - color = if (useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, - border = BorderStroke( - 1.dp, - if (useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant - ), - modifier = Modifier - .fillMaxWidth() - .clickable { onToggleOnlineDictionary(true) } - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(id = R.drawable.ai), - contentDescription = null, - tint = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "AI Smart Dictionary", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Definitions powered by AI.", - style = MaterialTheme.typography.bodySmall, - color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (useOnlineDictionary) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Dictionary Engine", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) - // External App Option - Surface( - shape = RoundedCornerShape(12.dp), - color = if (!useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, - border = BorderStroke( - 1.dp, - if (!useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant - ), - modifier = Modifier - .fillMaxWidth() - .clickable { onToggleOnlineDictionary(false) } - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = null, - tint = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "External App", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Launch an offline dictionary or search app.", - style = MaterialTheme.typography.bodySmall, - color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) + SegmentedButton( + selected = useOnlineDictionary, + onClick = { onToggleOnlineDictionary(true) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) + ) { + Text("Smart (AI)") } - if (!useOnlineDictionary) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary - ) + SegmentedButton( + selected = !useOnlineDictionary, + onClick = { onToggleOnlineDictionary(false) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) + ) { + Text("External App") } } + + Text( + text = if (useOnlineDictionary) + "Uses AI for definitions. Will fallback to the external app below if offline or if the selected phrase is too long." + else + "Uses the selected app for dictionary lookups.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = if (useOnlineDictionary) "Fallback App" else "Dictionary App", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + AppSelectionDropdown( + apps = dictionaryApps, + selectedPackageName = selectedDictionaryPackageName, + onSelect = onSelectDictionaryPackage, + placeholder = "Select an app" + ) } } - - Spacer(modifier = Modifier.height(24.dp)) - + } else { Text( - text = if (useOnlineDictionary) "Fallback External App (Used when offline)" else "Select External App", + text = "Dictionary", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp) ) - - } else { - // OSS FLAVOR UI (Dedicated to external apps) - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.padding(bottom = 16.dp) - ) { - Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(32.dp) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = "Choose an external app to define selected words.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } + AppSelectionDropdown( + apps = dictionaryApps, + selectedPackageName = selectedDictionaryPackageName, + onSelect = onSelectDictionaryPackage, + placeholder = "Select an app" + ) } - // App List - if (availableApps.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - Text( - "No supported dictionary apps found.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center + SectionDivider() + + // ── Translate ── + Text( + text = "Translate", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = "App used for translating selected text.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + AppSelectionDropdown( + apps = dictionaryApps, + selectedPackageName = selectedTranslatePackageName, + onSelect = onSelectTranslatePackage, + placeholder = "Select an app" + ) + + SectionDivider() + + // ── Search ── + Text( + text = "Search", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = "App used for web searches.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + AppSelectionDropdown( + apps = searchApps, + selectedPackageName = selectedSearchPackageName, + onSelect = onSelectSearchPackage, + placeholder = "Select an app" + ) + } + } + } +} + +@Composable +private fun SectionDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppSelectionDropdown( + apps: List, + selectedPackageName: String?, + onSelect: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val selectedApp = apps.find { it.packageName == selectedPackageName } + val hasSelection = !selectedPackageName.isNullOrEmpty() && selectedApp != null + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier + ) { + OutlinedTextField( + value = if (hasSelection) selectedApp.label else "", + onValueChange = {}, + readOnly = true, + singleLine = true, + placeholder = { Text(placeholder) }, + leadingIcon = if (hasSelection && selectedApp.icon != null) { + { + Image( + bitmap = selectedApp.icon.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } else null, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + // None option + DropdownMenuItem( + text = { + Text( + "None", + color = if (!hasSelection) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface + ) + }, + trailingIcon = if (!hasSelection) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary ) } - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) // Bound height so it doesn't take over screen - .background(MaterialTheme.colorScheme.surfaceContainerLowest, RoundedCornerShape(12.dp)) - ) { - items(availableApps) { app -> - val isSelected = app.packageName == selectedPackageName - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelectPackage(app.packageName) } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Dynamic Icon handling - if (app.packageName == ExternalDictionaryHelper.GOOGLE_SEARCH_PKG) { - Box( - modifier = Modifier - .size(40.dp) - .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(24.dp) - ) - } - } else if (app.icon != null) { - Image( - bitmap = app.icon.toBitmap().asImageBitmap(), - contentDescription = null, - modifier = Modifier.size(40.dp) - ) - } else { - Box( - modifier = Modifier - .size(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = app.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.weight(1f) - ) - - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary - ) - } - } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - } - } + } else null, + onClick = { + onSelect("") + expanded = false } + ) + + if (apps.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + + apps.forEach { app -> + val isSelected = app.packageName == selectedPackageName + DropdownMenuItem( + text = { Text(app.label) }, + leadingIcon = { + if (app.icon != null) { + Image( + bitmap = app.icon.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center, + content = {} + ) + } + }, + trailingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + onSelect(app.packageName) + expanded = false + } + ) } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 6cdc712..02f08c2 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -108,6 +108,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -120,6 +121,7 @@ import com.aryan.reader.R import com.aryan.reader.RenderMode import com.aryan.reader.SearchState import com.aryan.reader.SearchTopBar +import com.aryan.reader.TooltipIconButton import com.aryan.reader.epub.EpubChapter import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.IPaginator @@ -182,7 +184,11 @@ fun EpubReaderTopBar( onCloseSearch = onCloseSearch ) } else { - IconButton(onClick = onNavigateBack) { + TooltipIconButton( + text = stringResource(R.string.tooltip_back), + description = stringResource(R.string.tooltip_back_desc), + onClick = onNavigateBack + ) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } Spacer(Modifier.width(8.dp)) @@ -193,7 +199,11 @@ fun EpubReaderTopBar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - IconButton(onClick = onOpenDictionarySettings) { + TooltipIconButton( + text = stringResource(R.string.tooltip_dictionary), + description = stringResource(R.string.tooltip_dictionary_desc), + onClick = onOpenDictionarySettings + ) { Icon( painter = painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary Settings" @@ -201,7 +211,11 @@ fun EpubReaderTopBar( } Box { var showMoreMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMoreMenu = true }) { + TooltipIconButton( + text = stringResource(R.string.tooltip_more_options), + description = stringResource(R.string.tooltip_more_options_desc), + onClick = { showMoreMenu = true } + ) { Icon(Icons.Default.MoreVert, contentDescription = "More Options") } @@ -392,19 +406,33 @@ fun EpubReaderBottomBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceAround ) { - IconButton( + TooltipIconButton( + text = stringResource(R.string.tooltip_slider), + description = stringResource(R.string.tooltip_slider_desc), onClick = onOpenSlider, enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL ) { Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider") } - IconButton(onClick = onOpenDrawer) { + TooltipIconButton( + text = stringResource(R.string.tooltip_toc), + description = stringResource(R.string.tooltip_toc_desc), + onClick = onOpenDrawer + ) { Icon(imageVector = Icons.Default.Menu, contentDescription = "Chapters Menu") } - IconButton(onClick = onToggleFormat) { + TooltipIconButton( + text = stringResource(R.string.tooltip_format), + description = stringResource(R.string.tooltip_format_desc), + onClick = onToggleFormat + ) { Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = "Text Formatting") } - IconButton(onClick = onToggleSearch) { + TooltipIconButton( + text = stringResource(R.string.tooltip_search), + description = stringResource(R.string.tooltip_search_desc), + onClick = onToggleSearch + ) { Icon(imageVector = Icons.Default.Search, contentDescription = "Search") } @@ -412,7 +440,11 @@ fun EpubReaderBottomBar( if (BuildConfig.FLAVOR != "oss") { Box { var showAiFeaturesMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showAiFeaturesMenu = true }) { + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = { showAiFeaturesMenu = true } + ) { Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features") } DropdownMenu( @@ -441,14 +473,32 @@ fun EpubReaderBottomBar( } Box { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onToggleTts) { + TooltipIconButton( + text = if (isTtsSessionActive) + stringResource(R.string.tooltip_tts_stop) + else + stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) + stringResource(R.string.tooltip_tts_stop_desc) + else + stringResource(R.string.tooltip_tts_start_desc), + onClick = onToggleTts + ) { Icon( painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS" ) } if (isTtsSessionActive) { - IconButton( + TooltipIconButton( + text = if (ttsState.isPlaying) + stringResource(R.string.tooltip_tts_pause) + else + stringResource(R.string.tooltip_tts_resume), + description = if (ttsState.isPlaying) + stringResource(R.string.tooltip_tts_pause_desc) + else + stringResource(R.string.tooltip_tts_resume_desc), onClick = onPlayPauseTts, enabled = !ttsState.isLoading ) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index c9c3e64..8881534 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -291,6 +291,8 @@ private fun saveTtsMode(context: Context, modeName: String) { private const val PREF_USE_ONLINE_DICT = "use_online_dictionary" private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" +private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package" +private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private fun loadUseOnlineDict(context: Context): Boolean { @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false @@ -313,6 +315,26 @@ private fun saveExternalDictPackage(context: Context, packageName: String) { prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) } } +private fun loadExternalTranslatePackage(context: Context): String? { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null) +} + +private fun saveExternalTranslatePackage(context: Context, packageName: String) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) } +} + +private fun loadExternalSearchPackage(context: Context): String? { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null) +} + +private fun saveExternalSearchPackage(context: Context, packageName: String) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) } +} + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun EpubReaderScreen( @@ -573,6 +595,12 @@ fun EpubReaderHost( var selectedDictPackage by remember { mutableStateOf(loadExternalDictPackage(context)) } + var selectedTranslatePackage by remember { + mutableStateOf(loadExternalTranslatePackage(context)) + } + var selectedSearchPackage by remember { + mutableStateOf(loadExternalSearchPackage(context)) + } var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } @@ -605,7 +633,7 @@ fun EpubReaderHost( showDictionaryUpsellDialog = true } } else { - if (selectedDictPackage != null) { + if (!selectedDictPackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word) } else { Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show() @@ -614,6 +642,24 @@ fun EpubReaderHost( } } + val onTranslateLookup = { text: String -> + if (!selectedTranslatePackage.isNullOrEmpty()) { + ExternalDictionaryHelper.launchTranslate(context, selectedTranslatePackage!!, text) + } else { + Toast.makeText(context, "Please select a translate app first.", Toast.LENGTH_SHORT).show() + showDictionarySettingsSheet = true + } + } + + val onSearchLookup = { text: String -> + if (!selectedSearchPackage.isNullOrEmpty()) { + ExternalDictionaryHelper.launchSearch(context, selectedSearchPackage!!, text) + } else { + Toast.makeText(context, "Please select a search app first.", Toast.LENGTH_SHORT).show() + showDictionarySettingsSheet = true + } + } + val summaryCacheManager = remember(context) { SummaryCacheManager(context) } var showRecapPopup by remember { mutableStateOf(false) } var recapResult by remember { mutableStateOf(null) } @@ -2256,6 +2302,12 @@ fun EpubReaderHost( onWordSelectedForAiDefinition = { text -> onDictionaryLookup(text) }, + onTranslate = { text -> + onTranslateLookup(text) + }, + onSearch = { text -> + onSearchLookup(text) + }, onContentReadyForSummarization = { content -> Timber.d("Content received for summarization") scope.launch { @@ -2591,6 +2643,12 @@ fun EpubReaderHost( onWordSelectedForAiDefinition = { text -> onDictionaryLookup(text) }, + onTranslate = { text -> + onTranslateLookup(text) + }, + onSearch = { text -> + onSearchLookup(text) + }, userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) }, onHighlightCreated = { cfi, text, colorId -> Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") @@ -3532,7 +3590,7 @@ fun EpubReaderHost( onNavigateToPro = onNavigateToPro, isTtsSessionActive = isTtsSessionActive, onOpenExternalDictionary = { text -> - if (selectedDictPackage != null) { + if (!selectedDictPackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) } else { Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show() @@ -3707,10 +3765,20 @@ fun EpubReaderHost( useOnlineDictionary = newState saveUseOnlineDict(context, newState) }, - selectedPackageName = selectedDictPackage, - onSelectPackage = { pkg -> + selectedDictionaryPackageName = selectedDictPackage, + onSelectDictionaryPackage = { pkg -> selectedDictPackage = pkg saveExternalDictPackage(context, pkg) + }, + selectedTranslatePackageName = selectedTranslatePackage, + onSelectTranslatePackage = { pkg -> + selectedTranslatePackage = pkg + saveExternalTranslatePackage(context, pkg) + }, + selectedSearchPackageName = selectedSearchPackage, + onSelectSearchPackage = { pkg -> + selectedSearchPackage = pkg + saveExternalSearchPackage(context, pkg) } ) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt index cf08a92..beda0b2 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt @@ -6,9 +6,11 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Build import android.widget.Toast import timber.log.Timber +import androidx.core.net.toUri data class ExternalDictionaryApp( val label: String, @@ -23,7 +25,6 @@ object ExternalDictionaryHelper { "com.samsung.android.samsungpassautofill", "com.samsung.android.samsungpass", "com.samsung.android.app.pass", - "com.google.android.gms", "com.truecaller", "com.adobe.reader", "com.reddit.frontpage" @@ -77,18 +78,15 @@ object ExternalDictionaryHelper { ) ) - return sortedApps + return apps.sortedBy { it.label } } fun launchDictionary(context: Context, packageName: String, query: String) { + if (packageName.isEmpty()) return val pm = context.packageManager try { if (packageName == GOOGLE_SEARCH_PKG) { - val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, query) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(searchIntent) + launchSearch(context, packageName, query) return } @@ -133,6 +131,89 @@ object ExternalDictionaryHelper { } } + fun launchTranslate(context: Context, packageName: String, query: String) { + if (packageName.isEmpty()) return + val pm = context.packageManager + try { + if (packageName == GOOGLE_SEARCH_PKG) { + launchSearch(context, packageName, query) + return + } + + // Google Translate specific intent + if (packageName == "com.google.android.apps.translate") { + val translateIntent = Intent(Intent.ACTION_PROCESS_TEXT).apply { + type = "text/plain" + putExtra(Intent.EXTRA_PROCESS_TEXT, query) + putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) + setPackage(packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (translateIntent.resolveActivity(pm) != null) { + context.startActivity(translateIntent) + return + } + } + + // Generic text processing intent + val processTextIntent = Intent(Intent.ACTION_PROCESS_TEXT).apply { + type = "text/plain" + putExtra(Intent.EXTRA_PROCESS_TEXT, query) + putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) + setPackage(packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (processTextIntent.resolveActivity(pm) != null) { + context.startActivity(processTextIntent) + return + } + + launchGenericSend(context, packageName, query) + } catch (e: Exception) { + Timber.e(e, "Failed to launch translate app: $packageName") + Toast.makeText(context, "Error opening translate app", Toast.LENGTH_SHORT).show() + } + } + + fun launchSearch(context: Context, packageName: String, query: String) { + try { + if (packageName == GOOGLE_SEARCH_PKG) { + val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, query) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(searchIntent) + return + } + + val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, query) + setPackage(packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (searchIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(searchIntent) + return + } + + val viewIntent = Intent(Intent.ACTION_VIEW).apply { + data = "https://www.google.com/search?q=${Uri.encode(query)}".toUri() + setPackage(packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (viewIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(viewIntent) + return + } + + launchGenericSend(context, packageName, query) + } catch (e: Exception) { + Timber.e(e, "Failed to launch search app: $packageName") + Toast.makeText(context, "Error opening search app", Toast.LENGTH_SHORT).show() + } + } + private fun launchGenericSend(context: Context, packageName: String, query: String) { val sendIntent = Intent(Intent.ACTION_SEND) sendIntent.type = "text/plain" @@ -151,4 +232,52 @@ object ExternalDictionaryHelper { } } } + + fun getAvailableSearchApps(context: Context): List { + val pm = context.packageManager + val apps = mutableListOf() + val addedPackages = mutableSetOf() + + 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 } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index c6d15da..d8472d8 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -69,6 +69,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -505,6 +506,8 @@ fun PaginatedReaderScreen( isOss: Boolean = false, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, + onTranslate: (String) -> Unit, + onSearch: (String) -> Unit, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, @@ -802,6 +805,8 @@ fun PaginatedReaderScreen( isOss = isOss, onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialog, onWordSelectedForAiDefinition = onWordSelectedForAiDefinition, + onTranslate = onTranslate, + onSearch = onSearch, userHighlights = userHighlights, onHighlightCreated = onHighlightCreated, onHighlightDeleted = onHighlightDeleted, @@ -1372,6 +1377,8 @@ internal fun PaginatedReaderContent( isOss: Boolean, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, + onTranslate: (String) -> Unit, + onSearch: (String) -> Unit, onGetChapterInfo: (Int) -> Pair?, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, @@ -2531,6 +2538,14 @@ internal fun PaginatedReaderContent( state.onCopy() isForDictionary = false 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 -> Timber.d("Menu: Highlight option clicked. Color: ${color.id}") isForHighlight = true @@ -2768,6 +2783,12 @@ internal fun PaginatedReaderContent( onShowDictionaryUpsellDialog() } activeSelection = null + }, onTranslate = { + onTranslate(sel.text) + activeSelection = null + }, onSearch = { + onSearch(sel.text) + activeSelection = null }, onHighlight = { color -> Timber.d( "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" @@ -2834,6 +2855,14 @@ internal fun PaginatedReaderContent( } activeHighlightForMenu = null }, + onTranslate = { + onTranslate(highlight.text) + activeHighlightForMenu = null + }, + onSearch = { + onSearch(highlight.text) + activeHighlightForMenu = null + }, onHighlight = { color -> Timber.d("Menu: Updating highlight color to ${color.id}") onHighlightDeleted(highlight.cfi) @@ -2911,6 +2940,8 @@ private fun PaginatedTextSelectionMenu( onCopy: () -> Unit, onSelectAll: (() -> Unit)?, onDictionary: () -> Unit, + onTranslate: () -> Unit, + onSearch: () -> Unit, onHighlight: ((HighlightColor) -> Unit)?, onDelete: (() -> Unit)?, @Suppress("unused") isProUser: Boolean, @@ -2955,99 +2986,71 @@ private fun PaginatedTextSelectionMenu( HorizontalDivider() } - // 2. Delete Option - 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 + // 2. Action Icons Row (Horizontal) Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onCopy) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - 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) - ) { + IconButton(onClick = onCopy) { Icon( - painter = painterResource(id = R.drawable.select_all), - contentDescription = null, + painter = painterResource(id = R.drawable.copy), + contentDescription = "Copy", tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Text( - "Select All", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + modifier = Modifier.size(24.dp) ) } - } - // 5. Dictionary Option - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onDictionary) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Text( - "Dictionary", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) + IconButton(onClick = onDictionary) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = "Dictionary", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = onTranslate) { + Icon( + painter = painterResource(id = R.drawable.translate), + contentDescription = "Translate", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = onSearch) { + 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) + ) + } + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index bd69d26..f25d97b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -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) + ) } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 5f3c17c..bd16341 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -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}") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 7ce1f82..7a3a6f5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -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, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index dbf40f1..f7579f3 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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) } ) } diff --git a/app/src/main/res/drawable-nodpi/search.xml b/app/src/main/res/drawable-nodpi/search.xml new file mode 100644 index 0000000..390774b --- /dev/null +++ b/app/src/main/res/drawable-nodpi/search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/translate.xml b/app/src/main/res/drawable-nodpi/translate.xml new file mode 100644 index 0000000..5792d2c --- /dev/null +++ b/app/src/main/res/drawable-nodpi/translate.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5315e43..4825a13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,61 @@ Episteme - \ No newline at end of file + + + Back + Dictionary + More Options + Page Slider + Table of Contents + Text Format + Search + AI Features + Start Text-to-Speech + Stop Text-to-Speech + Pause + Resume + Enable Dark Mode + Disable Dark Mode + Lock Panning + Unlock Panning + Full Screen + Show Highlights + Hide Highlights + Annotation Mode + Exit Annotation Mode + Close Search + Clear Search + Show Results + Hide Results + Previous Result + Next Result + + + Exit the reader and return to the home screen + Choose your preferred app for word lookups + Access reading mode, bookmarks, and advanced settings + Drag to jump quickly to any page in the document + Browse chapters and navigate to any section + Adjust font, size, line height, alignment, and custom fonts + Find any word or phrase in this book + Summarize the current chapter or page using AI + Read the book aloud using your device\'s voice engine + Stop the current read-aloud session + Pause the current read-aloud playback + Resume paused read-aloud playback + Invert PDF colors for dark mode + Disable dark mode and restore the original PDF colors + Lock horizontal panning on the page + Unlock panning to re-enable pinch-to-zoom and drag gestures + Hide all UI controls for an immersive, distraction-free reading view + Visually mark selectable text regions across the current page + Remove the selectable text overlay from the page + Add ink or text annotations + Finish editing and return to normal reading view + Exit search and go back to the reader + Erase your current search query and start over + Expand the panel to see all search matches + Collapse the search results panel + Jump to the previous search match in the document + Jump to the next search match in the document +