From 2ea3c631fbe5c572ba3ecb8494d3f5b8df94b8d6 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 1 Mar 2026 06:00:31 +0500 Subject: [PATCH 1/5] feat(details): Implement translation for "About" and "What's New" sections This commit introduces a translation feature leveraging the Google Translate API to allow users to translate repository descriptions (README) and release notes within the app. It includes a language picker and state management for toggling between original and translated content. - **feat(details)**: Added `TranslationRepository` and its implementation using Ktor to interface with the Google Translate API, including text chunking for large documents and a simple LRU cache. - **feat(details)**: Introduced `LanguagePicker` and `TranslationControls` UI components to manage language selection and translation states. - **feat(details)**: Updated `DetailsViewModel` and `DetailsState` to handle translation logic, including support for automatic device language detection. - **feat(details)**: Integrated translation controls into the `About` and `WhatsNew` UI sections. - **domain**: Added `SupportedLanguage`, `TranslationResult`, and `TranslationState` models. - **i18n**: Added translation-related string resources for multiple languages (English, Bengali, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Turkish, and Chinese). - **chore(di)**: Registered `TranslationRepository` in the Koin `detailsModule`. --- .../composeResources/values-bn/strings-bn.xml | 10 ++ .../composeResources/values-es/strings-es.xml | 10 ++ .../composeResources/values-fr/strings-fr.xml | 10 ++ .../composeResources/values-hi/strings-hi.xml | 10 ++ .../composeResources/values-it/strings-it.xml | 10 ++ .../composeResources/values-ja/strings-ja.xml | 10 ++ .../composeResources/values-kr/strings-kr.xml | 10 ++ .../composeResources/values-pl/strings-pl.xml | 10 ++ .../composeResources/values-ru/strings-ru.xml | 10 ++ .../composeResources/values-tr/strings-tr.xml | 10 ++ .../values-zh-rCN/strings-zh-rCN.xml | 10 ++ .../composeResources/values/strings.xml | 10 ++ .../rainxch/details/data/di/SharedModule.kt | 9 + .../repository/TranslationRepositoryImpl.kt | 167 ++++++++++++++++++ .../details/domain/model/SupportedLanguage.kt | 6 + .../details/domain/model/TranslationResult.kt | 6 + .../repository/TranslationRepository.kt | 13 ++ .../details/presentation/DetailsAction.kt | 7 + .../details/presentation/DetailsRoot.kt | 39 ++++ .../details/presentation/DetailsState.kt | 11 ++ .../details/presentation/DetailsViewModel.kt | 110 +++++++++++- .../presentation/components/LanguagePicker.kt | 157 ++++++++++++++++ .../components/TranslationControls.kt | 130 ++++++++++++++ .../presentation/components/sections/About.kt | 40 ++++- .../components/sections/WhatsNew.kt | 45 +++-- .../presentation/model/SupportedLanguages.kt | 41 +++++ .../presentation/model/TranslationState.kt | 11 ++ 27 files changed, 891 insertions(+), 21 deletions(-) create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SupportedLanguage.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/TranslationResult.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SupportedLanguages.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationState.kt diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index b92f1004..3a263b24 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -428,4 +428,14 @@ লিংক শেয়ার করতে ব্যর্থ হয়েছে লিংক ক্লিপবোর্ডে কপি করা হয়েছে + + অনুবাদ করুন + অনুবাদ হচ্ছে… + মূল দেখান + %1$s এ অনুবাদিত + অনুবাদ করুন… + ভাষা খুঁজুন + ভাষা পরিবর্তন করুন + অনুবাদ ব্যর্থ হয়েছে। আবার চেষ্টা করুন। + diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index a7274862..ff4a3ecd 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -393,4 +393,14 @@ No se pudo compartir el enlace Enlace copiado al portapapeles + + Traducir + Traduciendo… + Mostrar original + Traducido a %1$s + Traducir a… + Buscar idioma + Cambiar idioma + Error de traducción. Inténtalo de nuevo. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 208a426a..9ad125cb 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -393,4 +393,14 @@ Échec du partage du lien Lien copié dans le presse-papiers + + Traduire + Traduction… + Afficher l\'original + Traduit en %1$s + Traduire en… + Rechercher une langue + Changer de langue + Échec de la traduction. Veuillez réessayer. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 1ffd32e4..d057f3bf 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -428,4 +428,14 @@ लिंक साझा करने में विफल लिंक क्लिपबोर्ड में कॉपी किया गया + + अनुवाद करें + अनुवाद हो रहा है… + मूल दिखाएं + %1$s में अनुवादित + अनुवाद करें… + भाषा खोजें + भाषा बदलें + अनुवाद विफल। कृपया पुनः प्रयास करें। + diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index f4a2fd88..3f4059ee 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -429,4 +429,14 @@ Impossibile condividere il link Link copiato negli appunti + + Traduci + Traduzione… + Mostra originale + Tradotto in %1$s + Traduci in… + Cerca lingua + Cambia lingua + Traduzione fallita. Riprova. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 5370f181..ccddf4ac 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -392,4 +392,14 @@ リンクの共有に失敗しました リンクをクリップボードにコピーしました + + 翻訳 + 翻訳中… + 原文を表示 + %1$sに翻訳済み + 翻訳先… + 言語を検索 + 言語を変更 + 翻訳に失敗しました。もう一度お試しください。 + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml index c7ea3077..698ef35c 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -425,4 +425,14 @@ 링크 공유에 실패했습니다 링크가 클립보드에 복사되었습니다 + + 번역 + 번역 중… + 원문 보기 + %1$s로 번역됨 + 번역 대상… + 언어 검색 + 언어 변경 + 번역 실패. 다시 시도해주세요. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 825b7178..8b00e022 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -391,4 +391,14 @@ Nie udało się udostępnić linku Link skopiowany do schowka + + Tłumacz + Tłumaczenie… + Pokaż oryginał + Przetłumaczono na %1$s + Tłumacz na… + Szukaj języka + Zmień język + Tłumaczenie nie powiodło się. Spróbuj ponownie. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 8fb908ad..de2286c3 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -393,4 +393,14 @@ Не удалось поделиться ссылкой Ссылка скопирована в буфер обмена + + Перевести + Перевод… + Показать оригинал + Переведено на %1$s + Перевести на… + Поиск языка + Изменить язык + Ошибка перевода. Попробуйте ещё раз. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 180ff2f6..d28b936c 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -425,4 +425,14 @@ Bağlantı paylaşılamadı Bağlantı panoya kopyalandı + + Çevir + Çevriliyor… + Orijinali göster + %1$s diline çevrildi + Şuna çevir… + Dil ara + Dili değiştir + Çeviri başarısız. Lütfen tekrar deneyin. + diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index f4fc1c15..a57fd916 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -393,4 +393,14 @@ 无法分享链接 链接已复制到剪贴板 + + 翻译 + 翻译中… + 显示原文 + 已翻译为%1$s + 翻译为… + 搜索语言 + 更改语言 + 翻译失败,请重试。 + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 25a9b287..8fe7137a 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -425,4 +425,14 @@ Share repository Failed to share link Link copied to clipboard + + + Translate + Translating… + Show Original + Translated to %1$s + Translate to… + Search language + Change language + Translation failed. Please try again. \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index d997f6f8..ff8b0992 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -2,7 +2,9 @@ package zed.rainxch.details.data.di import org.koin.dsl.module import zed.rainxch.details.data.repository.DetailsRepositoryImpl +import zed.rainxch.details.data.repository.TranslationRepositoryImpl import zed.rainxch.details.domain.repository.DetailsRepository +import zed.rainxch.details.domain.repository.TranslationRepository val detailsModule = module { single { @@ -13,4 +15,11 @@ val detailsModule = module { cacheManager = get() ) } + + single { + TranslationRepositoryImpl( + logger = get(), + localizationManager = get() + ) + } } \ No newline at end of file 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 new file mode 100644 index 00000000..47b4d0ab --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt @@ -0,0 +1,167 @@ +package zed.rainxch.details.data.repository + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import zed.rainxch.core.data.network.createPlatformHttpClient +import zed.rainxch.core.data.services.LocalizationManager +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.details.domain.model.TranslationResult +import zed.rainxch.details.domain.repository.TranslationRepository + +class TranslationRepositoryImpl( + private val logger: GitHubStoreLogger, + private val localizationManager: LocalizationManager +) : TranslationRepository { + + private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None) + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val cache = LinkedHashMap(50, 0.75f, true) + private val maxCacheSize = 50 + private val maxChunkSize = 4500 + + override suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String + ): TranslationResult { + val cacheKey = "${text.hashCode()}:$targetLanguage" + cache[cacheKey]?.let { return it } + + val chunks = chunkText(text) + val translatedChunks = mutableListOf() + var detectedLang: String? = null + + for ((chunkText, _) in chunks) { + val response = translateSingleChunk(chunkText, targetLanguage, sourceLanguage) + translatedChunks.add(response.translatedText) + if (detectedLang == null) { + detectedLang = response.detectedSourceLanguage + } + } + + val result = TranslationResult( + translatedText = translatedChunks.joinToString("\n\n"), + detectedSourceLanguage = detectedLang + ) + + if (cache.size >= maxCacheSize) { + val firstKey = cache.keys.first() + cache.remove(firstKey) + } + cache[cacheKey] = result + return result + } + + override fun getDeviceLanguageCode(): String { + return localizationManager.getPrimaryLanguageCode() + } + + private suspend fun translateSingleChunk( + text: String, + targetLanguage: String, + sourceLanguage: String + ): TranslationResult { + val responseText = httpClient.get( + "https://translate.googleapis.com/translate_a/single" + ) { + parameter("client", "gtx") + parameter("sl", sourceLanguage) + parameter("tl", targetLanguage) + parameter("dt", "t") + parameter("q", text) + }.bodyAsText() + + return parseTranslationResponse(responseText) + } + + private fun parseTranslationResponse(responseText: String): TranslationResult { + val root = json.parseToJsonElement(responseText).jsonArray + + val segments = root[0].jsonArray + val translatedText = segments.joinToString("") { segment -> + segment.jsonArray[0].jsonPrimitive.content + } + + val detectedLang = try { + root[2].jsonPrimitive.content + } catch (_: Exception) { + null + } + + return TranslationResult( + translatedText = translatedText, + detectedSourceLanguage = detectedLang + ) + } + + private fun chunkText(text: String): List> { + val paragraphs = text.split("\n\n") + val chunks = mutableListOf>() + val currentChunk = StringBuilder() + + for (paragraph in paragraphs) { + if (paragraph.length > maxChunkSize) { + if (currentChunk.isNotEmpty()) { + chunks.add(Pair(currentChunk.toString(), "\n\n")) + currentChunk.clear() + } + chunkLargeParagraph(paragraph, chunks) + } else if (currentChunk.length + paragraph.length + 2 > maxChunkSize) { + chunks.add(Pair(currentChunk.toString(), "\n\n")) + currentChunk.clear() + currentChunk.append(paragraph) + } else { + if (currentChunk.isNotEmpty()) currentChunk.append("\n\n") + currentChunk.append(paragraph) + } + } + + if (currentChunk.isNotEmpty()) { + chunks.add(Pair(currentChunk.toString(), "\n\n")) + } + + return chunks + } + + private fun chunkLargeParagraph( + paragraph: String, + chunks: MutableList> + ) { + val lines = paragraph.split("\n") + val currentChunk = StringBuilder() + + for (line in lines) { + if (line.length > maxChunkSize) { + if (currentChunk.isNotEmpty()) { + chunks.add(Pair(currentChunk.toString(), "\n")) + currentChunk.clear() + } + var start = 0 + while (start < line.length) { + val end = minOf(start + maxChunkSize, line.length) + chunks.add(Pair(line.substring(start, end), "")) + start = end + } + } else if (currentChunk.length + line.length + 1 > maxChunkSize) { + chunks.add(Pair(currentChunk.toString(), "\n")) + currentChunk.clear() + currentChunk.append(line) + } else { + if (currentChunk.isNotEmpty()) currentChunk.append("\n") + currentChunk.append(line) + } + } + + if (currentChunk.isNotEmpty()) { + chunks.add(Pair(currentChunk.toString(), "\n")) + } + } +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SupportedLanguage.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SupportedLanguage.kt new file mode 100644 index 00000000..a566b346 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SupportedLanguage.kt @@ -0,0 +1,6 @@ +package zed.rainxch.details.domain.model + +data class SupportedLanguage( + val code: String, + val displayName: String +) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/TranslationResult.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/TranslationResult.kt new file mode 100644 index 00000000..9e2d48b5 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/TranslationResult.kt @@ -0,0 +1,6 @@ +package zed.rainxch.details.domain.model + +data class TranslationResult( + val translatedText: String, + val detectedSourceLanguage: String? +) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt new file mode 100644 index 00000000..df8ab5e2 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/TranslationRepository.kt @@ -0,0 +1,13 @@ +package zed.rainxch.details.domain.repository + +import zed.rainxch.details.domain.model.TranslationResult + +interface TranslationRepository { + suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String = "auto" + ): TranslationResult + + fun getDeviceLanguageCode(): String +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 60bbd04d..3eaa0292 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -38,4 +38,11 @@ sealed interface DetailsAction { data object ToggleVersionPicker : DetailsAction data object ToggleAboutExpanded : DetailsAction data object ToggleWhatsNewExpanded : DetailsAction + + data class TranslateAbout(val targetLanguageCode: String) : DetailsAction + data class TranslateWhatsNew(val targetLanguageCode: String) : DetailsAction + data object ToggleAboutTranslation : DetailsAction + data object ToggleWhatsNewTranslation : DetailsAction + data class ShowLanguagePicker(val target: TranslationTarget) : DetailsAction + data object DismissLanguagePicker : DetailsAction } \ No newline at end of file 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 1acf7de6..2f8e2822 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 @@ -64,6 +64,7 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable +import zed.rainxch.details.presentation.components.LanguagePicker import zed.rainxch.details.presentation.components.sections.about import zed.rainxch.details.presentation.components.sections.author import zed.rainxch.details.presentation.components.sections.header @@ -199,6 +200,24 @@ fun DetailsScreen( modifier = Modifier.liquefiable(liquidTopbarState) ) { innerPadding -> + LanguagePicker( + isVisible = state.isLanguagePickerVisible, + selectedLanguageCode = when (state.languagePickerTarget) { + TranslationTarget.ABOUT -> state.aboutTranslation.targetLanguageCode + TranslationTarget.WHATS_NEW -> state.whatsNewTranslation.targetLanguageCode + null -> null + }, + onLanguageSelected = { language -> + when (state.languagePickerTarget) { + TranslationTarget.ABOUT -> onAction(DetailsAction.TranslateAbout(language.code)) + TranslationTarget.WHATS_NEW -> onAction(DetailsAction.TranslateWhatsNew(language.code)) + null -> {} + } + onAction(DetailsAction.DismissLanguagePicker) + }, + onDismiss = { onAction(DetailsAction.DismissLanguagePicker) } + ) + if (state.isLoading) { Box( modifier = Modifier.fillMaxSize(), @@ -248,6 +267,16 @@ fun DetailsScreen( isExpanded = state.isAboutExpanded, onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, collapsedHeight = collapsedSectionHeight, + translationState = state.aboutTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleAboutTranslation) + }, ) } @@ -257,6 +286,16 @@ fun DetailsScreen( isExpanded = state.isWhatsNewExpanded, onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, collapsedHeight = collapsedSectionHeight, + translationState = state.whatsNewTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleWhatsNewTranslation) + }, ) } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index 4f90f517..59bd156b 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -10,6 +10,7 @@ import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem +import zed.rainxch.details.presentation.model.TranslationState data class DetailsState( val isLoading: Boolean = true, @@ -57,6 +58,12 @@ data class DetailsState( val isAboutExpanded: Boolean = false, val isWhatsNewExpanded: Boolean = false, + + val aboutTranslation: TranslationState = TranslationState(), + val whatsNewTranslation: TranslationState = TranslationState(), + val isLanguagePickerVisible: Boolean = false, + val languagePickerTarget: TranslationTarget? = null, + val deviceLanguageCode: String = "en", ) { /** * True when the app is detected as installed on the system (via assets matching) @@ -75,3 +82,7 @@ data class DetailsState( ReleaseCategory.ALL -> allReleases } } + +enum class TranslationTarget { + ABOUT, WHATS_NEW +} 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 fa262010..44db3c96 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import zed.rainxch.githubstore.core.presentation.res.* import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -40,9 +41,12 @@ import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository +import zed.rainxch.details.domain.repository.TranslationRepository import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem import zed.rainxch.details.presentation.model.LogResult +import zed.rainxch.details.presentation.model.SupportedLanguages +import zed.rainxch.details.presentation.model.TranslationState import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Clock.System import kotlin.time.ExperimentalTime @@ -62,6 +66,7 @@ class DetailsViewModel( private val starredRepository: StarredRepository, private val packageMonitor: PackageMonitor, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, + private val translationRepository: TranslationRepository, private val logger: GitHubStoreLogger ) : ViewModel() { @@ -275,6 +280,7 @@ class DetailsViewModel( isAppManagerAvailable = isAppManagerAvailable, isAppManagerEnabled = isAppManagerEnabled, installedApp = installedApp, + deviceLanguageCode = translationRepository.getDeviceLanguageCode(), ) observeInstalledApp(repo.id) @@ -662,7 +668,8 @@ class DetailsViewModel( selectedRelease = release, installableAssets = installable, primaryAsset = primary, - isVersionPickerVisible = false + isVersionPickerVisible = false, + whatsNewTranslation = TranslationState() ) } } @@ -685,6 +692,55 @@ class DetailsViewModel( } } + is DetailsAction.TranslateAbout -> { + val readme = _state.value.readmeMarkdown ?: return + translateContent( + text = readme, + targetLanguageCode = action.targetLanguageCode, + updateState = { ts -> _state.update { it.copy(aboutTranslation = ts) } }, + getCurrentState = { _state.value.aboutTranslation } + ) + } + + is DetailsAction.TranslateWhatsNew -> { + val description = _state.value.selectedRelease?.description ?: return + translateContent( + text = description, + targetLanguageCode = action.targetLanguageCode, + updateState = { ts -> _state.update { it.copy(whatsNewTranslation = ts) } }, + getCurrentState = { _state.value.whatsNewTranslation } + ) + } + + DetailsAction.ToggleAboutTranslation -> { + _state.update { + val current = it.aboutTranslation + it.copy(aboutTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) + } + } + + DetailsAction.ToggleWhatsNewTranslation -> { + _state.update { + val current = it.whatsNewTranslation + it.copy(whatsNewTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) + } + } + + is DetailsAction.ShowLanguagePicker -> { + _state.update { + it.copy( + isLanguagePickerVisible = true, + languagePickerTarget = action.target + ) + } + } + + DetailsAction.DismissLanguagePicker -> { + _state.update { + it.copy(isLanguagePickerVisible = false, languagePickerTarget = null) + } + } + DetailsAction.OnNavigateBackClick -> { // Handled in composable } @@ -1017,7 +1073,7 @@ class DetailsViewModel( val assetsToClean = listOfNotNull(currentAssetName, cachedDownloadAssetName).distinct() if (assetsToClean.isNotEmpty()) { - viewModelScope.launch { + viewModelScope.launch(NonCancellable) { for (asset in assetsToClean) { try { downloader.cancelDownload(asset) @@ -1030,6 +1086,56 @@ class DetailsViewModel( } } + private fun translateContent( + text: String, + targetLanguageCode: String, + updateState: (TranslationState) -> Unit, + getCurrentState: () -> TranslationState + ) { + viewModelScope.launch { + try { + updateState( + getCurrentState().copy( + isTranslating = true, + error = null, + targetLanguageCode = targetLanguageCode + ) + ) + + val result = translationRepository.translate( + text = text, + targetLanguage = targetLanguageCode + ) + + val langDisplayName = SupportedLanguages.all + .find { it.code == targetLanguageCode }?.displayName + ?: targetLanguageCode + + updateState( + TranslationState( + isTranslating = false, + translatedText = result.translatedText, + isShowingTranslation = true, + targetLanguageCode = targetLanguageCode, + targetLanguageDisplayName = langDisplayName, + detectedSourceLanguage = result.detectedSourceLanguage + ) + ) + } catch (e: Exception) { + logger.error("Translation failed: ${e.message}") + updateState( + getCurrentState().copy( + isTranslating = false, + error = e.message + ) + ) + _events.send( + DetailsEvent.OnMessage(getString(Res.string.translation_failed)) + ) + } + } + } + private fun normalizeVersion(version: String?): String { return version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" } 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 new file mode 100644 index 00000000..9436ff7a --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt @@ -0,0 +1,157 @@ +package zed.rainxch.details.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.details.domain.model.SupportedLanguage +import zed.rainxch.details.presentation.model.SupportedLanguages +import zed.rainxch.githubstore.core.presentation.res.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguagePicker( + isVisible: Boolean, + selectedLanguageCode: String?, + onLanguageSelected: (SupportedLanguage) -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var searchQuery by remember { mutableStateOf("") } + + val filteredLanguages = remember(searchQuery) { + if (searchQuery.isBlank()) { + SupportedLanguages.all + } else { + SupportedLanguages.all.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.code.contains(searchQuery, ignoreCase = true) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + Text( + text = stringResource(Res.string.translate_to), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text(stringResource(Res.string.search_language)) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + HorizontalDivider() + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = filteredLanguages, + key = { it.code } + ) { language -> + LanguageListItem( + language = language, + isSelected = language.code == selectedLanguageCode, + onClick = { onLanguageSelected(language) } + ) + } + } + } + } +} + +@Composable +private fun LanguageListItem( + language: SupportedLanguage, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = language.displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + Text( + text = language.code, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + + if (isSelected) { + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } +} 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 new file mode 100644 index 00000000..633307d4 --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt @@ -0,0 +1,130 @@ +package zed.rainxch.details.presentation.components + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.GTranslate +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.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.details.presentation.model.TranslationState +import zed.rainxch.githubstore.core.presentation.res.* + +@Composable +fun TranslationControls( + translationState: TranslationState, + onTranslateClick: () -> Unit, + onLanguagePickerClick: () -> Unit, + onToggleTranslation: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + when { + translationState.isTranslating -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(Res.string.translating), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + 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) + ) + } + } + + 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) + ) + } + } + + else -> { + IconButton( + onClick = onTranslateClick, + modifier = Modifier.size(32.dp) + ) { + 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) + ) + } + } + } + } +} 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 aeb5235d..82110e7d 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 @@ -38,6 +38,8 @@ import zed.rainxch.githubstore.core.presentation.res.* import io.github.fletchmckee.liquid.liquefiable import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.jetbrains.compose.resources.stringResource +import zed.rainxch.details.presentation.components.TranslationControls +import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors @@ -49,6 +51,10 @@ fun LazyListScope.about( isExpanded: Boolean, onToggleExpanded: () -> Unit, collapsedHeight: Dp, + translationState: TranslationState, + onTranslateClick: () -> Unit, + onLanguagePickerClick: () -> Unit, + onToggleTranslation: () -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current @@ -72,14 +78,26 @@ fun LazyListScope.about( modifier = Modifier.liquefiable(liquidState) ) - readmeLanguage?.let { - Text( - text = it, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Medium, - modifier = Modifier.liquefiable(liquidState) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + TranslationControls( + translationState = translationState, + onTranslateClick = onTranslateClick, + onLanguagePickerClick = onLanguagePickerClick, + onToggleTranslation = onToggleTranslation, ) + + readmeLanguage?.let { + Text( + text = it, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Medium, + modifier = Modifier.liquefiable(liquidState) + ) + } } } } @@ -87,8 +105,14 @@ fun LazyListScope.about( item { val liquidState = LocalTopbarLiquidState.current + val displayContent = if (translationState.isShowingTranslation && translationState.translatedText != null) { + translationState.translatedText + } else { + readmeMarkdown + } + ExpandableMarkdownContent( - content = readmeMarkdown, + content = displayContent, isExpanded = isExpanded, onToggleExpanded = onToggleExpanded, imageTransformer = MarkdownImageTransformer, 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 7b2593b2..236535c7 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 @@ -20,7 +20,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -38,6 +37,8 @@ import io.github.fletchmckee.liquid.liquefiable import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.details.presentation.components.TranslationControls +import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors @@ -48,6 +49,10 @@ fun LazyListScope.whatsNew( isExpanded: Boolean, onToggleExpanded: () -> Unit, collapsedHeight: Dp, + translationState: TranslationState, + onTranslateClick: () -> Unit, + onLanguagePickerClick: () -> Unit, + onToggleTranslation: () -> Unit, ) { item { val liquidState = LocalTopbarLiquidState.current @@ -56,15 +61,28 @@ fun LazyListScope.whatsNew( Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(Res.string.whats_new), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, + Row( modifier = Modifier - .liquefiable(liquidState) + .fillMaxWidth() .padding(bottom = 8.dp), - fontWeight = FontWeight.Bold, - ) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.whats_new), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.liquefiable(liquidState), + fontWeight = FontWeight.Bold, + ) + + TranslationControls( + translationState = translationState, + onTranslateClick = onTranslateClick, + onLanguagePickerClick = onLanguagePickerClick, + onToggleTranslation = onToggleTranslation, + ) + } Spacer(Modifier.height(8.dp)) @@ -107,8 +125,14 @@ fun LazyListScope.whatsNew( val flavour = remember { GFMFlavourDescriptor() } val cardColor = MaterialTheme.colorScheme.surfaceContainerLow + val displayContent = if (translationState.isShowingTranslation && translationState.translatedText != null) { + translationState.translatedText + } else { + release.description ?: stringResource(Res.string.no_release_notes) + } + val collapsedHeightPx = with(density) { collapsedHeight.toPx() } - var contentHeightPx by remember(release.description, collapsedHeightPx) { + var contentHeightPx by remember(displayContent, collapsedHeightPx) { mutableFloatStateOf(0f) } val needsExpansion = remember(contentHeightPx, collapsedHeightPx) { @@ -127,8 +151,7 @@ fun LazyListScope.whatsNew( } ) { Markdown( - content = release.description - ?: stringResource(Res.string.no_release_notes), + content = displayContent, colors = colors, typography = typography, flavour = flavour, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SupportedLanguages.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SupportedLanguages.kt new file mode 100644 index 00000000..15197c5c --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/SupportedLanguages.kt @@ -0,0 +1,41 @@ +package zed.rainxch.details.presentation.model + +import zed.rainxch.details.domain.model.SupportedLanguage + +object SupportedLanguages { + val all: List = listOf( + SupportedLanguage("ar", "Arabic"), + SupportedLanguage("bn", "Bengali"), + SupportedLanguage("zh-CN", "Chinese (Simplified)"), + SupportedLanguage("zh-TW", "Chinese (Traditional)"), + SupportedLanguage("cs", "Czech"), + SupportedLanguage("da", "Danish"), + SupportedLanguage("nl", "Dutch"), + SupportedLanguage("en", "English"), + SupportedLanguage("fi", "Finnish"), + SupportedLanguage("fr", "French"), + SupportedLanguage("de", "German"), + SupportedLanguage("el", "Greek"), + SupportedLanguage("he", "Hebrew"), + SupportedLanguage("hi", "Hindi"), + SupportedLanguage("hu", "Hungarian"), + SupportedLanguage("id", "Indonesian"), + SupportedLanguage("it", "Italian"), + SupportedLanguage("ja", "Japanese"), + SupportedLanguage("ko", "Korean"), + SupportedLanguage("ms", "Malay"), + SupportedLanguage("no", "Norwegian"), + SupportedLanguage("pl", "Polish"), + SupportedLanguage("pt", "Portuguese"), + SupportedLanguage("pt-BR", "Portuguese (Brazil)"), + SupportedLanguage("ro", "Romanian"), + SupportedLanguage("ru", "Russian"), + SupportedLanguage("es", "Spanish"), + SupportedLanguage("sv", "Swedish"), + SupportedLanguage("th", "Thai"), + SupportedLanguage("tr", "Turkish"), + SupportedLanguage("uk", "Ukrainian"), + SupportedLanguage("uz", "Uzbek"), + SupportedLanguage("vi", "Vietnamese"), + ) +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationState.kt new file mode 100644 index 00000000..3ed3b5c3 --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/TranslationState.kt @@ -0,0 +1,11 @@ +package zed.rainxch.details.presentation.model + +data class TranslationState( + val isTranslating: Boolean = false, + val translatedText: String? = null, + val isShowingTranslation: Boolean = false, + val targetLanguageCode: String? = null, + val targetLanguageDisplayName: String? = null, + val detectedSourceLanguage: String? = null, + val error: String? = null +) From b332a5d4ffe76994df14b69d4709084950d5a9c0 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 1 Mar 2026 12:11:21 +0500 Subject: [PATCH 2/5] feat(search): Implement GitHub link detection and clipboard integration This commit introduces the ability to detect GitHub repository links within the search query and from the system clipboard. Users can now quickly navigate to repository details by pasting or typing GitHub URLs, and the app will provide a banner for auto-detected links from the clipboard. - **feat(search)**: Added `GithubUrlParser` to identify and extract owner/repo metadata from GitHub URLs. - **feat(search)**: Implemented a clipboard banner that appears when a GitHub link is detected in the system clipboard. - **feat(search)**: Added a Floating Action Button (FAB) to manually trigger GitHub link detection from the clipboard. - **feat(search)**: Enhanced the search logic to automatically suggest navigation when the query consists entirely of GitHub URLs. - **feat(profile)**: Added a setting in the Appearance section to enable or disable automatic clipboard link detection. - **feat(core)**: Extended `ClipboardHelper` with `getText()` support for Android and Desktop platforms. - **refactor(core)**: Updated `ThemesRepository` to manage the persistence of the clipboard detection preference. - **i18n**: Added string resources for clipboard detection, link labels, and app-specific navigation prompts. --- .../baselineProfiles/0/composeApp-release.dm | Bin 9249 -> 11155 bytes .../baselineProfiles/1/composeApp-release.dm | Bin 9212 -> 11133 bytes .../app/navigation/AppNavigation.kt | 8 + .../core/data/utils/AndroidClipboardHelper.kt | 7 + .../data/repository/ThemesRepositoryImpl.kt | 13 + .../core/data/utils/DesktopClipboardHelper.kt | 12 + .../domain/repository/ThemesRepository.kt | 2 + .../core/domain/utils/ClipboardHelper.kt | 2 + .../composeResources/values/strings.xml | 9 + .../rainxch/details/data/di/SharedModule.kt | 1 - .../profile/presentation/ProfileAction.kt | 1 + .../profile/presentation/ProfileState.kt | 1 + .../profile/presentation/ProfileViewModel.kt | 14 + .../components/sections/Appearance.kt | 11 + .../search/presentation/SearchAction.kt | 5 +- .../search/presentation/SearchEvent.kt | 3 +- .../rainxch/search/presentation/SearchRoot.kt | 254 +++++++++++++++++- .../search/presentation/SearchState.kt | 7 +- .../search/presentation/SearchViewModel.kt | 115 +++++++- .../presentation/utils/GithubUrlParser.kt | 30 +++ 20 files changed, 472 insertions(+), 23 deletions(-) create mode 100644 feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm index e9322a2d08ca963726814554734734cffc707e8c..f2ccad8a9eaa3423d3c3c8f99d96df5989d4e1f1 100644 GIT binary patch literal 11155 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 R|I^0%_jv!S#9aS*`#){IaoPX? literal 9249 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 diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm index acf71a1f13d9ca3e7948d16469c24c9c2856d601..78bca095c6aa7089472adad63164ebbb013710ab 100644 GIT binary patch literal 11133 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 HXY{`SD{gUt literal 9212 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 + navController.navigate( + GithubStoreGraph.DetailsScreen( + owner = owner, + repo = repo + ) + ) + }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidClipboardHelper.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidClipboardHelper.kt index a216e857..7b32962b 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidClipboardHelper.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidClipboardHelper.kt @@ -12,4 +12,11 @@ class AndroidClipboardHelper( val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText(label, text)) } + + override fun getText(): String? { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = cm.primaryClip ?: return null + if (clip.itemCount == 0) return null + return clip.getItemAt(0).text?.toString() + } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt index 7c0faec1..3b52b556 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt @@ -18,6 +18,7 @@ class ThemesRepositoryImpl( private val AMOLED_KEY = booleanPreferencesKey("amoled_theme") private val IS_DARK_THEME_KEY = booleanPreferencesKey("is_dark_theme") private val FONT_KEY = stringPreferencesKey("font_theme") + private val AUTO_DETECT_CLIPBOARD_KEY = booleanPreferencesKey("auto_detect_clipboard_links") override fun getThemeColor(): Flow { return preferences.data.map { prefs -> @@ -73,4 +74,16 @@ class ThemesRepositoryImpl( prefs[FONT_KEY] = fontTheme.name } } + + override fun getAutoDetectClipboardLinks(): Flow { + return preferences.data.map { prefs -> + prefs[AUTO_DETECT_CLIPBOARD_KEY] ?: true + } + } + + override suspend fun setAutoDetectClipboardLinks(enabled: Boolean) { + preferences.edit { prefs -> + prefs[AUTO_DETECT_CLIPBOARD_KEY] = enabled + } + } } \ No newline at end of file diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopClipboardHelper.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopClipboardHelper.kt index b586f72c..47f837d3 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopClipboardHelper.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopClipboardHelper.kt @@ -2,6 +2,7 @@ package zed.rainxch.core.data.utils import zed.rainxch.core.domain.utils.ClipboardHelper import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection class DesktopClipboardHelper : ClipboardHelper { @@ -9,4 +10,15 @@ class DesktopClipboardHelper : ClipboardHelper { val clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(StringSelection(text), null) } + + override fun getText(): String? { + return try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + clipboard.getData(DataFlavor.stringFlavor) as? String + } else null + } catch (_: Exception) { + null + } + } } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt index 67118bbc..f5a754b8 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt @@ -13,4 +13,6 @@ interface ThemesRepository { suspend fun setAmoledTheme(enabled: Boolean) fun getFontTheme(): Flow suspend fun setFontTheme(fontTheme: FontTheme) + fun getAutoDetectClipboardLinks(): Flow + suspend fun setAutoDetectClipboardLinks(enabled: Boolean) } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ClipboardHelper.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ClipboardHelper.kt index 3dc91298..4f9a94e2 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ClipboardHelper.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ClipboardHelper.kt @@ -5,4 +5,6 @@ interface ClipboardHelper { label: String, text: String ) + + fun getText(): String? } \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 8fe7137a..a1d1d485 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -435,4 +435,13 @@ Search language Change language Translation failed. Please try again. + + + Open GitHub Link + GitHub link detected in clipboard + Auto-detect clipboard links + Automatically detect GitHub links from clipboard when opening search + Detected Links + Open in app + No GitHub link found in clipboard \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index ff8b0992..88241ebf 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -18,7 +18,6 @@ val detailsModule = module { single { TranslationRepositoryImpl( - logger = get(), localizationManager = get() ) } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index 0a810047..921cef90 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -24,4 +24,5 @@ sealed interface ProfileAction { data class OnProxyPasswordChanged(val password: String) : ProfileAction data object OnProxyPasswordVisibilityToggle : ProfileAction data object OnProxySave : ProfileAction + data class OnAutoDetectClipboardToggled(val enabled: Boolean) : ProfileAction } \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt index b474cc58..c78f4b7c 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt @@ -20,6 +20,7 @@ data class ProfileState( val proxyUsername: String = "", val proxyPassword: String = "", val isProxyPasswordVisible: Boolean = false, + val autoDetectClipboardLinks: Boolean = true, ) enum class ProxyType { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index 0d81ec8b..49d675c7 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -122,6 +122,14 @@ class ProfileViewModel( } } } + + viewModelScope.launch { + themesRepository.getAutoDetectClipboardLinks().collect { enabled -> + _state.update { + it.copy(autoDetectClipboardLinks = enabled) + } + } + } } private fun loadProxyConfig() { @@ -286,6 +294,12 @@ class ProfileViewModel( _state.update { it.copy(isProxyPasswordVisible = !it.isProxyPasswordVisible) } } + is ProfileAction.OnAutoDetectClipboardToggled -> { + viewModelScope.launch { + themesRepository.setAutoDetectClipboardLinks(action.enabled) + } + } + ProfileAction.OnProxySave -> { val currentState = _state.value val port = currentState.proxyPort.toIntOrNull() diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt index 9ba9251d..651ab587 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt @@ -123,6 +123,17 @@ fun LazyListScope.appearanceSection( ) } ) + + VerticalSpacer(16.dp) + + ToggleSettingCard( + title = stringResource(Res.string.auto_detect_clipboard_links), + description = stringResource(Res.string.auto_detect_clipboard_description), + checked = state.autoDetectClipboardLinks, + onCheckedChange = { enabled -> + onAction(ProfileAction.OnAutoDetectClipboardToggled(enabled)) + } + ) } } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt index 4af75ae5..72854a54 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt @@ -12,11 +12,14 @@ sealed interface SearchAction { data class OnSortBySelected(val sortBy: SortBy) : SearchAction data class OnRepositoryClick(val repository: GithubRepoSummary) : SearchAction data class OnRepositoryDeveloperClick(val username: String) : SearchAction - data class OnShareClick (val repo: GithubRepoSummary) : SearchAction + data class OnShareClick(val repo: GithubRepoSummary) : SearchAction + data class OpenGithubLink(val owner: String, val repo: String) : SearchAction data object OnSearchImeClick : SearchAction data object OnNavigateBackClick : SearchAction data object LoadMore : SearchAction data object OnClearClick : SearchAction data object Retry : SearchAction data object OnToggleLanguageSheetVisibility : SearchAction + data object OnFabClick : SearchAction + data object DismissClipboardBanner : SearchAction } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchEvent.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchEvent.kt index 60017bf6..30729a9d 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchEvent.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchEvent.kt @@ -2,4 +2,5 @@ package zed.rainxch.search.presentation sealed interface SearchEvent { data class OnMessage(val message: String) : SearchEvent -} \ No newline at end of file + data class NavigateToRepo(val owner: String, val repo: String) : SearchEvent +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index f89b20c2..2494f0de 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -1,5 +1,11 @@ package zed.rainxch.search.presentation +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +19,8 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid @@ -20,18 +28,24 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -39,6 +53,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -76,11 +91,17 @@ import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SearchPlatform import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.clipboard_link_detected +import zed.rainxch.githubstore.core.presentation.res.dismiss +import zed.rainxch.githubstore.core.presentation.res.detected_links import zed.rainxch.githubstore.core.presentation.res.language_label +import zed.rainxch.githubstore.core.presentation.res.open_github_link +import zed.rainxch.githubstore.core.presentation.res.open_in_app import zed.rainxch.githubstore.core.presentation.res.results_found import zed.rainxch.githubstore.core.presentation.res.retry import zed.rainxch.githubstore.core.presentation.res.search_repositories_hint import zed.rainxch.search.presentation.components.LanguageFilterBottomSheet +import zed.rainxch.search.presentation.utils.ParsedGithubLink import zed.rainxch.search.presentation.utils.label @OptIn(ExperimentalMaterial3Api::class) @@ -88,6 +109,7 @@ import zed.rainxch.search.presentation.utils.label fun SearchRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (GithubRepoSummary) -> Unit, + onNavigateToDetailsFromLink: (owner: String, repo: String) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: SearchViewModel = koinViewModel() ) { @@ -102,19 +124,11 @@ fun SearchRoot( snackbarHost.showSnackbar(event.message) } } - } - } - if (state.isLanguageSheetVisible) { - LanguageFilterBottomSheet( - selectedLanguage = state.selectedLanguage, - onLanguageSelected = { language -> - viewModel.onAction(SearchAction.OnLanguageSelected(language)) - }, - onDismissRequest = { - viewModel.onAction(SearchAction.OnToggleLanguageSheetVisibility) + is SearchEvent.NavigateToRepo -> { + onNavigateToDetailsFromLink(event.owner, event.repo) } - ) + } } SearchScreen( @@ -140,6 +154,18 @@ fun SearchRoot( } } ) + + if (state.isLanguageSheetVisible) { + LanguageFilterBottomSheet( + selectedLanguage = state.selectedLanguage, + onLanguageSelected = { language -> + viewModel.onAction(SearchAction.OnLanguageSelected(language)) + }, + onDismissRequest = { + viewModel.onAction(SearchAction.OnToggleLanguageSheetVisibility) + } + ) + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -231,6 +257,32 @@ fun SearchScreen( modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp) ) }, + floatingActionButton = { + FloatingActionButton( + onClick = { + onAction(SearchAction.OnFabClick) + }, + modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp) + ) { + Row ( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + + Text( + text = stringResource(Res.string.open_github_link), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + }, containerColor = MaterialTheme.colorScheme.background, modifier = Modifier.liquefiable(liquidState) ) { innerPadding -> @@ -240,6 +292,37 @@ fun SearchScreen( .padding(innerPadding) .padding(horizontal = 16.dp) ) { + // Clipboard banner + AnimatedVisibility( + visible = state.isClipboardBannerVisible && state.clipboardLinks.isNotEmpty(), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + ClipboardBanner( + links = state.clipboardLinks, + onOpenLink = { link -> + onAction(SearchAction.OpenGithubLink(link.owner, link.repo)) + }, + onDismiss = { + onAction(SearchAction.DismissClipboardBanner) + } + ) + } + + // Detected links from search query + AnimatedVisibility( + visible = state.detectedLinks.isNotEmpty(), + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + DetectedLinksSection( + links = state.detectedLinks, + onOpenLink = { link -> + onAction(SearchAction.OpenGithubLink(link.owner, link.repo)) + } + ) + } + LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -417,6 +500,149 @@ fun SearchScreen( } } +@Composable +private fun ClipboardBanner( + links: List, + onOpenLink: (ParsedGithubLink) -> Unit, + onDismiss: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.clipboard_link_detected), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.dismiss), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(Modifier.height(4.dp)) + + links.forEach { link -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onOpenLink(link) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = "${link.owner}/${link.repo}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(Res.string.open_in_app), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +@Composable +private fun DetectedLinksSection( + links: List, + onOpenLink: (ParsedGithubLink) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Text( + text = stringResource(Res.string.detected_links), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp) + ) + + links.forEach { link -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + onClick = { onOpenLink(link) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "${link.owner}/${link.repo}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.open_in_app), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + @Composable private fun SearchTopbar( onAction: (SearchAction) -> Unit, @@ -449,7 +675,7 @@ private fun SearchTopbar( onAction(SearchAction.OnClearClick) }, modifier = Modifier - .size(20.dp) + .size(24.dp) .clip(CircleShape) ) { Icon( @@ -483,7 +709,7 @@ private fun SearchTopbar( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer ), shape = CircleShape, modifier = Modifier @@ -503,4 +729,4 @@ private fun Preview() { onAction = {}, ) } -} \ No newline at end of file +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt index 73e980d6..3e2f0b84 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt @@ -4,6 +4,7 @@ import zed.rainxch.core.presentation.model.DiscoveryRepository import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SearchPlatform import zed.rainxch.domain.model.SortBy +import zed.rainxch.search.presentation.utils.ParsedGithubLink data class SearchState( val query: String = "", @@ -16,5 +17,9 @@ data class SearchState( val errorMessage: String? = null, val hasMorePages: Boolean = true, val totalCount: Int? = null, - val isLanguageSheetVisible: Boolean = false + val isLanguageSheetVisible: Boolean = false, + val detectedLinks: List = emptyList(), + val clipboardLinks: List = emptyList(), + val isClipboardBannerVisible: Boolean = false, + val autoDetectClipboardEnabled: Boolean = true, ) diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index f683436a..c4a7abbc 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -22,10 +22,14 @@ import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.StarredRepository +import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase +import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.core.presentation.model.DiscoveryRepository import zed.rainxch.domain.repository.SearchRepository +import zed.rainxch.search.presentation.utils.isEntirelyGithubUrls +import zed.rainxch.search.presentation.utils.parseGithubUrls class SearchViewModel( private val searchRepository: SearchRepository, @@ -35,7 +39,9 @@ class SearchViewModel( private val starredRepository: StarredRepository, private val logger: GitHubStoreLogger, private val shareManager: ShareManager, - private val platform: Platform + private val platform: Platform, + private val clipboardHelper: ClipboardHelper, + private val themesRepository: ThemesRepository, ) : ViewModel() { private var hasLoadedInitialData = false @@ -57,6 +63,8 @@ class SearchViewModel( observeInstalledApps() observeFavouriteApps() observeStarredRepos() + observeClipboardSetting() + checkClipboardForLinks() hasLoadedInitialData = true } @@ -83,6 +91,36 @@ class SearchViewModel( } } + private fun observeClipboardSetting() { + viewModelScope.launch { + themesRepository.getAutoDetectClipboardLinks().collect { enabled -> + _state.update { it.copy(autoDetectClipboardEnabled = enabled) } + } + } + } + + private fun checkClipboardForLinks() { + viewModelScope.launch { + val enabled = themesRepository.getAutoDetectClipboardLinks().first() + if (!enabled) return@launch + + try { + val clipText = clipboardHelper.getText() ?: return@launch + val links = parseGithubUrls(clipText) + if (links.isNotEmpty()) { + _state.update { + it.copy( + clipboardLinks = links, + isClipboardBannerVisible = true + ) + } + } + } catch (e: Exception) { + logger.debug("Failed to read clipboard: ${e.message}") + } + } + } + private fun observeInstalledApps() { viewModelScope.launch { installedAppsRepository.getAllInstalledApps().collect { installedApps -> @@ -297,10 +335,28 @@ class SearchViewModel( is SearchAction.OnSearchChange -> { - _state.update { it.copy(query = action.query) } + val links = parseGithubUrls(action.query) + _state.update { + it.copy( + query = action.query, + detectedLinks = links, + ) + } searchDebounceJob?.cancel() + if (isEntirelyGithubUrls(action.query)) { + currentSearchJob?.cancel() + _state.update { + it.copy( + isLoading = false, + isLoadingMore = false, + errorMessage = null + ) + } + return + } + if (action.query.isBlank()) { _state.update { it.copy( @@ -340,6 +396,13 @@ class SearchViewModel( } SearchAction.OnSearchImeClick -> { + if (_state.value.detectedLinks.isNotEmpty() && isEntirelyGithubUrls(_state.value.query)) { + val link = _state.value.detectedLinks.first() + viewModelScope.launch { + _events.send(SearchEvent.NavigateToRepo(link.owner, link.repo)) + } + return + } searchDebounceJob?.cancel() currentPage = 1 performSearch(isInitial = true) @@ -394,12 +457,54 @@ class SearchViewModel( isLoading = false, isLoadingMore = false, errorMessage = null, - totalCount = null - + totalCount = null, + detectedLinks = emptyList(), ) } } + is SearchAction.OpenGithubLink -> { + viewModelScope.launch { + _events.send(SearchEvent.NavigateToRepo(action.owner, action.repo)) + } + } + + SearchAction.OnFabClick -> { + viewModelScope.launch { + try { + val clipText = clipboardHelper.getText() + if (clipText.isNullOrBlank()) { + _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) + return@launch + } + val links = parseGithubUrls(clipText) + if (links.isEmpty()) { + _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) + return@launch + } + if (links.size == 1) { + _events.send(SearchEvent.NavigateToRepo(links.first().owner, links.first().repo)) + } else { + _state.update { + it.copy( + query = clipText, + detectedLinks = links, + ) + } + } + } catch (e: Exception) { + logger.error("Failed to read clipboard: ${e.message}") + _events.send(SearchEvent.OnMessage(getString(Res.string.no_github_link_in_clipboard))) + } + } + } + + SearchAction.DismissClipboardBanner -> { + _state.update { + it.copy(isClipboardBannerVisible = false) + } + } + is SearchAction.OnRepositoryClick -> { /* Handled in composable */ } @@ -419,4 +524,4 @@ class SearchViewModel( currentSearchJob?.cancel() searchDebounceJob?.cancel() } -} \ No newline at end of file +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt new file mode 100644 index 00000000..499608a5 --- /dev/null +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt @@ -0,0 +1,30 @@ +package zed.rainxch.search.presentation.utils + +data class ParsedGithubLink( + val owner: String, + val repo: String, + val fullUrl: String +) + +private val GITHUB_URL_REGEX = Regex( + """(?:https?://)?(?:www\.)?github\.com/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+)""" +) + +fun parseGithubUrls(text: String): List { + return GITHUB_URL_REGEX.findAll(text) + .map { match -> + ParsedGithubLink( + owner = match.groupValues[1], + repo = match.groupValues[2].removeSuffix(".git"), + fullUrl = "https://github.com/${match.groupValues[1]}/${match.groupValues[2].removeSuffix(".git")}" + ) + } + .distinctBy { "${it.owner}/${it.repo}" } + .toList() +} + +fun isEntirelyGithubUrls(text: String): Boolean { + val stripped = text.replace(GITHUB_URL_REGEX, "") + .replace(Regex("""[\s,;]+"""), "") + return stripped.isEmpty() && parseGithubUrls(text).isNotEmpty() +} From 4b8b909d1cee7ba0c4001ccd9d289fa4b2554b14 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 1 Mar 2026 12:11:29 +0500 Subject: [PATCH 3/5] refactor(details): improve thread safety and resource management in `TranslationRepositoryImpl` This commit enhances the `TranslationRepositoryImpl` by introducing thread-safe cache access using a Mutex and ensuring proper disposal of the HTTP client. - **refactor(details)**: Implemented `AutoCloseable` in `TranslationRepositoryImpl` to properly close the `httpClient`. - **refactor(details)**: Added `Mutex` to synchronize access to the translation cache, preventing potential race conditions. - **refactor(details)**: Removed unused `GitHubStoreLogger` dependency from `TranslationRepositoryImpl`. --- .../repository/TranslationRepositoryImpl.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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 47b4d0ab..6251810f 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 @@ -4,25 +4,26 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import zed.rainxch.core.data.network.createPlatformHttpClient import zed.rainxch.core.data.services.LocalizationManager -import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.details.domain.model.TranslationResult import zed.rainxch.details.domain.repository.TranslationRepository class TranslationRepositoryImpl( - private val logger: GitHubStoreLogger, private val localizationManager: LocalizationManager -) : TranslationRepository { +) : TranslationRepository, AutoCloseable { private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None) private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val cacheMutex = Mutex() private val cache = LinkedHashMap(50, 0.75f, true) private val maxCacheSize = 50 private val maxChunkSize = 4500 @@ -33,7 +34,7 @@ class TranslationRepositoryImpl( sourceLanguage: String ): TranslationResult { val cacheKey = "${text.hashCode()}:$targetLanguage" - cache[cacheKey]?.let { return it } + cacheMutex.withLock { cache[cacheKey] }?.let { return it } val chunks = chunkText(text) val translatedChunks = mutableListOf() @@ -52,11 +53,13 @@ class TranslationRepositoryImpl( detectedSourceLanguage = detectedLang ) - if (cache.size >= maxCacheSize) { - val firstKey = cache.keys.first() - cache.remove(firstKey) + cacheMutex.withLock { + if (cache.size >= maxCacheSize) { + val firstKey = cache.keys.first() + cache.remove(firstKey) + } + cache[cacheKey] = result } - cache[cacheKey] = result return result } @@ -164,4 +167,8 @@ class TranslationRepositoryImpl( chunks.add(Pair(currentChunk.toString(), "\n")) } } + + override fun close() { + httpClient.close() + } } From 0cc2e2a2a12f10a4469e4246e9126dd819786dcd Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 1 Mar 2026 12:22:55 +0500 Subject: [PATCH 4/5] feat(details): prioritize "What's New" section when navigating from updates This commit introduces a new `isComingFromUpdate` flag to the details screen to improve the user experience when checking for app updates. When this flag is active, the "What's New" (release notes) section is displayed above the "About" (README) section. - **feat(details)**: Added `isComingFromUpdate` parameter to `DetailsViewModel` and `DetailsState`. - **feat(ui)**: Reordered the display of `whatsNew` and `about` sections in `DetailsRoot` based on the `isComingFromUpdate` flag. - **feat(navigation)**: Updated `GithubStoreGraph` and `AppNavigation` to pass the `isComingFromUpdate` flag when navigating from the updates screen. - **refactor(details)**: Simplified `DetailsState` by removing the unused `isTrackable` computed property. - **chore**: Updated Koin view model factory to include the new navigation parameter. --- .../app/navigation/AppNavigation.kt | 5 +- .../app/navigation/GithubStoreGraph.kt | 3 +- .../details/presentation/DetailsRoot.kt | 113 ++++++++++++------ .../details/presentation/DetailsState.kt | 12 +- .../details/presentation/DetailsViewModel.kt | 5 +- 5 files changed, 88 insertions(+), 50 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 9db4666e..805bc8b2 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -141,7 +141,7 @@ fun AppNavigation( ) }, viewModel = koinViewModel { - parametersOf(args.repositoryId, args.owner, args.repo) + parametersOf(args.repositoryId, args.owner, args.repo, args.isComingFromUpdate) } ) } @@ -250,7 +250,8 @@ fun AppNavigation( onNavigateToRepo = { repoId -> navController.navigate( GithubStoreGraph.DetailsScreen( - repositoryId = repoId + repositoryId = repoId, + isComingFromUpdate = true ) ) }, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index 33d30e99..77901662 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -17,7 +17,8 @@ sealed interface GithubStoreGraph { data class DetailsScreen( val repositoryId: Long = -1L, val owner: String = "", - val repo: String = "" + val repo: String = "", + val isComingFromUpdate: Boolean = false, ) : GithubStoreGraph @Serializable 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 2f8e2822..f2e346a4 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 @@ -260,43 +260,84 @@ fun DetailsScreen( stats(repoStats = stats) } - state.readmeMarkdown?.let { - about( - readmeMarkdown = state.readmeMarkdown, - readmeLanguage = state.readmeLanguage, - isExpanded = state.isAboutExpanded, - onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, - collapsedHeight = collapsedSectionHeight, - translationState = state.aboutTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleAboutTranslation) - }, - ) - } + if (state.isComingFromUpdate) { + state.selectedRelease?.let { release -> + whatsNew( + release = release, + isExpanded = state.isWhatsNewExpanded, + onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, + collapsedHeight = collapsedSectionHeight, + translationState = state.whatsNewTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleWhatsNewTranslation) + }, + ) + } - state.selectedRelease?.let { release -> - whatsNew( - release = release, - isExpanded = state.isWhatsNewExpanded, - onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, - collapsedHeight = collapsedSectionHeight, - translationState = state.whatsNewTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleWhatsNewTranslation) - }, - ) + state.readmeMarkdown?.let { + about( + readmeMarkdown = state.readmeMarkdown, + readmeLanguage = state.readmeLanguage, + isExpanded = state.isAboutExpanded, + onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, + collapsedHeight = collapsedSectionHeight, + translationState = state.aboutTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleAboutTranslation) + }, + ) + } + } else { + state.readmeMarkdown?.let { + about( + readmeMarkdown = state.readmeMarkdown, + readmeLanguage = state.readmeLanguage, + isExpanded = state.isAboutExpanded, + onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }, + collapsedHeight = collapsedSectionHeight, + translationState = state.aboutTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleAboutTranslation) + }, + ) + } + + state.selectedRelease?.let { release -> + whatsNew( + release = release, + isExpanded = state.isWhatsNewExpanded, + onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, + collapsedHeight = collapsedSectionHeight, + translationState = state.whatsNewTranslation, + onTranslateClick = { + onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) + }, + onLanguagePickerClick = { + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleWhatsNewTranslation) + }, + ) + } } state.userProfile?.let { userProfile -> diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index 59bd156b..abd5d0b8 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -64,17 +64,9 @@ data class DetailsState( val isLanguagePickerVisible: Boolean = false, val languagePickerTarget: TranslationTarget? = null, val deviceLanguageCode: String = "en", -) { - /** - * True when the app is detected as installed on the system (via assets matching) - * but is NOT yet tracked in our database. Shows the "Track this app" button. - */ - val isTrackable: Boolean - get() = installedApp == null && - !isLoading && - repository != null && - primaryAsset != null + val isComingFromUpdate: Boolean = false, +) { val filteredReleases: List get() = when (selectedReleaseCategory) { ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } 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 44db3c96..75059b6d 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 @@ -67,7 +67,8 @@ class DetailsViewModel( private val packageMonitor: PackageMonitor, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val translationRepository: TranslationRepository, - private val logger: GitHubStoreLogger + private val logger: GitHubStoreLogger, + private val isComingFromUpdate: Boolean ) : ViewModel() { private var hasLoadedInitialData = false @@ -81,6 +82,7 @@ class DetailsViewModel( .onStart { if (!hasLoadedInitialData) { loadInitial() + hasLoadedInitialData = true } } @@ -281,6 +283,7 @@ class DetailsViewModel( isAppManagerEnabled = isAppManagerEnabled, installedApp = installedApp, deviceLanguageCode = translationRepository.getDeviceLanguageCode(), + isComingFromUpdate = isComingFromUpdate, ) observeInstalledApp(repo.id) From 0f5ed343ad625b7ad9af5b6f685fdd9bb82d183e Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 1 Mar 2026 12:31:40 +0500 Subject: [PATCH 5/5] refactor(details): use sealed interface for TranslationTarget and improve translation handling This commit refines the translation logic and search behavior while fixing some minor issues in URL parsing and clipboard detection. Key changes include converting the `TranslationTarget` to a sealed interface for better type safety and improving how translated text chunks are joined to preserve delimiters. - **refactor(details)**: Converted `TranslationTarget` from an `enum` to a `sealed interface`. - **refactor(details)**: Updated `TranslationRepositoryImpl` to correctly preserve delimiters between translated text chunks and added a fallback for parsing failures. - **fix(search)**: Hardened `GITHUB_URL_REGEX` with a negative lookbehind to prevent incorrect matches on prefixed strings. - **fix(search)**: Updated `SearchViewModel` to clear repository results and reset loading states when the query is cleared or multiple links are detected in the clipboard. - **fix(core)**: Changed the default value of `AUTO_DETECT_CLIPBOARD_KEY` from `true` to `false` in `ThemesRepositoryImpl`. - **chore**: Updated `DetailsRoot` to reflect `TranslationTarget` naming changes (`About`/`WhatsNew`). --- .../data/repository/ThemesRepositoryImpl.kt | 2 +- .../repository/TranslationRepositoryImpl.kt | 20 ++++++++++++++----- .../details/presentation/DetailsRoot.kt | 16 +++++++-------- .../details/presentation/DetailsState.kt | 7 ++++--- .../search/presentation/SearchViewModel.kt | 16 +++++++++++++-- .../presentation/utils/GithubUrlParser.kt | 2 +- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt index 3b52b556..f86d09d0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt @@ -77,7 +77,7 @@ class ThemesRepositoryImpl( override fun getAutoDetectClipboardLinks(): Flow { return preferences.data.map { prefs -> - prefs[AUTO_DETECT_CLIPBOARD_KEY] ?: true + prefs[AUTO_DETECT_CLIPBOARD_KEY] ?: false } } 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 6251810f..075b8b6e 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 @@ -37,19 +37,22 @@ class TranslationRepositoryImpl( cacheMutex.withLock { cache[cacheKey] }?.let { return it } val chunks = chunkText(text) - val translatedChunks = mutableListOf() + val translatedParts = mutableListOf>() + var detectedLang: String? = null - for ((chunkText, _) in chunks) { + for ((chunkText, delimiter) in chunks) { val response = translateSingleChunk(chunkText, targetLanguage, sourceLanguage) - translatedChunks.add(response.translatedText) + translatedParts.add(response.translatedText to delimiter) if (detectedLang == null) { detectedLang = response.detectedSourceLanguage } } val result = TranslationResult( - translatedText = translatedChunks.joinToString("\n\n"), + translatedText = translatedParts.dropLast(1) + .joinToString("") { (text, delim) -> text + delim } + + translatedParts.lastOrNull()?.first.orEmpty(), detectedSourceLanguage = detectedLang ) @@ -82,7 +85,14 @@ class TranslationRepositoryImpl( parameter("q", text) }.bodyAsText() - return parseTranslationResponse(responseText) + return try { + parseTranslationResponse(responseText) + } catch (_: Exception) { + TranslationResult( + translatedText = text, + detectedSourceLanguage = null + ) + } } private fun parseTranslationResponse(responseText: String): TranslationResult { 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 f2e346a4..173da9e6 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 @@ -203,14 +203,14 @@ fun DetailsScreen( LanguagePicker( isVisible = state.isLanguagePickerVisible, selectedLanguageCode = when (state.languagePickerTarget) { - TranslationTarget.ABOUT -> state.aboutTranslation.targetLanguageCode - TranslationTarget.WHATS_NEW -> state.whatsNewTranslation.targetLanguageCode + TranslationTarget.About -> state.aboutTranslation.targetLanguageCode + TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode null -> null }, onLanguageSelected = { language -> when (state.languagePickerTarget) { - TranslationTarget.ABOUT -> onAction(DetailsAction.TranslateAbout(language.code)) - TranslationTarget.WHATS_NEW -> onAction(DetailsAction.TranslateWhatsNew(language.code)) + TranslationTarget.About -> onAction(DetailsAction.TranslateAbout(language.code)) + TranslationTarget.WhatsNew -> onAction(DetailsAction.TranslateWhatsNew(language.code)) null -> {} } onAction(DetailsAction.DismissLanguagePicker) @@ -272,7 +272,7 @@ fun DetailsScreen( onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) }, onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) }, onToggleTranslation = { onAction(DetailsAction.ToggleWhatsNewTranslation) @@ -292,7 +292,7 @@ fun DetailsScreen( onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) }, onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) }, onToggleTranslation = { onAction(DetailsAction.ToggleAboutTranslation) @@ -312,7 +312,7 @@ fun DetailsScreen( onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) }, onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.ABOUT)) + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) }, onToggleTranslation = { onAction(DetailsAction.ToggleAboutTranslation) @@ -331,7 +331,7 @@ fun DetailsScreen( onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) }, onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WHATS_NEW)) + onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) }, onToggleTranslation = { onAction(DetailsAction.ToggleWhatsNewTranslation) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index abd5d0b8..2ef3e7cd 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -75,6 +75,7 @@ data class DetailsState( } } -enum class TranslationTarget { - ABOUT, WHATS_NEW -} +sealed interface TranslationTarget { + data object About : TranslationTarget + data object WhatsNew : TranslationTarget +} \ No newline at end of file diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index c4a7abbc..94119696 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -351,7 +351,9 @@ class SearchViewModel( it.copy( isLoading = false, isLoadingMore = false, - errorMessage = null + errorMessage = null, + repositories = emptyList(), + totalCount = null ) } return @@ -483,12 +485,22 @@ class SearchViewModel( return@launch } if (links.size == 1) { - _events.send(SearchEvent.NavigateToRepo(links.first().owner, links.first().repo)) + _events.send( + SearchEvent.NavigateToRepo( + links.first().owner, + links.first().repo + ) + ) } else { _state.update { it.copy( query = clipText, detectedLinks = links, + repositories = emptyList(), + totalCount = null, + isLoading = false, + isLoadingMore = false, + errorMessage = null, ) } } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt index 499608a5..509114af 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/utils/GithubUrlParser.kt @@ -7,7 +7,7 @@ data class ParsedGithubLink( ) private val GITHUB_URL_REGEX = Regex( - """(?:https?://)?(?:www\.)?github\.com/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+)""" + """(? {