implement automated translation and caching for vocabulary pack names and descriptions in ExplorePacksScreen using LibreTranslate.
This commit is contained in:
@@ -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 {
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("q", text)
|
||||
|
||||
@@ -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<Color> =
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<VocabularyItem?>(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
|
||||
)
|
||||
|
||||
@@ -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>> {
|
||||
return translationService.getMultipleSynonyms(sentence, contextPhrase)
|
||||
.also { result ->
|
||||
|
||||
Reference in New Issue
Block a user