Skip to content

Commit b4bb08e

Browse files
committed
feat: enhance translation UI and caching logic
- Redesign `TranslationControls` with a pill-shaped layout, `AnimatedContent` transitions, and specific visual states for idle, translating, success, and error. - Improve `LanguagePicker` by adding a device language shortcut, search field styling, and better list item visuals. - Implement more robust translation caching in `TranslationRepositoryImpl` with a time-to-live (TTL) of 30 minutes and a more stable cache key logic. - Add `AnimatedContent` to "About" and "What's New" sections for smoother transitions when switching between original and translated text. - Update `DetailsViewModel` to manage translation jobs effectively, ensuring previous requests are cancelled when a new translation or release selection is triggered. - Add new string resources for translation error handling and language selection.
1 parent 3d8f6a8 commit b4bb08e

9 files changed

Lines changed: 478 additions & 204 deletions

File tree

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,15 @@
450450
<!-- Translation feature -->
451451
<string name="translate">Translate</string>
452452
<string name="translating">Translating…</string>
453-
<string name="show_original">Show Original</string>
453+
<string name="show_original">Show original</string>
454454
<string name="translated_to">Translated to %1$s</string>
455455
<string name="translate_to">Translate to…</string>
456456
<string name="search_language">Search language</string>
457457
<string name="change_language">Change language</string>
458458
<string name="translation_failed">Translation failed. Please try again.</string>
459+
<string name="translation_error_retry">Retry</string>
460+
<string name="translated_from">Auto-detected: %1$s</string>
461+
<string name="select_language">Select language</string>
459462

460463
<!-- Search - GitHub Link -->
461464
<string name="open_github_link">Open GitHub Link</string>

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import zed.rainxch.core.data.services.LocalizationManager
1414
import zed.rainxch.core.domain.model.ProxyConfig
1515
import zed.rainxch.details.domain.model.TranslationResult
1616
import zed.rainxch.details.domain.repository.TranslationRepository
17+
import kotlin.time.Clock
18+
import kotlin.time.ExperimentalTime
1719

1820
class TranslationRepositoryImpl(
1921
private val localizationManager: LocalizationManager,
20-
) : TranslationRepository,
21-
AutoCloseable {
22+
) : TranslationRepository {
2223
private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None)
2324

2425
private val json =
@@ -28,21 +29,26 @@ class TranslationRepositoryImpl(
2829
}
2930

3031
private val cacheMutex = Mutex()
31-
private val cache = LinkedHashMap<String, TranslationResult>(50, 0.75f, true)
32-
private val maxCacheSize = 50
32+
private val cache = LinkedHashMap<String, CachedTranslation>(MAX_CACHE_SIZE, 0.75f, true)
3333
private val maxChunkSize = 4500
3434

35+
@OptIn(ExperimentalTime::class)
3536
override suspend fun translate(
3637
text: String,
3738
targetLanguage: String,
3839
sourceLanguage: String,
3940
): TranslationResult {
40-
val cacheKey = "${text.hashCode()}:$targetLanguage"
41-
cacheMutex.withLock { cache[cacheKey] }?.let { return it }
41+
val cacheKey = buildCacheKey(text, targetLanguage)
42+
43+
cacheMutex.withLock {
44+
cache[cacheKey]?.let { cached ->
45+
if (!cached.isExpired()) return cached.result
46+
cache.remove(cacheKey)
47+
}
48+
}
4249

4350
val chunks = chunkText(text)
4451
val translatedParts = mutableListOf<Pair<String, String>>()
45-
4652
var detectedLang: String? = null
4753

4854
for ((chunkText, delimiter) in chunks) {
@@ -64,17 +70,21 @@ class TranslationRepositoryImpl(
6470
)
6571

6672
cacheMutex.withLock {
67-
if (cache.size >= maxCacheSize) {
73+
if (cache.size >= MAX_CACHE_SIZE) {
6874
val firstKey = cache.keys.first()
6975
cache.remove(firstKey)
7076
}
71-
cache[cacheKey] = result
77+
cache[cacheKey] = CachedTranslation(result)
7278
}
7379
return result
7480
}
7581

7682
override fun getDeviceLanguageCode(): String = localizationManager.getPrimaryLanguageCode()
7783

84+
override fun clearCache() {
85+
cache.clear()
86+
}
87+
7888
private suspend fun translateSingleChunk(
7989
text: String,
8090
targetLanguage: String,
@@ -92,14 +102,7 @@ class TranslationRepositoryImpl(
92102
parameter("q", text)
93103
}.bodyAsText()
94104

95-
return try {
96-
parseTranslationResponse(responseText)
97-
} catch (_: Exception) {
98-
TranslationResult(
99-
translatedText = text,
100-
detectedSourceLanguage = null,
101-
)
102-
}
105+
return parseTranslationResponse(responseText)
103106
}
104107

105108
private fun parseTranslationResponse(responseText: String): TranslationResult {
@@ -187,7 +190,27 @@ class TranslationRepositoryImpl(
187190
}
188191
}
189192

190-
override fun close() {
191-
httpClient.close()
193+
companion object {
194+
private const val MAX_CACHE_SIZE = 50
195+
private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes
196+
197+
/**
198+
* Build a stable cache key using the first/last 100 chars + length + target language.
199+
* This avoids hashCode collisions while keeping the key compact.
200+
*/
201+
private fun buildCacheKey(text: String, targetLanguage: String): String {
202+
val prefix = text.take(100)
203+
val suffix = text.takeLast(100)
204+
return "$prefix|$suffix|${text.length}:$targetLanguage"
205+
}
206+
}
207+
208+
@OptIn(ExperimentalTime::class)
209+
private class CachedTranslation(
210+
val result: TranslationResult,
211+
private val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
212+
) {
213+
fun isExpired(): Boolean =
214+
Clock.System.now().toEpochMilliseconds() - timestamp > CACHE_TTL_MS
192215
}
193216
}

feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ interface TranslationRepository {
1010
): TranslationResult
1111

1212
fun getDeviceLanguageCode(): String
13+
14+
fun clearCache()
1315
}

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState
7575
import zed.rainxch.githubstore.core.presentation.res.Res
7676
import zed.rainxch.githubstore.core.presentation.res.add_to_favourites
7777
import zed.rainxch.githubstore.core.presentation.res.cancel
78+
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message
79+
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title
7880
import zed.rainxch.githubstore.core.presentation.res.dismiss
7981
import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall
8082
import zed.rainxch.githubstore.core.presentation.res.downgrade_warning_message
@@ -88,6 +90,7 @@ import zed.rainxch.githubstore.core.presentation.res.repository_not_starred
8890
import zed.rainxch.githubstore.core.presentation.res.repository_starred
8991
import zed.rainxch.githubstore.core.presentation.res.share_repository
9092
import zed.rainxch.githubstore.core.presentation.res.star_from_github
93+
import zed.rainxch.githubstore.core.presentation.res.uninstall
9194
import zed.rainxch.githubstore.core.presentation.res.uninstall_first
9295
import zed.rainxch.githubstore.core.presentation.res.unstar_from_github
9396

@@ -300,6 +303,7 @@ fun DetailsScreen(
300303
TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode
301304
null -> null
302305
},
306+
deviceLanguageCode = state.deviceLanguageCode,
303307
onLanguageSelected = { language ->
304308
when (state.languagePickerTarget) {
305309
TranslationTarget.About -> {

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ class DetailsViewModel(
8585
private var hasLoadedInitialData = false
8686
private var currentDownloadJob: Job? = null
8787
private var currentAssetName: String? = null
88+
private var aboutTranslationJob: Job? = null
89+
private var whatsNewTranslationJob: Job? = null
8890

8991
private var cachedDownloadAssetName: String? = null
9092

@@ -725,19 +727,22 @@ class DetailsViewModel(
725727
val newSelected = filtered.firstOrNull()
726728
val (installable, primary) = recomputeAssetsForRelease(newSelected)
727729

730+
whatsNewTranslationJob?.cancel()
728731
_state.update {
729732
it.copy(
730733
selectedReleaseCategory = newCategory,
731734
selectedRelease = newSelected,
732735
installableAssets = installable,
733736
primaryAsset = primary,
737+
whatsNewTranslation = TranslationState(),
734738
)
735739
}
736740
}
737741

738742
is DetailsAction.SelectRelease -> {
739743
val release = action.release
740744
val (installable, primary) = recomputeAssetsForRelease(release)
745+
whatsNewTranslationJob?.cancel()
741746

742747
_state.update {
743748
it.copy(
@@ -770,7 +775,8 @@ class DetailsViewModel(
770775

771776
is DetailsAction.TranslateAbout -> {
772777
val readme = _state.value.readmeMarkdown ?: return
773-
translateContent(
778+
aboutTranslationJob?.cancel()
779+
aboutTranslationJob = translateContent(
774780
text = readme,
775781
targetLanguageCode = action.targetLanguageCode,
776782
updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } },
@@ -780,7 +786,8 @@ class DetailsViewModel(
780786

781787
is DetailsAction.TranslateWhatsNew -> {
782788
val description = _state.value.selectedRelease?.description ?: return
783-
translateContent(
789+
whatsNewTranslationJob?.cancel()
790+
whatsNewTranslationJob = translateContent(
784791
text = description,
785792
targetLanguageCode = action.targetLanguageCode,
786793
updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } },
@@ -1347,8 +1354,8 @@ class DetailsViewModel(
13471354
targetLanguageCode: String,
13481355
updateState: (TranslationState) -> Unit,
13491356
getCurrentState: () -> TranslationState,
1350-
) {
1351-
viewModelScope.launch {
1357+
): Job {
1358+
return viewModelScope.launch {
13521359
try {
13531360
updateState(
13541361
getCurrentState().copy(
@@ -1380,6 +1387,8 @@ class DetailsViewModel(
13801387
detectedSourceLanguage = result.detectedSourceLanguage,
13811388
),
13821389
)
1390+
} catch (e: CancellationException) {
1391+
throw e
13831392
} catch (e: Exception) {
13841393
logger.error("Translation failed: ${e.message}")
13851394
updateState(

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
package zed.rainxch.details.presentation.components
22

3+
import androidx.compose.foundation.background
34
import androidx.compose.foundation.clickable
45
import androidx.compose.foundation.layout.Arrangement
56
import androidx.compose.foundation.layout.Column
67
import androidx.compose.foundation.layout.PaddingValues
78
import androidx.compose.foundation.layout.Row
89
import androidx.compose.foundation.layout.Spacer
910
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.height
1012
import androidx.compose.foundation.layout.navigationBarsPadding
1113
import androidx.compose.foundation.layout.padding
1214
import androidx.compose.foundation.layout.size
1315
import androidx.compose.foundation.layout.width
1416
import androidx.compose.foundation.lazy.LazyColumn
1517
import androidx.compose.foundation.lazy.items
18+
import androidx.compose.foundation.shape.RoundedCornerShape
1619
import androidx.compose.material.icons.Icons
1720
import androidx.compose.material.icons.filled.CheckCircle
1821
import androidx.compose.material.icons.filled.Search
22+
import androidx.compose.material.icons.filled.Smartphone
1923
import androidx.compose.material3.ExperimentalMaterial3Api
2024
import androidx.compose.material3.HorizontalDivider
2125
import androidx.compose.material3.Icon
@@ -31,6 +35,7 @@ import androidx.compose.runtime.remember
3135
import androidx.compose.runtime.setValue
3236
import androidx.compose.ui.Alignment
3337
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.draw.clip
3439
import androidx.compose.ui.text.font.FontWeight
3540
import androidx.compose.ui.unit.dp
3641
import org.jetbrains.compose.resources.stringResource
@@ -43,6 +48,7 @@ import zed.rainxch.githubstore.core.presentation.res.*
4348
fun LanguagePicker(
4449
isVisible: Boolean,
4550
selectedLanguageCode: String?,
51+
deviceLanguageCode: String,
4652
onLanguageSelected: (SupportedLanguage) -> Unit,
4753
onDismiss: () -> Unit,
4854
) {
@@ -51,12 +57,17 @@ fun LanguagePicker(
5157
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
5258
var searchQuery by remember { mutableStateOf("") }
5359

60+
val deviceLanguage = remember(deviceLanguageCode) {
61+
SupportedLanguages.all.find { it.code == deviceLanguageCode }
62+
}
63+
5464
val filteredLanguages =
5565
remember(searchQuery) {
66+
val all = SupportedLanguages.all
5667
if (searchQuery.isBlank()) {
57-
SupportedLanguages.all
68+
all
5869
} else {
59-
SupportedLanguages.all.filter {
70+
all.filter {
6071
it.displayName.contains(searchQuery, ignoreCase = true) ||
6172
it.code.contains(searchQuery, ignoreCase = true)
6273
}
@@ -88,13 +99,60 @@ fun LanguagePicker(
8899
Icon(Icons.Default.Search, contentDescription = null)
89100
},
90101
singleLine = true,
102+
shape = RoundedCornerShape(12.dp),
91103
modifier =
92104
Modifier
93105
.fillMaxWidth()
94106
.padding(horizontal = 16.dp, vertical = 8.dp),
95107
)
96108

97-
HorizontalDivider()
109+
// Device language shortcut — only shown when not searching
110+
if (searchQuery.isBlank() && deviceLanguage != null) {
111+
Row(
112+
modifier =
113+
Modifier
114+
.fillMaxWidth()
115+
.padding(horizontal = 16.dp, vertical = 4.dp)
116+
.clip(RoundedCornerShape(12.dp))
117+
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
118+
.clickable { onLanguageSelected(deviceLanguage) }
119+
.padding(horizontal = 12.dp, vertical = 10.dp),
120+
verticalAlignment = Alignment.CenterVertically,
121+
) {
122+
Icon(
123+
imageVector = Icons.Default.Smartphone,
124+
contentDescription = null,
125+
tint = MaterialTheme.colorScheme.primary,
126+
modifier = Modifier.size(18.dp),
127+
)
128+
Spacer(Modifier.width(10.dp))
129+
Column(modifier = Modifier.weight(1f)) {
130+
Text(
131+
text = deviceLanguage.displayName,
132+
style = MaterialTheme.typography.titleSmall,
133+
fontWeight = FontWeight.SemiBold,
134+
color = MaterialTheme.colorScheme.primary,
135+
)
136+
Text(
137+
text = stringResource(Res.string.select_language),
138+
style = MaterialTheme.typography.labelSmall,
139+
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
140+
)
141+
}
142+
if (deviceLanguage.code == selectedLanguageCode) {
143+
Icon(
144+
imageVector = Icons.Default.CheckCircle,
145+
contentDescription = null,
146+
tint = MaterialTheme.colorScheme.primary,
147+
modifier = Modifier.size(20.dp),
148+
)
149+
}
150+
}
151+
152+
Spacer(Modifier.height(4.dp))
153+
}
154+
155+
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
98156

99157
LazyColumn(
100158
modifier = Modifier.fillMaxWidth(),

0 commit comments

Comments
 (0)