From 95dfd3c7eb045dae78aea8138db437a474f90e4f Mon Sep 17 00:00:00 2001 From: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:37:53 +0100 Subject: [PATCH] implement automated translation and caching for vocabulary pack names and descriptions in `ExplorePacksScreen` using LibreTranslate. --- .../translator/utils/TranslationService.kt | 4 +- .../view/vocabulary/ExplorePacksScreen.kt | 217 ++++++++++++++++-- .../viewmodel/TranslationViewModel.kt | 11 + 3 files changed, 212 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt b/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt index 5741de6..094a553 100644 --- a/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt +++ b/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt @@ -55,7 +55,9 @@ class TranslationService(private val context: Context) { } } - private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result = withContext(Dispatchers.IO) { + // Public method to directly use LibreTranslate (bypasses AI) + suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result = withContext(Dispatchers.IO) { + Log.d("libreTranslate: $text, $source, $target") try { val json = org.json.JSONObject().apply { put("q", text) diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt index d46ccf2..bdedaf1 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt @@ -89,6 +89,7 @@ import eu.gaudian.translator.viewmodel.ImportState import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.PackDownloadState import eu.gaudian.translator.viewmodel.PackUiState +import eu.gaudian.translator.viewmodel.TranslationViewModel import eu.gaudian.translator.viewmodel.VocabPacksViewModel import kotlin.math.abs @@ -125,19 +126,168 @@ enum class PackFilter { // --------------------------------------------------------------------------- private val gradientPalette = listOf( - listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), - listOf(Color(0xFF00695C), Color(0xFF26A69A)), - listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), - listOf(Color(0xFFE65100), Color(0xFFFFA726)), - listOf(Color(0xFF212121), Color(0xFF546E7A)), - listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), - listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)), - listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)), + // Original Gradients + listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), // Blue + listOf(Color(0xFF00695C), Color(0xFF26A69A)), // Teal + listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), // Purple + listOf(Color(0xFFE65100), Color(0xFFFFA726)), // Orange + listOf(Color(0xFF212121), Color(0xFF546E7A)), // Dark Grey to Blue Grey + listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), // Red + listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)), // Green + listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)), // Deep Blue + + // New Monochromatic / Material Shades + listOf(Color(0xFFAD1457), Color(0xFFF06292)), // Pink + listOf(Color(0xFF283593), Color(0xFF7986CB)), // Indigo + listOf(Color(0xFF00838F), Color(0xFF4DD0E1)), // Cyan + listOf(Color(0xFFFF8F00), Color(0xFFFFD54F)), // Amber + listOf(Color(0xFFD84315), Color(0xFFFF8A65)), // Deep Orange + listOf(Color(0xFF4E342E), Color(0xFFA1887F)), // Brown + listOf(Color(0xFF4527A0), Color(0xFF9575CD)), // Deep Purple + listOf(Color(0xFF9E9D24), Color(0xFFDCE775)), // Lime + listOf(Color(0xFF37474F), Color(0xFF90A4AE)), // Cool Grey + listOf(Color(0xFFF57F17), Color(0xFFFFF176)), // Yellow + + // New Multi-Hue / Vibrant Gradients + listOf(Color(0xFF1A237E), Color(0xFF880E4F)), // Deep Blue to Deep Pink (Midnight) + listOf(Color(0xFFE65100), Color(0xFFE91E63)), // Dark Orange to Pink (Sunset) + listOf(Color(0xFF0277BD), Color(0xFF00897B)), // Light Blue to Teal (Ocean) + listOf(Color(0xFF303F9F), Color(0xFF7B1FA2)), // Indigo to Purple (Galaxy) + listOf(Color(0xFFBF360C), Color(0xFFFFCA28)), // Deep Red to Amber (Fire) + listOf(Color(0xFF004D40), Color(0xFF64FFDA)), // Dark Teal to Mint (Aqua) + listOf(Color(0xFF4A148C), Color(0xFFF50057)), // Dark Purple to Neon Pink (Cyberpunk) + listOf(Color(0xFF1B5E20), Color(0xFFC0CA33)), // Dark Green to Lime (Forest) + listOf(Color(0xFF827717), Color(0xFFFF9800)), // Olive to Orange (Autumn) + listOf(Color(0xFF01579B), Color(0xFF00E5FF)), // Navy to Neon Cyan (Electric Blue) + + // Pastel / Soft Gradients + listOf(Color(0xFF80DEEA), Color(0xFFE0F7FA)), // Soft Cyan + listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Soft Pink + listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Soft Purple + listOf(Color(0xFFA5D6A7), Color(0xFFE8F5E9)), // Soft Green + + listOf(Color(0xFF81D4FA), Color(0xFFE1F5FE)), // Light Sky Blue +listOf(Color(0xFFB39DDB), Color(0xFFEDE7F6)), // Soft Lavender +listOf(Color(0xFFFFCC80), Color(0xFFFFF3E0)), // Peach / Warm Sand +listOf(Color(0xFFA5D6A7), Color(0xFFF1F8E9)), // Pale Mint +listOf(Color(0xFFFFF59D), Color(0xFFFFFDE7)), // Soft Lemon +listOf(Color(0xFFFFAB91), Color(0xFFFBE9E7)), // Pale Coral +listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Light Orchid +listOf(Color(0xFFBCAAA4), Color(0xFFEFEBE9)), // Light Taupe / Oat +listOf(Color(0xFF90CAF9), Color(0xFFE3F2FD)), // Baby Blue +listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Rosewater + +// Soft Multi-Hue (Two-tone Pastels) +listOf(Color(0xFFE1BEE7), Color(0xFFBBDEFB)), // Light Purple to Light Blue (Cotton Candy) +listOf(Color(0xFFFFF9C4), Color(0xFFFFCCBC)), // Pale Yellow to Pale Peach (Morning Light) +listOf(Color(0xFFB2EBF2), Color(0xFFC8E6C9)), // Pale Cyan to Pale Green (Seafoam) +listOf(Color(0xFFFFD54F), Color(0xFFFF8A65)), // Warm Sun to Soft Coral (Soft Sunset) +listOf(Color(0xFFD1C4E9), Color(0xFFF8BBD0)), // Periwinkle to Blush Pink (Twilight) +listOf(Color(0xFFC5E1A5), Color(0xFFFFF59D)), // Spring Green to Pale Yellow (Meadow) +listOf(Color(0xFF80CBC4), Color(0xFF81D4FA)), // Soft Teal to Light Blue (Glacier) +listOf(Color(0xFFF8BBD0), Color(0xFFFFE0B2)), // Soft Pink to Cream (Sorbet) + + ) private fun gradientForId(id: String): List = gradientPalette[abs(id.hashCode()) % gradientPalette.size] +// --------------------------------------------------------------------------- +// Translation cache - shared between PackCard and PackPreviewDialog, cleared on screen exit +// --------------------------------------------------------------------------- + +private class TranslationCache { + // Cache key: pack ID, value: Pair(translatedName, translatedDescription) + private val cache = mutableMapOf>() + + fun get(packId: String): Pair? = cache[packId] + + fun put(packId: String, translated: Pair) { + cache[packId] = translated + } + + fun clear() { + cache.clear() + } +} + +@Composable +private fun rememberTranslationCache(): TranslationCache { + val cache = remember { TranslationCache() } + + // Clear cache when leaving the screen + DisposableEffect(Unit) { + onDispose { + cache.clear() + } + } + + return cache +} + +// --------------------------------------------------------------------------- +// Translation helper - translates pack name and description from English to device's locale using LibreTranslate +// --------------------------------------------------------------------------- + +@Composable +private fun rememberTranslatedPackInfo( + info: eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo, + translationViewModel: TranslationViewModel, + cache: TranslationCache +): Pair { + // Check cache first + val cached = cache.get(info.id) + if (cached != null) { + return cached + } + + var translatedName by remember(info.id) { mutableStateOf(info.name) } + var translatedDescription by remember(info.id) { mutableStateOf(info.description) } + + // Get device locale using Locale.getDefault() which is more reliable + val deviceLocale = remember { java.util.Locale.getDefault().language } + + // Launch translation when language or content changes + LaunchedEffect(info.name, info.description, deviceLocale) { + try { + // Always translate from English to device locale + val targetCode = deviceLocale + + var finalName = info.name + var finalDescription = info.description + + // Translate name if not empty using LibreTranslate directly + if (info.name.isNotBlank()) { + val nameResult = translationViewModel.translateWithLibreTranslate(info.name, targetCode, "en") + if (nameResult.isSuccess) { + finalName = nameResult.getOrNull() ?: info.name + } + } + + // Translate description if not empty using LibreTranslate directly + if (info.description.isNotBlank()) { + val descResult = translationViewModel.translateWithLibreTranslate(info.description, targetCode, "en") + if (descResult.isSuccess) { + finalDescription = descResult.getOrNull() ?: info.description + } + } + + // Update state + translatedName = finalName + translatedDescription = finalDescription + + // Store in cache + cache.put(info.id, Pair(finalName, finalDescription)) + } catch (e: Exception) { + Log.e(TAG, "Translation failed for pack ${info.id}: ${e.message}") + // Keep original text on failure + } + } + + return Pair(translatedName, translatedDescription) +} + // --------------------------------------------------------------------------- // Screen // --------------------------------------------------------------------------- @@ -154,6 +304,7 @@ fun ExplorePacksScreen( val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel() val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity) val packs by vocabPacksViewModel.packs.collectAsState() val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState() @@ -184,6 +335,9 @@ fun ExplorePacksScreen( } } + // Translation cache - shared between PackCard and PackPreviewDialog + val translationCache = rememberTranslationCache() + // Auto-open conflict dialog once a queued download finishes LaunchedEffect(packs, pendingImportPackId) { val id = pendingImportPackId ?: return@LaunchedEffect @@ -228,10 +382,10 @@ fun ExplorePacksScreen( } } - // Filtered + sorted pack list + // Filtered + sorted pack list - also search through translated names val filteredPacks = remember( packs, selectedFilter, searchQuery, - selectedSourceLanguage, selectedTargetLanguage + selectedSourceLanguage, selectedTargetLanguage, translationCache ) { val srcId = selectedSourceLanguage?.nameResId val tgtId = selectedTargetLanguage?.nameResId @@ -248,9 +402,16 @@ fun ExplorePacksScreen( (tgtId == null || ids.contains(tgtId)) } - val matchSearch = searchQuery.isBlank() || + // Search in both original English and translated names + val translated = translationCache.get(info.id) + val matchSearch = if (searchQuery.isBlank()) { + true + } else { info.name.contains(searchQuery, ignoreCase = true) || - info.category.contains(searchQuery, ignoreCase = true) + info.category.contains(searchQuery, ignoreCase = true) || + (translated?.first?.contains(searchQuery, ignoreCase = true) == true) || + (translated?.second?.contains(searchQuery, ignoreCase = true) == true) + } val matchFilter = when (val code = selectedFilter.cefrCode) { null -> true // All or Newest – handled by sort below @@ -450,6 +611,9 @@ fun ExplorePacksScreen( items(filteredPacks, key = { it.info.id }) { packState -> PackCard( packState = packState, + languageViewModel = languageViewModel, + translationViewModel = translationViewModel, + translationCache = translationCache, onCardClick = { previewPack = packState when (packState.downloadState) { @@ -509,6 +673,9 @@ fun ExplorePacksScreen( if (preview != null) { PackPreviewDialog( packState = preview, + languageViewModel = languageViewModel, + translationViewModel = translationViewModel, + translationCache = translationCache, onDismiss = { previewPack = null }, onGetClick = { pendingImportPackId = preview.info.id @@ -665,12 +832,15 @@ private fun PackConflictStrategyOption( @Composable private fun PackCard( packState: PackUiState, + languageViewModel: LanguageViewModel, + translationViewModel: TranslationViewModel, + translationCache: TranslationCache, onCardClick: () -> Unit, onGetClick: () -> Unit, onAddToLibraryClick: () -> Unit, modifier: Modifier = Modifier, - languageViewModel: LanguageViewModel = hiltViewModel(), ) { + val context = LocalContext.current val info = packState.info val gradient = gradientForId(info.id) @@ -683,6 +853,9 @@ private fun PackCard( .joinToString(" ⇆ ") .ifEmpty { info.category } + // Get translated name and description + val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache) + Surface( modifier = modifier .fillMaxWidth() @@ -788,7 +961,7 @@ private fun PackCard( // ── Pack info ───────────────────────────────────────────────────── Column(modifier = Modifier.padding(10.dp)) { Text( - text = info.name, + text = translatedName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, maxLines = 2 @@ -893,6 +1066,9 @@ private fun PackCard( @Composable private fun PackPreviewDialog( packState: PackUiState, + languageViewModel: LanguageViewModel, + translationViewModel: TranslationViewModel, + translationCache: TranslationCache, onDismiss: () -> Unit, onGetClick: () -> Unit, onAddToLibraryClick: () -> Unit, @@ -902,9 +1078,12 @@ private fun PackPreviewDialog( val gradient = gradientForId(info.id) var selectedItem by remember { mutableStateOf(null) } + // Get translated name and description + val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache) + AppDialog( onDismissRequest = onDismiss, - title = { Text(info.name, fontWeight = FontWeight.Bold) }, + title = { Text(translatedName, fontWeight = FontWeight.Bold) }, ) { // ── Gradient banner ─────────────────────────────────────────── Box( @@ -932,18 +1111,18 @@ private fun PackPreviewDialog( Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) { Text(info.emoji, fontSize = 32.sp) Text( - "${info.category} · ${stringResource(R.string.text_d_cards, info.itemCount)}", - style = MaterialTheme.typography.labelMedium, + "$translatedName · ${stringResource(R.string.text_d_cards, info.itemCount)}", + style = MaterialTheme.typography.labelLarge, color = Color.White.copy(alpha = 0.85f) ) } } // ── Description ─────────────────────────────────────────────── - if (info.description.isNotBlank()) { + if (translatedDescription.isNotBlank()) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = info.description, + text = translatedDescription, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt index 01ab571..d6699de 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt @@ -177,6 +177,17 @@ class TranslationViewModel @Inject constructor( } } + // Direct LibreTranslate without AI - for translating pack names/descriptions + suspend fun translateWithLibreTranslate(text: String, targetLanguageCode: String, sourceLanguageCode: String?): Result { + // If source and target are the same, return the original text without calling the API + val sourceCode = sourceLanguageCode?.lowercase() ?: "en" + val targetCode = targetLanguageCode.lowercase() + if (sourceCode == targetCode) { + return Result.success(text) + } + return translationService.libreTranslate(text, sourceLanguageCode, targetLanguageCode, 1) + } + suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result> { return translationService.getMultipleSynonyms(sentence, contextPhrase) .also { result ->