implement automated translation and caching for vocabulary pack names and descriptions in ExplorePacksScreen using LibreTranslate.

This commit is contained in:
jonasgaudian
2026-02-19 18:37:53 +01:00
parent d6a9ccf4e3
commit 95dfd3c7eb
3 changed files with 212 additions and 20 deletions

View File

@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
} }
} }
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) { // Public method to directly use LibreTranslate (bypasses AI)
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
Log.d("libreTranslate: $text, $source, $target")
try { try {
val json = org.json.JSONObject().apply { val json = org.json.JSONObject().apply {
put("q", text) put("q", text)

View File

@@ -89,6 +89,7 @@ import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.PackDownloadState import eu.gaudian.translator.viewmodel.PackDownloadState
import eu.gaudian.translator.viewmodel.PackUiState import eu.gaudian.translator.viewmodel.PackUiState
import eu.gaudian.translator.viewmodel.TranslationViewModel
import eu.gaudian.translator.viewmodel.VocabPacksViewModel import eu.gaudian.translator.viewmodel.VocabPacksViewModel
import kotlin.math.abs import kotlin.math.abs
@@ -125,19 +126,168 @@ enum class PackFilter {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private val gradientPalette = listOf( private val gradientPalette = listOf(
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), // Original Gradients
listOf(Color(0xFF00695C), Color(0xFF26A69A)), listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), // Blue
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), listOf(Color(0xFF00695C), Color(0xFF26A69A)), // Teal
listOf(Color(0xFFE65100), Color(0xFFFFA726)), listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), // Purple
listOf(Color(0xFF212121), Color(0xFF546E7A)), listOf(Color(0xFFE65100), Color(0xFFFFA726)), // Orange
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), listOf(Color(0xFF212121), Color(0xFF546E7A)), // Dark Grey to Blue Grey
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)), listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), // Red
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)), 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<Color> = private fun gradientForId(id: String): List<Color> =
gradientPalette[abs(id.hashCode()) % gradientPalette.size] 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<String, Pair<String, String>>()
fun get(packId: String): Pair<String, String>? = cache[packId]
fun put(packId: String, translated: Pair<String, String>) {
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<String, String> {
// 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 // Screen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -154,6 +304,7 @@ fun ExplorePacksScreen(
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel() val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity) val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity)
val packs by vocabPacksViewModel.packs.collectAsState() val packs by vocabPacksViewModel.packs.collectAsState()
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.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 // Auto-open conflict dialog once a queued download finishes
LaunchedEffect(packs, pendingImportPackId) { LaunchedEffect(packs, pendingImportPackId) {
val id = pendingImportPackId ?: return@LaunchedEffect 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( val filteredPacks = remember(
packs, selectedFilter, searchQuery, packs, selectedFilter, searchQuery,
selectedSourceLanguage, selectedTargetLanguage selectedSourceLanguage, selectedTargetLanguage, translationCache
) { ) {
val srcId = selectedSourceLanguage?.nameResId val srcId = selectedSourceLanguage?.nameResId
val tgtId = selectedTargetLanguage?.nameResId val tgtId = selectedTargetLanguage?.nameResId
@@ -248,9 +402,16 @@ fun ExplorePacksScreen(
(tgtId == null || ids.contains(tgtId)) (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.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) { val matchFilter = when (val code = selectedFilter.cefrCode) {
null -> true // All or Newest handled by sort below null -> true // All or Newest handled by sort below
@@ -450,6 +611,9 @@ fun ExplorePacksScreen(
items(filteredPacks, key = { it.info.id }) { packState -> items(filteredPacks, key = { it.info.id }) { packState ->
PackCard( PackCard(
packState = packState, packState = packState,
languageViewModel = languageViewModel,
translationViewModel = translationViewModel,
translationCache = translationCache,
onCardClick = { onCardClick = {
previewPack = packState previewPack = packState
when (packState.downloadState) { when (packState.downloadState) {
@@ -509,6 +673,9 @@ fun ExplorePacksScreen(
if (preview != null) { if (preview != null) {
PackPreviewDialog( PackPreviewDialog(
packState = preview, packState = preview,
languageViewModel = languageViewModel,
translationViewModel = translationViewModel,
translationCache = translationCache,
onDismiss = { previewPack = null }, onDismiss = { previewPack = null },
onGetClick = { onGetClick = {
pendingImportPackId = preview.info.id pendingImportPackId = preview.info.id
@@ -665,12 +832,15 @@ private fun PackConflictStrategyOption(
@Composable @Composable
private fun PackCard( private fun PackCard(
packState: PackUiState, packState: PackUiState,
languageViewModel: LanguageViewModel,
translationViewModel: TranslationViewModel,
translationCache: TranslationCache,
onCardClick: () -> Unit, onCardClick: () -> Unit,
onGetClick: () -> Unit, onGetClick: () -> Unit,
onAddToLibraryClick: () -> Unit, onAddToLibraryClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
languageViewModel: LanguageViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val info = packState.info val info = packState.info
val gradient = gradientForId(info.id) val gradient = gradientForId(info.id)
@@ -683,6 +853,9 @@ private fun PackCard(
.joinToString("") .joinToString("")
.ifEmpty { info.category } .ifEmpty { info.category }
// Get translated name and description
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -788,7 +961,7 @@ private fun PackCard(
// ── Pack info ───────────────────────────────────────────────────── // ── Pack info ─────────────────────────────────────────────────────
Column(modifier = Modifier.padding(10.dp)) { Column(modifier = Modifier.padding(10.dp)) {
Text( Text(
text = info.name, text = translatedName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2 maxLines = 2
@@ -893,6 +1066,9 @@ private fun PackCard(
@Composable @Composable
private fun PackPreviewDialog( private fun PackPreviewDialog(
packState: PackUiState, packState: PackUiState,
languageViewModel: LanguageViewModel,
translationViewModel: TranslationViewModel,
translationCache: TranslationCache,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onGetClick: () -> Unit, onGetClick: () -> Unit,
onAddToLibraryClick: () -> Unit, onAddToLibraryClick: () -> Unit,
@@ -902,9 +1078,12 @@ private fun PackPreviewDialog(
val gradient = gradientForId(info.id) val gradient = gradientForId(info.id)
var selectedItem by remember { mutableStateOf<VocabularyItem?>(null) } var selectedItem by remember { mutableStateOf<VocabularyItem?>(null) }
// Get translated name and description
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
AppDialog( AppDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(info.name, fontWeight = FontWeight.Bold) }, title = { Text(translatedName, fontWeight = FontWeight.Bold) },
) { ) {
// ── Gradient banner ─────────────────────────────────────────── // ── Gradient banner ───────────────────────────────────────────
Box( Box(
@@ -932,18 +1111,18 @@ private fun PackPreviewDialog(
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) { Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
Text(info.emoji, fontSize = 32.sp) Text(info.emoji, fontSize = 32.sp)
Text( Text(
"${info.category} · ${stringResource(R.string.text_d_cards, info.itemCount)}", "$translatedName · ${stringResource(R.string.text_d_cards, info.itemCount)}",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.85f) color = Color.White.copy(alpha = 0.85f)
) )
} }
} }
// ── Description ─────────────────────────────────────────────── // ── Description ───────────────────────────────────────────────
if (info.description.isNotBlank()) { if (translatedDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = info.description, text = translatedDescription,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -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<String> {
// 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<List<String>> { suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result<List<String>> {
return translationService.getMultipleSynonyms(sentence, contextPhrase) return translationService.getMultipleSynonyms(sentence, contextPhrase)
.also { result -> .also { result ->