diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm index e9322a2d..f2ccad8a 100644 Binary files a/composeApp/release/baselineProfiles/0/composeApp-release.dm and b/composeApp/release/baselineProfiles/0/composeApp-release.dm differ diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm index acf71a1f..78bca095 100644 Binary files a/composeApp/release/baselineProfiles/1/composeApp-release.dm and b/composeApp/release/baselineProfiles/1/composeApp-release.dm differ 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 f813a0f6..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 @@ -102,6 +102,14 @@ fun AppNavigation( ) ) }, + onNavigateToDetailsFromLink = { owner, repo -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + owner = owner, + repo = repo + ) + ) + }, onNavigateToDeveloperProfile = { username -> navController.navigate( GithubStoreGraph.DeveloperProfileScreen( @@ -133,7 +141,7 @@ fun AppNavigation( ) }, viewModel = koinViewModel { - parametersOf(args.repositoryId, args.owner, args.repo) + parametersOf(args.repositoryId, args.owner, args.repo, args.isComingFromUpdate) } ) } @@ -242,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/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..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 @@ -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] ?: false + } + } + + 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-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..a1d1d485 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -425,4 +425,23 @@ 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. + + + 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 d997f6f8..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 @@ -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,10 @@ val detailsModule = module { cacheManager = get() ) } + + single { + TranslationRepositoryImpl( + 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..075b8b6e --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt @@ -0,0 +1,184 @@ +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.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.model.ProxyConfig +import zed.rainxch.details.domain.model.TranslationResult +import zed.rainxch.details.domain.repository.TranslationRepository + +class TranslationRepositoryImpl( + private val localizationManager: LocalizationManager +) : 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 + + override suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String + ): TranslationResult { + val cacheKey = "${text.hashCode()}:$targetLanguage" + cacheMutex.withLock { cache[cacheKey] }?.let { return it } + + val chunks = chunkText(text) + val translatedParts = mutableListOf>() + + var detectedLang: String? = null + + for ((chunkText, delimiter) in chunks) { + val response = translateSingleChunk(chunkText, targetLanguage, sourceLanguage) + translatedParts.add(response.translatedText to delimiter) + if (detectedLang == null) { + detectedLang = response.detectedSourceLanguage + } + } + + val result = TranslationResult( + translatedText = translatedParts.dropLast(1) + .joinToString("") { (text, delim) -> text + delim } + + translatedParts.lastOrNull()?.first.orEmpty(), + detectedSourceLanguage = detectedLang + ) + + cacheMutex.withLock { + 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 try { + parseTranslationResponse(responseText) + } catch (_: Exception) { + TranslationResult( + translatedText = text, + detectedSourceLanguage = null + ) + } + } + + 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")) + } + } + + override fun close() { + httpClient.close() + } +} 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..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 @@ -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.WhatsNew -> state.whatsNewTranslation.targetLanguageCode + null -> null + }, + onLanguageSelected = { language -> + when (state.languagePickerTarget) { + TranslationTarget.About -> onAction(DetailsAction.TranslateAbout(language.code)) + TranslationTarget.WhatsNew -> onAction(DetailsAction.TranslateWhatsNew(language.code)) + null -> {} + } + onAction(DetailsAction.DismissLanguagePicker) + }, + onDismiss = { onAction(DetailsAction.DismissLanguagePicker) } + ) + if (state.isLoading) { Box( modifier = Modifier.fillMaxSize(), @@ -241,23 +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, - ) - } + 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.WhatsNew)) + }, + onToggleTranslation = { + onAction(DetailsAction.ToggleWhatsNewTranslation) + }, + ) + } - state.selectedRelease?.let { release -> - whatsNew( - release = release, - isExpanded = state.isWhatsNewExpanded, - onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }, - collapsedHeight = collapsedSectionHeight, - ) + 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.WhatsNew)) + }, + 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 4f90f517..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 @@ -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,17 +58,15 @@ data class DetailsState( val isAboutExpanded: Boolean = false, val isWhatsNewExpanded: Boolean = false, -) { - /** - * 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 aboutTranslation: TranslationState = TranslationState(), + val whatsNewTranslation: TranslationState = TranslationState(), + val isLanguagePickerVisible: Boolean = false, + val languagePickerTarget: TranslationTarget? = null, + val deviceLanguageCode: String = "en", + + val isComingFromUpdate: Boolean = false, +) { val filteredReleases: List get() = when (selectedReleaseCategory) { ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } @@ -75,3 +74,8 @@ data class DetailsState( ReleaseCategory.ALL -> allReleases } } + +sealed interface TranslationTarget { + data object About : TranslationTarget + data object WhatsNew : TranslationTarget +} \ No newline at end of file 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..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 @@ -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,7 +66,9 @@ class DetailsViewModel( private val starredRepository: StarredRepository, private val packageMonitor: PackageMonitor, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, - private val logger: GitHubStoreLogger + private val translationRepository: TranslationRepository, + private val logger: GitHubStoreLogger, + private val isComingFromUpdate: Boolean ) : ViewModel() { private var hasLoadedInitialData = false @@ -76,6 +82,7 @@ class DetailsViewModel( .onStart { if (!hasLoadedInitialData) { loadInitial() + hasLoadedInitialData = true } } @@ -275,6 +282,8 @@ class DetailsViewModel( isAppManagerAvailable = isAppManagerAvailable, isAppManagerEnabled = isAppManagerEnabled, installedApp = installedApp, + deviceLanguageCode = translationRepository.getDeviceLanguageCode(), + isComingFromUpdate = isComingFromUpdate, ) observeInstalledApp(repo.id) @@ -662,7 +671,8 @@ class DetailsViewModel( selectedRelease = release, installableAssets = installable, primaryAsset = primary, - isVersionPickerVisible = false + isVersionPickerVisible = false, + whatsNewTranslation = TranslationState() ) } } @@ -685,6 +695,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 +1076,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 +1089,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 +) 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..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 @@ -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,30 @@ 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, + repositories = emptyList(), + totalCount = null + ) + } + return + } + if (action.query.isBlank()) { _state.update { it.copy( @@ -340,6 +398,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 +459,64 @@ 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, + repositories = emptyList(), + totalCount = null, + isLoading = false, + isLoadingMore = false, + errorMessage = null, + ) + } + } + } 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 +536,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..509114af --- /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( + """(? { + 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() +}