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