diff --git a/.claude/memory/feedback_coding_boundaries.md b/.claude/memory/feedback_coding_boundaries.md
new file mode 100644
index 00000000..fa0a8949
--- /dev/null
+++ b/.claude/memory/feedback_coding_boundaries.md
@@ -0,0 +1,15 @@
+---
+name: coding_boundaries
+description: User wants to write all non-trivial logic themselves — Claude should only review, suggest, and handle boilerplate
+type: feedback
+---
+
+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.
+
+**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.
+
+**How to apply:**
+- **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.
+- **Boilerplate** (Claude codes): repetitive refactors, string resources, migration scaffolding, import fixes, build config, copy-paste patterns, test scaffolding, file moves/renames.
+- 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.
+- If the user explicitly asks "just do it" for something non-trivial, remind them of this agreement first.
diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml
index b0ddca4d..224e332d 100644
--- a/core/presentation/src/commonMain/composeResources/values/strings.xml
+++ b/core/presentation/src/commonMain/composeResources/values/strings.xml
@@ -450,12 +450,15 @@
Translate
Translating…
- Show Original
+ Show original
Translated to %1$s
Translate to…
Search language
Change language
Translation failed. Please try again.
+ Retry
+ Auto-detected: %1$s
+ Select language
Open GitHub Link
diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt
index 2b9c46f5..a501fd87 100644
--- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt
+++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt
@@ -14,11 +14,12 @@ import zed.rainxch.core.data.services.LocalizationManager
import zed.rainxch.core.domain.model.ProxyConfig
import zed.rainxch.details.domain.model.TranslationResult
import zed.rainxch.details.domain.repository.TranslationRepository
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
class TranslationRepositoryImpl(
private val localizationManager: LocalizationManager,
-) : TranslationRepository,
- AutoCloseable {
+) : TranslationRepository {
private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None)
private val json =
@@ -28,21 +29,26 @@ class TranslationRepositoryImpl(
}
private val cacheMutex = Mutex()
- private val cache = LinkedHashMap(50, 0.75f, true)
- private val maxCacheSize = 50
+ private val cache = LinkedHashMap(MAX_CACHE_SIZE, 0.75f, true)
private val maxChunkSize = 4500
+ @OptIn(ExperimentalTime::class)
override suspend fun translate(
text: String,
targetLanguage: String,
sourceLanguage: String,
): TranslationResult {
- val cacheKey = "${text.hashCode()}:$targetLanguage"
- cacheMutex.withLock { cache[cacheKey] }?.let { return it }
+ val cacheKey = CacheKey(text, targetLanguage, sourceLanguage)
+
+ cacheMutex.withLock {
+ cache[cacheKey]?.let { cached ->
+ if (!cached.isExpired()) return cached.result
+ cache.remove(cacheKey)
+ }
+ }
val chunks = chunkText(text)
val translatedParts = mutableListOf>()
-
var detectedLang: String? = null
for ((chunkText, delimiter) in chunks) {
@@ -64,11 +70,11 @@ class TranslationRepositoryImpl(
)
cacheMutex.withLock {
- if (cache.size >= maxCacheSize) {
+ if (cache.size >= MAX_CACHE_SIZE) {
val firstKey = cache.keys.first()
cache.remove(firstKey)
}
- cache[cacheKey] = result
+ cache[cacheKey] = CachedTranslation(result)
}
return result
}
@@ -92,14 +98,7 @@ class TranslationRepositoryImpl(
parameter("q", text)
}.bodyAsText()
- return try {
- parseTranslationResponse(responseText)
- } catch (_: Exception) {
- TranslationResult(
- translatedText = text,
- detectedSourceLanguage = null,
- )
- }
+ return parseTranslationResponse(responseText)
}
private fun parseTranslationResponse(responseText: String): TranslationResult {
@@ -187,7 +186,22 @@ class TranslationRepositoryImpl(
}
}
- override fun close() {
- httpClient.close()
+ companion object {
+ private const val MAX_CACHE_SIZE = 50
+ private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes
+ }
+
+ @OptIn(ExperimentalTime::class)
+ private class CachedTranslation(
+ val result: TranslationResult,
+ private val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
+ ) {
+ fun isExpired(): Boolean = Clock.System.now().toEpochMilliseconds() - timestamp > CACHE_TTL_MS
}
+
+ private data class CacheKey(
+ val text: String,
+ val targetLanguage: String,
+ val sourceLanguage: String,
+ )
}
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
index e573949d..b96179f1 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
@@ -75,6 +75,8 @@ import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState
import zed.rainxch.githubstore.core.presentation.res.Res
import zed.rainxch.githubstore.core.presentation.res.add_to_favourites
import zed.rainxch.githubstore.core.presentation.res.cancel
+import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message
+import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title
import zed.rainxch.githubstore.core.presentation.res.dismiss
import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall
import zed.rainxch.githubstore.core.presentation.res.downgrade_warning_message
@@ -88,6 +90,7 @@ import zed.rainxch.githubstore.core.presentation.res.repository_not_starred
import zed.rainxch.githubstore.core.presentation.res.repository_starred
import zed.rainxch.githubstore.core.presentation.res.share_repository
import zed.rainxch.githubstore.core.presentation.res.star_from_github
+import zed.rainxch.githubstore.core.presentation.res.uninstall
import zed.rainxch.githubstore.core.presentation.res.uninstall_first
import zed.rainxch.githubstore.core.presentation.res.unstar_from_github
@@ -300,6 +303,7 @@ fun DetailsScreen(
TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode
null -> null
},
+ deviceLanguageCode = state.deviceLanguageCode,
onLanguageSelected = { language ->
when (state.languagePickerTarget) {
TranslationTarget.About -> {
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
index 3ef82419..ebd021e4 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
@@ -85,6 +85,8 @@ class DetailsViewModel(
private var hasLoadedInitialData = false
private var currentDownloadJob: Job? = null
private var currentAssetName: String? = null
+ private var aboutTranslationJob: Job? = null
+ private var whatsNewTranslationJob: Job? = null
private var cachedDownloadAssetName: String? = null
@@ -725,12 +727,14 @@ class DetailsViewModel(
val newSelected = filtered.firstOrNull()
val (installable, primary) = recomputeAssetsForRelease(newSelected)
+ whatsNewTranslationJob?.cancel()
_state.update {
it.copy(
selectedReleaseCategory = newCategory,
selectedRelease = newSelected,
installableAssets = installable,
primaryAsset = primary,
+ whatsNewTranslation = TranslationState(),
)
}
}
@@ -738,6 +742,7 @@ class DetailsViewModel(
is DetailsAction.SelectRelease -> {
val release = action.release
val (installable, primary) = recomputeAssetsForRelease(release)
+ whatsNewTranslationJob?.cancel()
_state.update {
it.copy(
@@ -770,7 +775,8 @@ class DetailsViewModel(
is DetailsAction.TranslateAbout -> {
val readme = _state.value.readmeMarkdown ?: return
- translateContent(
+ aboutTranslationJob?.cancel()
+ aboutTranslationJob = translateContent(
text = readme,
targetLanguageCode = action.targetLanguageCode,
updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } },
@@ -780,7 +786,8 @@ class DetailsViewModel(
is DetailsAction.TranslateWhatsNew -> {
val description = _state.value.selectedRelease?.description ?: return
- translateContent(
+ whatsNewTranslationJob?.cancel()
+ whatsNewTranslationJob = translateContent(
text = description,
targetLanguageCode = action.targetLanguageCode,
updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } },
@@ -1347,8 +1354,8 @@ class DetailsViewModel(
targetLanguageCode: String,
updateState: (TranslationState) -> Unit,
getCurrentState: () -> TranslationState,
- ) {
- viewModelScope.launch {
+ ): Job {
+ return viewModelScope.launch {
try {
updateState(
getCurrentState().copy(
@@ -1380,6 +1387,8 @@ class DetailsViewModel(
detectedSourceLanguage = result.detectedSourceLanguage,
),
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
logger.error("Translation failed: ${e.message}")
updateState(
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt
index ce0b581e..01e06777 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt
@@ -1,5 +1,6 @@
package zed.rainxch.details.presentation.components
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -7,15 +8,18 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -31,6 +35,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
@@ -43,6 +48,7 @@ import zed.rainxch.githubstore.core.presentation.res.*
fun LanguagePicker(
isVisible: Boolean,
selectedLanguageCode: String?,
+ deviceLanguageCode: String,
onLanguageSelected: (SupportedLanguage) -> Unit,
onDismiss: () -> Unit,
) {
@@ -51,12 +57,17 @@ fun LanguagePicker(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var searchQuery by remember { mutableStateOf("") }
+ val deviceLanguage = remember(deviceLanguageCode) {
+ SupportedLanguages.all.find { it.code == deviceLanguageCode }
+ }
+
val filteredLanguages =
remember(searchQuery) {
+ val all = SupportedLanguages.all
if (searchQuery.isBlank()) {
- SupportedLanguages.all
+ all
} else {
- SupportedLanguages.all.filter {
+ all.filter {
it.displayName.contains(searchQuery, ignoreCase = true) ||
it.code.contains(searchQuery, ignoreCase = true)
}
@@ -88,13 +99,60 @@ fun LanguagePicker(
Icon(Icons.Default.Search, contentDescription = null)
},
singleLine = true,
+ shape = RoundedCornerShape(12.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
- HorizontalDivider()
+ // Device language shortcut — only shown when not searching
+ if (searchQuery.isBlank() && deviceLanguage != null) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
+ .clickable { onLanguageSelected(deviceLanguage) }
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Smartphone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = deviceLanguage.displayName,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringResource(Res.string.select_language),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
+ )
+ }
+ if (deviceLanguage.code == selectedLanguageCode) {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt
index 4b44e851..a1038431 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt
@@ -1,23 +1,37 @@
package zed.rainxch.details.presentation.components
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.GTranslate
+import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import zed.rainxch.details.presentation.model.TranslationState
@@ -31,101 +45,244 @@ fun TranslationControls(
onToggleTranslation: () -> Unit,
modifier: Modifier = Modifier,
) {
- Row(
+ AnimatedContent(
+ targetState = translationState.controlState,
modifier = modifier,
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- when {
- translationState.isTranslating -> {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
+ transitionSpec = {
+ (fadeIn() + slideInHorizontally { it / 3 }) togetherWith
+ (fadeOut() + slideOutHorizontally { -it / 3 })
+ },
+ label = "translation_controls",
+ ) { state ->
+ when (state) {
+ TranslationControlState.IDLE -> {
+ IdleControls(
+ onTranslateClick = onTranslateClick,
+ onLanguagePickerClick = onLanguagePickerClick,
)
- Spacer(Modifier.width(4.dp))
- Text(
- text = stringResource(Res.string.translating),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ }
+
+ TranslationControlState.TRANSLATING -> {
+ TranslatingIndicator()
+ }
+
+ TranslationControlState.SHOWING_TRANSLATION -> {
+ TranslatedControls(
+ displayName = translationState.targetLanguageDisplayName,
+ isShowingTranslation = true,
+ onToggle = onToggleTranslation,
+ onLanguagePickerClick = onLanguagePickerClick,
)
}
- translationState.isShowingTranslation -> {
- TextButton(
- onClick = onToggleTranslation,
- contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
- ) {
- Text(
- text = stringResource(Res.string.show_original),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- IconButton(
- onClick = onLanguagePickerClick,
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- imageVector = Icons.Default.ArrowDropDown,
- contentDescription = stringResource(Res.string.change_language),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp),
- )
- }
+ TranslationControlState.SHOWING_ORIGINAL -> {
+ TranslatedControls(
+ displayName = translationState.targetLanguageDisplayName,
+ isShowingTranslation = false,
+ onToggle = onToggleTranslation,
+ onLanguagePickerClick = onLanguagePickerClick,
+ )
}
- translationState.translatedText != null && !translationState.isShowingTranslation -> {
- TextButton(
- onClick = onToggleTranslation,
- contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
- ) {
- Text(
- text =
- stringResource(
- Res.string.translated_to,
- translationState.targetLanguageDisplayName ?: "",
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- IconButton(
- onClick = onLanguagePickerClick,
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- imageVector = Icons.Default.ArrowDropDown,
- contentDescription = stringResource(Res.string.change_language),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp),
- )
- }
+ TranslationControlState.ERROR -> {
+ ErrorControls(
+ onRetry = onTranslateClick,
+ onLanguagePickerClick = onLanguagePickerClick,
+ )
}
+ }
+ }
+}
- else -> {
- IconButton(
- onClick = onTranslateClick,
- modifier = Modifier.size(32.dp),
- ) {
+@Composable
+private fun IdleControls(
+ onTranslateClick: () -> Unit,
+ onLanguagePickerClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .clickable(onClick = onTranslateClick)
+ .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.GTranslate,
+ contentDescription = stringResource(Res.string.translate),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(5.dp))
+ Text(
+ text = stringResource(Res.string.translate),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ LanguageDropdownButton(onLanguagePickerClick)
+ }
+}
+
+@Composable
+private fun TranslatingIndicator() {
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Text(
+ text = stringResource(Res.string.translating),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+}
+
+@Composable
+private fun TranslatedControls(
+ displayName: String?,
+ isShowingTranslation: Boolean,
+ onToggle: () -> Unit,
+ onLanguagePickerClick: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ if (isShowingTranslation) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ },
+ ).clickable(onClick = onToggle)
+ .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AnimatedVisibility(
+ visible = isShowingTranslation,
+ enter = fadeIn() + scaleIn(),
+ exit = fadeOut() + scaleOut(),
+ ) {
+ Row {
Icon(
imageVector = Icons.Default.GTranslate,
- contentDescription = stringResource(Res.string.translate),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(20.dp),
- )
- }
- IconButton(
- onClick = onLanguagePickerClick,
- modifier = Modifier.size(28.dp),
- ) {
- Icon(
- imageVector = Icons.Default.ArrowDropDown,
- contentDescription = stringResource(Res.string.change_language),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(16.dp),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(14.dp),
)
+ Spacer(Modifier.width(4.dp))
}
}
+
+ Text(
+ text =
+ if (isShowingTranslation) {
+ stringResource(Res.string.show_original)
+ } else {
+ displayName ?: stringResource(Res.string.translate)
+ },
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ color =
+ if (isShowingTranslation) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ LanguageDropdownButton(
+ onClick = onLanguagePickerClick,
+ tint =
+ if (isShowingTranslation) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
}
}
}
+
+@Composable
+private fun ErrorControls(
+ onRetry: () -> Unit,
+ onLanguagePickerClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .clickable(onClick = onRetry)
+ .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = stringResource(Res.string.translation_error_retry),
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = stringResource(Res.string.translation_error_retry),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ LanguageDropdownButton(
+ onClick = onLanguagePickerClick,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+}
+
+@Composable
+private fun LanguageDropdownButton(
+ onClick: () -> Unit,
+ tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant,
+) {
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = stringResource(Res.string.change_language),
+ tint = tint,
+ modifier =
+ Modifier
+ .size(24.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(onClick = onClick)
+ .padding(2.dp),
+ )
+}
+
+private enum class TranslationControlState {
+ IDLE,
+ TRANSLATING,
+ SHOWING_TRANSLATION,
+ SHOWING_ORIGINAL,
+ ERROR,
+}
+
+private val TranslationState.controlState: TranslationControlState
+ get() =
+ when {
+ isTranslating -> TranslationControlState.TRANSLATING
+ error != null && translatedText == null -> TranslationControlState.ERROR
+ isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_TRANSLATION
+ !isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_ORIGINAL
+ else -> TranslationControlState.IDLE
+ }
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
index b11e7d22..7df015be 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
@@ -1,6 +1,10 @@
package zed.rainxch.details.presentation.components.sections
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -71,35 +75,34 @@ fun LazyListScope.about(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- text = stringResource(Res.string.about_this_app),
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onBackground,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.liquefiable(liquidState),
- )
-
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- TranslationControls(
- translationState = translationState,
- onTranslateClick = onTranslateClick,
- onLanguagePickerClick = onLanguagePickerClick,
- onToggleTranslation = onToggleTranslation,
+ Text(
+ text = stringResource(Res.string.about_this_app),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.liquefiable(liquidState),
)
readmeLanguage?.let {
Text(
text = it,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onBackground,
- fontWeight = FontWeight.Medium,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
modifier = Modifier.liquefiable(liquidState),
)
}
}
+
+ TranslationControls(
+ translationState = translationState,
+ onTranslateClick = onTranslateClick,
+ onLanguagePickerClick = onLanguagePickerClick,
+ onToggleTranslation = onToggleTranslation,
+ )
}
}
@@ -113,18 +116,25 @@ fun LazyListScope.about(
readmeMarkdown
}
- ExpandableMarkdownContent(
- content = displayContent,
- isExpanded = isExpanded,
- onToggleExpanded = onToggleExpanded,
- imageTransformer = MarkdownImageTransformer,
- collapsedHeight = collapsedHeight,
- fadeColor = MaterialTheme.colorScheme.background,
- modifier =
- Modifier
- .fillMaxWidth()
- .liquefiable(liquidState),
- )
+ AnimatedContent(
+ targetState = displayContent,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "about_content",
+ ) { content ->
+ ExpandableMarkdownContent(
+ content = content,
+ isExpanded = isExpanded,
+ onToggleExpanded = onToggleExpanded,
+ imageTransformer = MarkdownImageTransformer,
+ collapsedHeight = collapsedHeight,
+ fadeColor = MaterialTheme.colorScheme.background,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .liquefiable(liquidState)
+ .animateContentSize(),
+ )
+ }
}
}
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
index bb3808a4..32d2e571 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
@@ -1,6 +1,10 @@
package zed.rainxch.details.presentation.components.sections
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,6 +22,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
@@ -32,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.Markdown
+import io.github.fletchmckee.liquid.LiquidState
import io.github.fletchmckee.liquid.liquefiable
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.jetbrains.compose.resources.stringResource
@@ -119,96 +125,126 @@ fun LazyListScope.whatsNew(
modifier = Modifier.liquefiable(liquidState),
)
}
+ }
+ }
+ }
- Spacer(Modifier.height(12.dp))
+ item {
+ val liquidState = LocalTopbarLiquidState.current
- val density = LocalDensity.current
- val colors = rememberMarkdownColors()
- val typography = rememberMarkdownTypography()
- val flavour = remember { GFMFlavourDescriptor() }
- val cardColor = MaterialTheme.colorScheme.surfaceContainerLow
+ Spacer(Modifier.height(12.dp))
- val displayContent =
- if (translationState.isShowingTranslation && translationState.translatedText != null) {
- translationState.translatedText
- } else {
- release.description ?: stringResource(Res.string.no_release_notes)
- }
+ ExpandableMarkdownContent(
+ translationState = translationState,
+ release = release,
+ collapsedHeight = collapsedHeight,
+ isExpanded = isExpanded,
+ liquidState = liquidState,
+ onToggleExpanded = onToggleExpanded,
+ )
+ }
+}
- val collapsedHeightPx = with(density) { collapsedHeight.toPx() }
- var contentHeightPx by remember(displayContent, collapsedHeightPx) {
- mutableFloatStateOf(0f)
- }
- val needsExpansion =
- remember(contentHeightPx, collapsedHeightPx) {
- contentHeightPx > collapsedHeightPx && collapsedHeightPx > 0f
- }
+@Composable
+private fun ExpandableMarkdownContent(
+ translationState: TranslationState,
+ release: GithubRelease,
+ collapsedHeight: Dp,
+ isExpanded: Boolean,
+ liquidState: LiquidState,
+ onToggleExpanded: () -> Unit,
+) {
+ val displayContent =
+ if (translationState.isShowingTranslation && translationState.translatedText != null) {
+ translationState.translatedText
+ } else {
+ release.description ?: stringResource(Res.string.no_release_notes)
+ }
+
+ val density = LocalDensity.current
+ val colors = rememberMarkdownColors()
+ val typography = rememberMarkdownTypography()
+ val flavour = remember { GFMFlavourDescriptor() }
+ val cardColor = MaterialTheme.colorScheme.surfaceContainerLow
+
+ AnimatedContent(
+ targetState = displayContent,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "whats_new_content",
+ ) { content ->
+
+ val collapsedHeightPx = with(density) { collapsedHeight.toPx() }
+ var contentHeightPx by remember(content, collapsedHeightPx) {
+ mutableFloatStateOf(0f)
+ }
+ val needsExpansion =
+ remember(contentHeightPx, collapsedHeightPx) {
+ contentHeightPx > collapsedHeightPx && collapsedHeightPx > 0f
+ }
- Column(
- modifier = Modifier.animateContentSize(),
+ Column(
+ modifier = Modifier.animateContentSize(),
+ ) {
+ Box {
+ Box(
+ modifier =
+ if (!isExpanded && needsExpansion) {
+ Modifier.heightIn(max = collapsedHeight).clipToBounds()
+ } else {
+ Modifier
+ },
) {
- Box {
- Box(
- modifier =
- if (!isExpanded && needsExpansion) {
- Modifier.heightIn(max = collapsedHeight).clipToBounds()
- } else {
- Modifier
+ Markdown(
+ content = content,
+ colors = colors,
+ typography = typography,
+ flavour = flavour,
+ imageTransformer = MarkdownImageTransformer,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .liquefiable(liquidState)
+ .onGloballyPositioned { coordinates ->
+ val measured = coordinates.size.height.toFloat()
+ if (measured > contentHeightPx) {
+ contentHeightPx = measured
+ }
},
- ) {
- Markdown(
- content = displayContent,
- colors = colors,
- typography = typography,
- flavour = flavour,
- imageTransformer = MarkdownImageTransformer,
- modifier =
- Modifier
- .fillMaxWidth()
- .liquefiable(liquidState)
- .onGloballyPositioned { coordinates ->
- val measured = coordinates.size.height.toFloat()
- if (measured > contentHeightPx) {
- contentHeightPx = measured
- }
- },
- )
- }
+ )
+ }
- if (!isExpanded && needsExpansion) {
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .height(80.dp)
- .background(
- Brush.verticalGradient(
- 0f to cardColor.copy(alpha = 0f),
- 1f to cardColor,
- ),
- ),
- )
- }
- }
-
- if (needsExpansion) {
- TextButton(
- onClick = onToggleExpanded,
- modifier = Modifier.align(Alignment.CenterHorizontally),
- ) {
- Text(
- text =
- if (isExpanded) {
- stringResource(Res.string.show_less)
- } else {
- stringResource(Res.string.read_more)
- },
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- }
+ if (!isExpanded && needsExpansion) {
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .height(80.dp)
+ .background(
+ Brush.verticalGradient(
+ 0f to cardColor.copy(alpha = 0f),
+ 1f to cardColor,
+ ),
+ ),
+ )
+ }
+ }
+
+ if (needsExpansion) {
+ TextButton(
+ onClick = onToggleExpanded,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ ) {
+ Text(
+ text =
+ if (isExpanded) {
+ stringResource(Res.string.show_less)
+ } else {
+ stringResource(Res.string.read_more)
+ },
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
}
}
}
diff --git a/gradle.properties b/gradle.properties
index 3192d193..e5557650 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,6 +6,7 @@ kotlin.daemon.jvmargs=-Xmx3072M
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
+org.gradle.parallel=true
#Android
android.nonTransitiveRClass=true