refactor(translation): split UI from orchestration, dedupe boilerplate

Pure-readability refactor — no behaviour change, all 410 unit tests still pass.

TranslatableRichTextViewer.kt (358 → 192 lines)
- Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation
  bridge + result validation + caching) into a private `suspend translateAndCache`
  function. The effect body is now four lines: try/catch around one call.
- Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a
  TranslationConfig only when an actual translation took place, replacing the
  five-condition inline if/else inside the effect.
- Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling
  file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer
  and don't belong in the orchestrator file.

TranslationStatusBar.kt (new)
- Renamed the public composable to `TranslationStatusBar` to make its role obvious.
- Split the status text and the dropdown into separate private composables so each
  fits on screen at a glance.
- Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four
  `DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks.
- Hoist `rememberDeviceLocales()` out of the dropdown body for clarity.
- Cache `settings.preferenceBetween(source, target)` once per dropdown render
  instead of calling it twice with identical args.

LanguageTranslatorService.kt
- Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)`
  helper. `autoTranslate` is now three lines that read top-to-bottom:
  pre-filter, dedupe, identifyLanguage → translateOrSkip.
- Promote the inline `when` deciding whether to translate (matches translateTo,
  is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so
  the policy is greppable.

TranslationDictionary.kt
- Add a `private inline fun Pattern.forEachMatch(text, block)` extension.
  The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())`
  loops collapse to three one-liners; the URL detector loop stays explicit because
  it has its own filter.

`inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is
a zero-cost refactor at runtime.

https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
This commit is contained in:
Claude
2026-04-26 13:54:18 +00:00
parent 91d194b11e
commit 63b20b88d0
4 changed files with 290 additions and 223 deletions
@@ -128,22 +128,34 @@ object LanguageTranslatorService {
translateTo: String,
): Task<ResultOrError> {
if (!TranslationDictionary.isWorthTranslating(text)) return Tasks.forCanceled()
val key = InFlightKey(text, translateTo, dontTranslateFrom)
inFlight[key]?.let { return it }
val task =
return dedupe(InFlightKey(text, translateTo, dontTranslateFrom)) {
identifyLanguage(text).onSuccessTask(executorService) { detected ->
when {
detected == "und" -> Tasks.forCanceled()
detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled()
detected in dontTranslateFrom -> Tasks.forCanceled()
else -> translate(text, detected, translateTo)
}
translateOrSkip(text, detected, dontTranslateFrom, translateTo)
}
}
}
private fun translateOrSkip(
text: String,
detected: String,
dontTranslateFrom: Set<String>,
translateTo: String,
): Task<ResultOrError> =
when {
detected == "und" -> Tasks.forCanceled()
detected.equals(translateTo, ignoreCase = true) -> Tasks.forCanceled()
detected in dontTranslateFrom -> Tasks.forCanceled()
else -> translate(text, detected, translateTo)
}
private inline fun dedupe(
key: InFlightKey,
factory: () -> Task<ResultOrError>,
): Task<ResultOrError> {
inFlight[key]?.let { return it }
val candidate = factory()
// putIfAbsent guards against a racing caller: keep the winner, drop the loser.
val winner = inFlight.putIfAbsent(key, task) ?: task
val winner = inFlight.putIfAbsent(key, candidate) ?: candidate
winner.addOnCompleteListener(executorService) { inFlight.remove(key, winner) }
return winner
}
@@ -70,14 +70,9 @@ internal object TranslationDictionary {
dict[placeholder(counter++)] = value
}
val lnMatcher = lnRegex.matcher(text)
while (lnMatcher.find()) addUnique(lnMatcher.group())
val tagMatcher = tagRegex.matcher(text)
while (tagMatcher.find()) addUnique(tagMatcher.group())
val nip08Matcher = nip08RefRegex.matcher(text)
while (nip08Matcher.find()) addUnique(nip08Matcher.group())
lnRegex.forEachMatch(text, ::addUnique)
tagRegex.forEachMatch(text, ::addUnique)
nip08RefRegex.forEachMatch(text, ::addUnique)
for (url in UrlDetector(text).detect()) {
val original = url.originalUrl
@@ -89,6 +84,14 @@ internal object TranslationDictionary {
return dict
}
private inline fun Pattern.forEachMatch(
text: String,
block: (String) -> Unit,
) {
val matcher = matcher(text)
while (matcher.find()) block(matcher.group())
}
fun encode(
text: String,
dict: Map<String, String>,
@@ -20,17 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -38,31 +28,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.service.lang.TranslationsCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.tasks.await
import java.util.Locale
import kotlin.coroutines.coroutineContext
@Composable
fun TranslatableRichTextViewer(
@@ -119,51 +98,13 @@ fun TranslatableRichTextViewer(
}
LaunchedEffect(content, translateTo, dontTranslateFrom) {
TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let {
translatedTextState.value = it
return@LaunchedEffect
}
val noOp = TranslationConfig(content, null, null)
try {
val task = LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo)
// ML Kit cancels the task to signal "no translation needed" (same language, "und",
// blocklisted). await() bridges that into a CancellationException; cache the no-op so
// we don't re-run language identification next time the same text scrolls into view.
val raw =
try {
task.await()
} catch (e: CancellationException) {
coroutineContext.ensureActive()
TranslationsCache.set(content, translateTo, dontTranslateFrom, noOp)
translatedTextState.value = noOp
return@LaunchedEffect
}
coroutineContext.ensureActive()
val translated = raw.result
val source = raw.sourceLang
val target = raw.targetLang
val newConfig =
if (
translated != null &&
source != null &&
target != null &&
source != target &&
translated != content
) {
TranslationConfig(translated, source, target)
} else {
noOp
}
TranslationsCache.set(content, translateTo, dontTranslateFrom, newConfig)
translatedTextState.value = newConfig
translatedTextState.value = translateAndCache(content, translateTo, dontTranslateFrom)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Network / model download / translator failure — keep showing the original. Do not
// cache: a transient failure shouldn't block future attempts on the same text.
// Transient ML Kit / network failure — keep showing the original. Do not cache: a
// one-off failure shouldn't block future attempts on the same text.
}
}
@@ -202,7 +143,7 @@ private fun RenderTextWithTranslateOptions(
displayText(toBeViewed)
if (translationOccurred) {
TranslationMessage(
TranslationStatusBar(
source = source,
target = target,
modifier = translationMessageModifier,
@@ -212,146 +153,40 @@ private fun RenderTextWithTranslateOptions(
}
}
@Composable
private fun TranslationMessage(
source: String,
target: String,
modifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
onChangeWhatToShow: (Boolean) -> Unit,
) {
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
/**
* Returns the translation for [content] under the current language settings, hitting the cache
* first and falling back to ML Kit. ML Kit's "no translation needed" cancellation (same language,
* undetected, blocklisted) is bridged into a no-op [TranslationConfig] that is itself cached, so
* the same text scrolling back into view doesn't re-run language identification.
*/
private suspend fun translateAndCache(
content: String,
translateTo: String,
dontTranslateFrom: Set<String>,
): TranslationConfig {
TranslationsCache.get(content, translateTo, dontTranslateFrom)?.let { return it }
val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName }
val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName }
val autoLabel = stringRes(R.string.translations_auto)
val translatedFromLabel = stringRes(R.string.translations_translated_from)
val toLabel = stringRes(R.string.translations_to)
Row(modifier = modifier) {
val textColor = MaterialTheme.colorScheme.lessImportantLink
Text(
text =
buildAnnotatedString {
appendLink(autoLabel, textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded }
append(" $translatedFromLabel ")
appendLink(sourceDisplay, textColor) { onChangeWhatToShow(true) }
append(" $toLabel ")
appendLink(targetDisplay, textColor) { onChangeWhatToShow(false) }
},
style =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f),
fontSize = Font14SP,
),
overflow = TextOverflow.Visible,
maxLines = 3,
)
if (langSettingsPopupExpanded) {
LangSettingsDropdown(
expanded = true,
source = source,
target = target,
sourceDisplay = sourceDisplay,
targetDisplay = targetDisplay,
accountViewModel = accountViewModel,
onDismiss = { langSettingsPopupExpanded = false },
)
val noOp = TranslationConfig(content, null, null)
val raw =
try {
LanguageTranslatorService.autoTranslate(content, dontTranslateFrom, translateTo).await()
} catch (e: CancellationException) {
// If our coroutine is the cancelled one, propagate; otherwise it's ML Kit signalling
// "no translation needed" — cache the no-op and return it.
coroutineContext.ensureActive()
return noOp.also { TranslationsCache.set(content, translateTo, dontTranslateFrom, it) }
}
}
coroutineContext.ensureActive()
val config = raw.toTranslationConfig(content) ?: noOp
TranslationsCache.set(content, translateTo, dontTranslateFrom, config)
return config
}
@Composable
private fun LangSettingsDropdown(
expanded: Boolean,
source: String,
target: String,
sourceDisplay: String,
targetDisplay: String,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val deviceLocales =
remember {
val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
(0 until list.size()).mapNotNull { list.get(it) }
}
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
text = {
CheckmarkRow(
checked = source in accountViewModel.dontTranslateFrom(),
label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay),
)
},
onClick = {
accountViewModel.toggleDontTranslateFrom(source)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.preferenceBetween(source, target) == source,
label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay),
)
},
onClick = {
accountViewModel.prefer(source, target, source)
onDismiss()
},
)
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.preferenceBetween(source, target) == target,
label = stringRes(R.string.translations_show_in_lang_first, targetDisplay),
)
},
onClick = {
accountViewModel.prefer(source, target, target)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
for (lang in deviceLocales) {
DropdownMenuItem(
text = {
CheckmarkRow(
checked = accountViewModel.account.settings.translateToContains(lang.language),
label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName),
)
},
onClick = {
onDismiss()
accountViewModel.updateTranslateTo(lang.language)
},
)
}
}
}
@Composable
private fun CheckmarkRow(
checked: Boolean,
label: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (checked) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(label)
}
private fun ResultOrError.toTranslationConfig(content: String): TranslationConfig? {
val translated = result ?: return null
val source = sourceLang ?: return null
val target = targetLang ?: return null
if (source == target || translated == content) return null
return TranslationConfig(translated, source, target)
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import java.util.Locale
/**
* The "Auto-translated from X to Y" footer shown beneath translated rich text. Tapping the source
* or target labels toggles which version is displayed; tapping "Auto-translated" opens the
* per-language preferences dropdown.
*/
@Composable
internal fun TranslationStatusBar(
source: String,
target: String,
modifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
onShowOriginalChange: (Boolean) -> Unit,
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val sourceDisplay = remember(source) { Locale.forLanguageTag(source).displayName }
val targetDisplay = remember(target) { Locale.forLanguageTag(target).displayName }
Row(modifier = modifier) {
TranslationStatusText(
sourceDisplay = sourceDisplay,
targetDisplay = targetDisplay,
onAutoLabelClick = { dropdownExpanded = !dropdownExpanded },
onSourceLabelClick = { onShowOriginalChange(true) },
onTargetLabelClick = { onShowOriginalChange(false) },
)
if (dropdownExpanded) {
LangSettingsDropdown(
source = source,
target = target,
sourceDisplay = sourceDisplay,
targetDisplay = targetDisplay,
accountViewModel = accountViewModel,
onDismiss = { dropdownExpanded = false },
)
}
}
}
@Composable
private fun TranslationStatusText(
sourceDisplay: String,
targetDisplay: String,
onAutoLabelClick: () -> Unit,
onSourceLabelClick: () -> Unit,
onTargetLabelClick: () -> Unit,
) {
val textColor = MaterialTheme.colorScheme.lessImportantLink
val autoLabel = stringRes(R.string.translations_auto)
val translatedFromLabel = stringRes(R.string.translations_translated_from)
val toLabel = stringRes(R.string.translations_to)
Text(
text =
buildAnnotatedString {
appendLink(autoLabel, textColor, onAutoLabelClick)
append(" $translatedFromLabel ")
appendLink(sourceDisplay, textColor, onSourceLabelClick)
append(" $toLabel ")
appendLink(targetDisplay, textColor, onTargetLabelClick)
},
style =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f),
fontSize = Font14SP,
),
overflow = TextOverflow.Visible,
maxLines = 3,
)
}
@Composable
private fun LangSettingsDropdown(
source: String,
target: String,
sourceDisplay: String,
targetDisplay: String,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val deviceLocales = rememberDeviceLocales()
val settings = accountViewModel.account.settings
val preferenceForPair = settings.preferenceBetween(source, target)
DropdownMenu(expanded = true, onDismissRequest = onDismiss) {
LangMenuItem(
checked = source in accountViewModel.dontTranslateFrom(),
label = stringRes(R.string.translations_never_translate_from_lang, sourceDisplay),
onClick = {
accountViewModel.toggleDontTranslateFrom(source)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
LangMenuItem(
checked = preferenceForPair == source,
label = stringRes(R.string.translations_show_in_lang_first, sourceDisplay),
onClick = {
accountViewModel.prefer(source, target, source)
onDismiss()
},
)
LangMenuItem(
checked = preferenceForPair == target,
label = stringRes(R.string.translations_show_in_lang_first, targetDisplay),
onClick = {
accountViewModel.prefer(source, target, target)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
for (lang in deviceLocales) {
LangMenuItem(
checked = settings.translateToContains(lang.language),
label = stringRes(R.string.translations_always_translate_to_lang, lang.displayName),
onClick = {
onDismiss()
accountViewModel.updateTranslateTo(lang.language)
},
)
}
}
}
@Composable
private fun rememberDeviceLocales(): List<Locale> =
remember {
val list = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
(0 until list.size()).mapNotNull { list.get(it) }
}
@Composable
private fun LangMenuItem(
checked: Boolean,
label: String,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { CheckmarkRow(checked, label) },
onClick = onClick,
)
}
@Composable
private fun CheckmarkRow(
checked: Boolean,
label: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (checked) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(label)
}
}