Skip to content

Commit 75a5796

Browse files
authored
Merge pull request #333 from OpenHub-Store/feat-translation
2 parents 3d8f6a8 + 9fa2432 commit 75a5796

10 files changed

Lines changed: 529 additions & 222 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
name: coding_boundaries
3+
description: User wants to write all non-trivial logic themselves — Claude should only review, suggest, and handle boilerplate
4+
type: feedback
5+
---
6+
7+
Never write the "hard parts" — architecture decisions, core business logic, state management patterns, bug fix implementations, algorithm design. Instead, review the user's code, point out issues, suggest approaches, and explain tradeoffs. Let the user implement it.
8+
9+
**Why:** The user noticed their coding instincts and skills declining from over-delegating to Claude. They want to stay sharp by doing the thinking and implementation themselves.
10+
11+
**How to apply:**
12+
- **Hard parts** (user codes): ViewModel logic, repository implementations, state flows, bug fixes, architectural patterns, cache strategies, concurrency handling, UI interaction logic. For these — review, suggest, explain, but don't write the code.
13+
- **Boilerplate** (Claude codes): repetitive refactors, string resources, migration scaffolding, import fixes, build config, copy-paste patterns, test scaffolding, file moves/renames.
14+
- When the user asks to fix a bug or implement a feature, describe what's wrong and suggest an approach — then let them write it.
15+
- If the user explicitly asks "just do it" for something non-trivial, remind them of this agreement first.

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: 33 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<CacheKey, 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 = CacheKey(text, targetLanguage, sourceLanguage)
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,11 +70,11 @@ 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
}
@@ -92,14 +98,7 @@ class TranslationRepositoryImpl(
9298
parameter("q", text)
9399
}.bodyAsText()
94100

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

105104
private fun parseTranslationResponse(responseText: String): TranslationResult {
@@ -187,7 +186,22 @@ class TranslationRepositoryImpl(
187186
}
188187
}
189188

190-
override fun close() {
191-
httpClient.close()
189+
companion object {
190+
private const val MAX_CACHE_SIZE = 50
191+
private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes
192+
}
193+
194+
@OptIn(ExperimentalTime::class)
195+
private class CachedTranslation(
196+
val result: TranslationResult,
197+
private val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
198+
) {
199+
fun isExpired(): Boolean = Clock.System.now().toEpochMilliseconds() - timestamp > CACHE_TTL_MS
192200
}
201+
202+
private data class CacheKey(
203+
val text: String,
204+
val targetLanguage: String,
205+
val sourceLanguage: String,
206+
)
193207
}

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)