From 7f23e3bb0ca434411c93358cafbed9b8f48fa53c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:09:26 +0500 Subject: [PATCH 01/12] domain: add AppLanguage model and AppLanguages registry - Introduce `AppLanguage` data class to represent user-selectable UI languages with IETF BCP 47 tags and native-script display names. - Create `AppLanguages` registry containing the list of currently supported languages (English, Arabic, Bengali, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Turkish, and Simplified Chinese). - Add `findByTag` utility to retrieve language metadata by its tag. --- .../rainxch/core/domain/model/AppLanguage.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt new file mode 100644 index 00000000..8d401e62 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt @@ -0,0 +1,47 @@ +package zed.rainxch.core.domain.model + +/** + * A user-selectable UI language for the app. Each entry corresponds to a + * `values-` directory that ships with the Compose resources + * bundle, so the [tag] must match what the Android-style locale qualifier + * resolves to (e.g. `zh-rCN` → language tag `zh-CN`). + * + * [displayName] is intentionally hard-coded in the native script so the + * picker is readable regardless of the currently active UI language — a + * user stuck in the wrong language needs to recognise their own language + * to escape. + */ +data class AppLanguage( + /** IETF BCP 47 language tag (e.g. `en`, `zh-CN`, `pt-BR`). */ + val tag: String, + /** Native-script label, e.g. `简体中文`, `Español`. */ + val displayName: String, +) + +/** + * Registry of languages the app currently ships translations for. Keep + * in sync with `core/presentation/src/commonMain/composeResources/values-*` + * directories. Order is the order shown in the Tweaks picker (English + * first as the source-of-truth language, rest alphabetised by tag). + */ +object AppLanguages { + val ALL: List = + listOf( + AppLanguage("en", "English"), + AppLanguage("ar", "العربية"), + AppLanguage("bn", "বাংলা"), + AppLanguage("es", "Español"), + AppLanguage("fr", "Français"), + AppLanguage("hi", "हिन्दी"), + AppLanguage("it", "Italiano"), + AppLanguage("ja", "日本語"), + AppLanguage("ko", "한국어"), + AppLanguage("pl", "Polski"), + AppLanguage("ru", "Русский"), + AppLanguage("tr", "Türkçe"), + AppLanguage("zh-CN", "简体中文"), + ) + + fun findByTag(tag: String?): AppLanguage? = + if (tag.isNullOrBlank()) null else ALL.find { it.tag == tag } +} From 45178dd03f9c2b74a98f2a88b30b4d3771e890ff Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:09:34 +0500 Subject: [PATCH 02/12] tweaks: add language selection component - Create a new `Language.kt` component in the tweaks presentation module. - Implement `languageSection` for `LazyListScope` to display language-related settings. - Add `LanguagePickerCard` and `LanguageDropdown` components to allow users to switch between supported app languages or follow the system default. - Use `AppLanguages` model to populate the dropdown with native-script labels and selection indicators. --- .../components/sections/Language.kt | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt new file mode 100644 index 00000000..6655f545 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt @@ -0,0 +1,209 @@ +package zed.rainxch.tweaks.presentation.components.sections + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.AppLanguages +import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksState +import zed.rainxch.tweaks.presentation.components.SectionHeader + +fun LazyListScope.languageSection( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + item { + SectionHeader(text = stringResource(Res.string.section_language)) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.language_intro), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + LanguagePickerCard( + state = state, + onAction = onAction, + ) + } +} + +@Composable +private fun LanguagePickerCard( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(32.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.language_picker_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.language_picker_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + + Spacer(Modifier.height(12.dp)) + + LanguageDropdown( + selectedTag = state.selectedAppLanguage, + onLanguageSelected = { tag -> + onAction(TweaksAction.OnAppLanguageSelected(tag)) + }, + ) + } + } +} + +@Composable +private fun LanguageDropdown( + selectedTag: String?, + onLanguageSelected: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + val currentLabel = + when (val match = AppLanguages.findByTag(selectedTag)) { + null -> stringResource(Res.string.language_follow_system) + else -> match.displayName + } + + Box(modifier = Modifier.fillMaxWidth()) { + // Anchor row — tappable area that shows the current value and + // toggles the menu. Sized like a standard OutlinedTextField so + // it reads as "pick one" rather than as static label text. + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { expanded = true } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = currentLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.size(8.dp)) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(20.dp), + ) { + // Follow-system first — it's the default and users + // escaping a wrong-language lock-in look for this first. + DropdownMenuItem( + text = { DropdownItemText(stringResource(Res.string.language_follow_system)) }, + onClick = { + onLanguageSelected(null) + expanded = false + }, + trailingIcon = { + if (selectedTag == null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + + AppLanguages.ALL.forEach { language -> + DropdownMenuItem( + text = { + // Native-script label so a user stuck in the + // wrong language can still recognise their + // own and escape. + DropdownItemText(language.displayName) + }, + onClick = { + onLanguageSelected(language.tag) + expanded = false + }, + trailingIcon = { + if (selectedTag == language.tag) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + } + } + } +} + +@Composable +private fun DropdownItemText(label: String) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} From 1d3824c218c33adafcab4544c2d9a35135b2c21a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:09:41 +0500 Subject: [PATCH 03/12] locales: add strings for app language settings - Add "LANGUAGE" section header. - Add strings for language picker title, description, and system default option. - Include introductory text explaining the immediate application of language overrides. --- .../src/commonMain/composeResources/values/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index b385fc88..cadff1ac 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -116,6 +116,11 @@ APPEARANCE + LANGUAGE + Override the app\'s UI language. Applies instantly — no restart needed. + App language + Changes menus, buttons, and messages throughout the app. Does not change content coming from GitHub. + Follow system NETWORK ABOUT From 6f48b0ce7452fb9d137dbc055575a2f6a8becb2a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:09:47 +0500 Subject: [PATCH 04/12] locales: add language selection strings across multiple languages - Introduce a new "LANGUAGE" section and related strings to support in-app language switching. - Add strings for language picker title, description, and an option to follow the system settings. - Include an introductory note explaining that language changes apply immediately without a restart. - Clarify that language settings affect the UI but not content fetched from GitHub. - Provide translations for Turkish, Italian, Simplified Chinese, Bengali, French, Russian, Japanese, Hindi, Korean, Spanish, and Polish. --- .../src/commonMain/composeResources/values-bn/strings-bn.xml | 5 +++++ .../src/commonMain/composeResources/values-es/strings-es.xml | 5 +++++ .../src/commonMain/composeResources/values-fr/strings-fr.xml | 5 +++++ .../src/commonMain/composeResources/values-hi/strings-hi.xml | 5 +++++ .../src/commonMain/composeResources/values-it/strings-it.xml | 5 +++++ .../src/commonMain/composeResources/values-ja/strings-ja.xml | 5 +++++ .../src/commonMain/composeResources/values-ko/strings-ko.xml | 5 +++++ .../src/commonMain/composeResources/values-pl/strings-pl.xml | 5 +++++ .../src/commonMain/composeResources/values-ru/strings-ru.xml | 5 +++++ .../src/commonMain/composeResources/values-tr/strings-tr.xml | 5 +++++ .../composeResources/values-zh-rCN/strings-zh-rCN.xml | 5 +++++ 11 files changed, 55 insertions(+) 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 62750d1d..306a298e 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -113,6 +113,11 @@ চেহারা + ভাষা + অ্যাপের UI ভাষা ওভাররাইড করুন। তৎক্ষণাৎ প্রয়োগ হয় — পুনরায় চালু করার প্রয়োজন নেই। + অ্যাপের ভাষা + সম্পূর্ণ অ্যাপের মেনু, বোতাম এবং বার্তা পরিবর্তন করে। GitHub থেকে আসা বিষয়বস্তু পরিবর্তন করে না। + সিস্টেম অনুসরণ করুন সম্পর্কে নেটওয়ার্ক 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 5e903895..ea73e609 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -95,6 +95,11 @@ Perfil APARIENCIA + IDIOMA + Reemplaza el idioma de la interfaz. Se aplica al instante — no requiere reinicio. + Idioma de la aplicación + Cambia los menús, botones y mensajes en toda la aplicación. No cambia el contenido que viene de GitHub. + Seguir el sistema ACERCA DE RED 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 8c6dd770..0ee9f8ad 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -95,6 +95,11 @@ Profil APPARENCE + LANGUE + Remplace la langue de l\'interface. Prise en compte immédiate — aucun redémarrage requis. + Langue de l\'application + Modifie les menus, boutons et messages dans toute l\'application. Ne modifie pas le contenu provenant de GitHub. + Suivre le système À PROPOS RÉSEAU 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 2d34bc25..de9d9086 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -113,6 +113,11 @@ उपस्थिति + भाषा + ऐप की UI भाषा को बदलें। तुरंत लागू होता है — पुनरारंभ की आवश्यकता नहीं। + ऐप भाषा + पूरे ऐप में मेनू, बटन और संदेश बदलता है। GitHub से आने वाली सामग्री नहीं बदलती। + सिस्टम का पालन करें के बारे में नेटवर्क 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 2c036bf2..7bb801a7 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -113,6 +113,11 @@ ASPETTO + LINGUA + Sovrascrivi la lingua dell\'interfaccia. Si applica immediatamente, senza riavvio. + Lingua dell\'app + Cambia menu, pulsanti e messaggi in tutta l\'app. Non modifica i contenuti provenienti da GitHub. + Segui il sistema INFORMAZIONI RETE 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 50f05095..714f0af2 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -95,6 +95,11 @@ プロフィール 外観 + 言語 + アプリのUI言語を上書きします。再起動不要で即座に適用されます。 + アプリの言語 + アプリ全体のメニュー、ボタン、メッセージを変更します。GitHubからのコンテンツは変更されません。 + システムに従う 情報 ネットワーク diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index e77a7558..da2caa20 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -111,6 +111,11 @@ 외관 + 언어 + 앱의 UI 언어를 재정의합니다. 즉시 적용되며 재시작이 필요 없습니다. + 앱 언어 + 앱 전체의 메뉴, 버튼, 메시지를 변경합니다. GitHub의 콘텐츠는 변경하지 않습니다. + 시스템 따름 정보 네트워크 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 b4303bb2..b9ed19b2 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -96,6 +96,11 @@ Profil WYGLĄD + JĘZYK + Zmień język interfejsu aplikacji. Stosowany natychmiast — bez restartu. + Język aplikacji + Zmienia menu, przyciski i komunikaty w całej aplikacji. Nie zmienia treści pochodzącej z GitHub. + Taki jak system O APLIKACJI SIEĆ 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 c39cde52..7ab43e8b 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -95,6 +95,11 @@ Профиль ВНЕШНИЙ ВИД + ЯЗЫК + Переопределить язык интерфейса приложения. Применяется мгновенно — перезапуск не требуется. + Язык приложения + Изменяет меню, кнопки и сообщения во всём приложении. Не изменяет содержимое с GitHub. + Как в системе О ПРИЛОЖЕНИИ СЕТЬ 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 32f9ebb9..48d7a3d4 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -112,6 +112,11 @@ GÖRÜNÜM + DİL + Uygulama arayüz dilini geçersiz kılar. Anında uygulanır — yeniden başlatma gerekmez. + Uygulama dili + Uygulamadaki menüleri, düğmeleri ve mesajları değiştirir. GitHub\'dan gelen içeriği değiştirmez. + Sistemi izle HAKKINDA 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 43d60e48..26717339 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 @@ -97,6 +97,11 @@ 个人资料 外观 + 语言 + 覆盖应用界面语言。即时生效,无需重启。 + 应用语言 + 更改应用中的菜单、按钮和消息。不会更改来自 GitHub 的内容。 + 跟随系统 关于 网络 From d01cf7d1e1ddfca6de399559698fd0f4cc927155 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:09:58 +0500 Subject: [PATCH 05/12] feat: add support for selecting and persisting app language - Add `getAppLanguage` and `setAppLanguage` to `TweaksRepository` and its implementation using DataStore. - Update `TweaksState` to include `selectedAppLanguage` as a BCP 47 tag. - Implement `OnAppLanguageSelected` action in `TweaksViewModel` to persist user language choice. - Add `OnAppLanguageChangeRequiresRestart` event to handle platforms where in-place language switching is not supported. - Ensure empty or blank language tags are treated as "unset" to follow the system locale. --- .../data/repository/TweaksRepositoryImpl.kt | 19 +++++++++++++ .../domain/repository/TweaksRepository.kt | 11 ++++++++ .../composeResources/values-ar/strings-ar.xml | 5 ++++ .../tweaks/presentation/TweaksAction.kt | 8 ++++++ .../tweaks/presentation/TweaksEvent.kt | 10 +++++++ .../tweaks/presentation/TweaksState.kt | 7 +++++ .../tweaks/presentation/TweaksViewModel.kt | 28 +++++++++++++++++++ 7 files changed, 88 insertions(+) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 7c1f968d..06ed028c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -211,6 +211,24 @@ class TweaksRepositoryImpl( } } + override fun getAppLanguage(): Flow = + preferences.data.map { prefs -> + // Treat empty/blank as "unset" so a stale/malformed write + // doesn't pin the UI to an unresolvable locale. + prefs[APP_LANGUAGE_KEY]?.takeIf { it.isNotBlank() } + } + + override suspend fun setAppLanguage(tag: String?) { + preferences.edit { prefs -> + val normalized = tag?.trim().orEmpty() + if (normalized.isEmpty()) { + prefs.remove(APP_LANGUAGE_KEY) + } else { + prefs[APP_LANGUAGE_KEY] = normalized + } + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -231,5 +249,6 @@ class TweaksRepositoryImpl( private val TRANSLATION_PROVIDER_KEY = stringPreferencesKey("translation_provider") private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") + private val APP_LANGUAGE_KEY = stringPreferencesKey("app_language") } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index 1624be46..abd7ea7a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -75,4 +75,15 @@ interface TweaksRepository { fun getYoudaoAppSecret(): Flow suspend fun setYoudaoAppSecret(appSecret: String) + + /** + * Selected UI language as a BCP 47 tag (e.g. `zh-CN`). Emits + * `null` when the user hasn't picked one — which means "follow + * whatever the JVM/Android locale is" at app start. `null` is + * distinct from `""`: the former is the unset state, the latter + * would be a malformed user choice we don't support. + */ + fun getAppLanguage(): Flow + + suspend fun setAppLanguage(tag: String?) } diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index a5fddde0..7783dbe1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -113,6 +113,11 @@ المظهر + اللغة + تجاوز لغة واجهة التطبيق. تُطبَّق فورًا — لا حاجة لإعادة التشغيل. + لغة التطبيق + يغير القوائم والأزرار والرسائل في جميع أنحاء التطبيق. لا يغير المحتوى القادم من GitHub. + اتباع النظام الشبكة حول diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index 95a9f1e1..b40e0830 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -130,4 +130,12 @@ sealed interface TweaksAction { data object OnYoudaoAppSecretVisibilityToggle : TweaksAction data object OnYoudaoCredentialsSave : TweaksAction + + /** + * User picked a UI language. `tag == null` means "follow system + * locale" — cleared persisted preference. + */ + data class OnAppLanguageSelected( + val tag: String?, + ) : TweaksAction } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt index 8c75205a..769b3d5d 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt @@ -28,4 +28,14 @@ sealed interface TweaksEvent { data object OnTranslationProviderSaved : TweaksEvent data object OnYoudaoCredentialsSaved : TweaksEvent + + /** + * Fired on platforms where changing the UI language cannot be + * applied in-place (currently Desktop — no `Activity.recreate()` + * equivalent). The UI prompts the user to restart so the new + * locale takes effect on the next cold start. On Android this + * event is never emitted; `MainActivity` handles runtime changes + * via `recreate()` directly. + */ + data object OnAppLanguageChangeRequiresRestart : TweaksEvent } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index fe847ed1..7d01f66b 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -42,6 +42,13 @@ data class TweaksState( val youdaoAppKey: String = "", val youdaoAppSecret: String = "", val isYoudaoAppSecretVisible: Boolean = false, + /** + * User-selected UI language as a BCP 47 tag, or `null` to follow + * the system locale. Mirrors the preference observed by + * `MainViewModel` — surfaced here so the Tweaks picker can show + * which chip is selected. + */ + val selectedAppLanguage: String? = null, ) { /** Effective provider to render as "selected" in the UI — draft * overrides persisted when a pending selection is in flight. */ diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index e4d9f760..94f548cf 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.TranslationProvider @@ -72,6 +74,7 @@ class TweaksViewModel( loadScrollbarEnabled() loadTelemetryEnabled() loadTranslationSettings() + loadAppLanguage() observeShizukuStatus() @@ -363,6 +366,14 @@ class TweaksViewModel( } } + private fun loadAppLanguage() { + viewModelScope.launch { + tweaksRepository.getAppLanguage().collect { tag -> + _state.update { it.copy(selectedAppLanguage = tag) } + } + } + } + private fun loadIncludePreReleases() { viewModelScope.launch { tweaksRepository.getIncludePreReleases().collect { enabled -> @@ -739,6 +750,23 @@ class TweaksViewModel( _events.send(TweaksEvent.OnYoudaoCredentialsSaved) } } + + is TweaksAction.OnAppLanguageSelected -> { + viewModelScope.launch { + tweaksRepository.setAppLanguage(action.tag) + // Android: `MainActivity` is subscribed to the + // same preference flow and calls `recreate()` on + // change — no extra nudging needed. Desktop has + // no recreate-equivalent, so we surface a + // "restart to apply" prompt; the user's choice is + // already persisted and will take effect on the + // next launch (or they can restart now via the + // snackbar action). + if (getPlatform() != Platform.ANDROID) { + _events.send(TweaksEvent.OnAppLanguageChangeRequiresRestart) + } + } + } } } From 22f5abf53b31c782821cfd02f4ccc0c27ff6de01 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:10:06 +0500 Subject: [PATCH 06/12] feat: implement dynamic app language switching - Add `setActiveLanguageTag` to `LocalizationManager` to allow overriding the process-wide JVM `Locale.getDefault()`. - Implement `setActiveLanguageTag` in `AndroidLocalizationManager` and `DesktopLocalizationManager`, including logic to restore the system default locale. - Update `MainActivity` on Android to: - Apply the persisted language setting via `runBlocking` before `super.onCreate` to prevent locale flickering on startup. - Observe runtime language changes from `TweaksRepository` and trigger `recreate()` to ensure all strings and `rememberSaveable` state are correctly updated. - Add a new `languageSection` to the `SettingsSection` UI component. --- .../zed/rainxch/githubstore/MainActivity.kt | 43 +++++++++++++++++++ .../zed/rainxch/githubstore/DesktopApp.kt | 19 ++++++++ .../services/AndroidLocalizationManager.kt | 18 ++++++++ .../core/data/services/LocalizationManager.kt | 13 ++++++ .../services/DesktopLocalizationManager.kt | 18 ++++++++ .../components/sections/SettingsSection.kt | 9 ++++ 6 files changed, 120 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index 6c16fa4f..d5e5ef04 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -13,14 +13,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.util.Consumer +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject +import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.utils.AndroidShareManager +import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.app.deeplink.DeepLinkParser class MainActivity : ComponentActivity() { private var deepLinkUri by mutableStateOf(null) private val shareManager: ShareManager by inject() + private val tweaksRepository: TweaksRepository by inject() + private val localizationManager: LocalizationManager by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -29,10 +40,42 @@ class MainActivity : ComponentActivity() { // Register activity result launcher for file picker (must be before STARTED) (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this) + // Apply the persisted language override BEFORE Compose kicks off + // so the very first frame resolves strings against the user's + // choice. `runBlocking` is acceptable here — DataStore reads are + // cheap and we only block once per Activity creation (including + // the post-language-swap recreate() path below). Without this, + // recreate() would briefly flash the old locale before settling. + runBlocking { + val tag = tweaksRepository.getAppLanguage().first() + localizationManager.setActiveLanguageTag(tag) + } + super.onCreate(savedInstanceState) handleIncomingIntent(intent) + // Watch for runtime language changes from the Tweaks picker. + // Drop the initial emission (already applied above) and + // recreate() on any subsequent change — Android preserves + // `rememberSaveable` / ViewModel state through recreate, so + // scroll offsets, nav stack, and form fields all survive while + // every string re-resolves against the new locale. `key()` in + // the composition can't pull off the same trick: it changes + // the composite-key hash under it, which breaks + // `rememberSaveable` lookups and snaps LazyColumns back to 0. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + tweaksRepository + .getAppLanguage() + .drop(1) + .collect { newTag -> + localizationManager.setActiveLanguageTag(newTag) + recreate() + } + } + } + setContent { DisposableEffect(Unit) { val listener = diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 84e22c48..f6ea126b 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -12,8 +12,13 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.core.context.GlobalContext +import zed.rainxch.core.data.services.LocalizationManager +import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin @@ -32,6 +37,20 @@ fun main(args: Array) { initKoin() + // Apply persisted UI language before any Compose code runs — same + // reasoning as on Android (see `MainActivity.onCreate`). Desktop + // Compose has no runtime `recreate()` equivalent, so mid-session + // language swaps surface as a "restart required" snackbar from the + // Tweaks screen; this block just covers the cold-start path so + // users see their chosen language immediately on next launch. + runBlocking { + val koin = GlobalContext.get() + val tweaksRepo = koin.get() + val localization = koin.get() + val tag = tweaksRepo.getAppLanguage().first() + localization.setActiveLanguageTag(tag) + } + val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt index e7e132d5..faf08242 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt @@ -3,6 +3,13 @@ package zed.rainxch.core.data.services import java.util.Locale class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationManager { + /** + * Snapshot of the original JVM locale at construction time, so + * [setActiveLanguageTag] with a null argument can restore it even + * after prior overrides have modified `Locale.getDefault()`. + */ + private val systemDefault: Locale = Locale.getDefault() + override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language @@ -15,4 +22,15 @@ class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationMa } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language + + override fun setActiveLanguageTag(tag: String?) { + val normalized = tag?.trim().orEmpty() + val target = + if (normalized.isEmpty()) { + systemDefault + } else { + Locale.forLanguageTag(normalized) + } + Locale.setDefault(target) + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt index d07ce3b2..2ff6606e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt @@ -11,4 +11,17 @@ interface LocalizationManager { * Returns the primary language code without region (e.g., "zh" from "zh-CN") */ fun getPrimaryLanguageCode(): String + + /** + * Overrides the process-wide JVM `Locale.getDefault()` used by + * Compose Resources' `LocalComposeEnvironment` for string + * resolution. Passing `null` (or blank) restores the original + * system locale captured at instance construction. + * + * Must be called from the composition side (see `App()`) *before* + * the `key(appLanguage)`-wrapped content remounts, so the new + * locale is picked up when `stringResource` re-reads + * `Locale.current` on recomposition. + */ + fun setActiveLanguageTag(tag: String?) } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt index e2656ae0..83eacefc 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt @@ -3,6 +3,13 @@ package zed.rainxch.core.data.services import java.util.Locale class DesktopLocalizationManager : LocalizationManager { + /** + * Snapshot of the original JVM locale at construction time, so + * [setActiveLanguageTag] with a null argument can restore it even + * after prior overrides have modified `Locale.getDefault()`. + */ + private val systemDefault: Locale = Locale.getDefault() + override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language @@ -15,4 +22,15 @@ class DesktopLocalizationManager : LocalizationManager { } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language + + override fun setActiveLanguageTag(tag: String?) { + val normalized = tag?.trim().orEmpty() + val target = + if (normalized.isEmpty()) { + systemDefault + } else { + Locale.forLanguageTag(normalized) + } + Locale.setDefault(target) + } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt index 7f263724..5eef2d94 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt @@ -21,6 +21,15 @@ fun LazyListScope.settings( Spacer(Modifier.height(32.dp)) } + languageSection( + state = state, + onAction = onAction, + ) + + item { + Spacer(Modifier.height(32.dp)) + } + networkSection( state = state, onAction = onAction, From 10f67676727e82ea78bc0c0b17b047cc77fcda9a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:18:56 +0500 Subject: [PATCH 07/12] tweaks: implement language restart prompt and improve UI contrast - Add `language_restart_required` and `language_restart_action` strings for language change notifications. - Handle `OnAppLanguageChangeRequiresRestart` event in `TweaksRoot` by showing a snackbar that triggers a JVM exit on desktop platforms. - Update `Language` picker UI to use a tinted `surface` background for better visibility against its container. - Set the language dropdown menu background to `surfaceContainerHigh` to provide better visual contrast against the parent card. --- .../composeResources/values/strings.xml | 2 ++ .../rainxch/tweaks/presentation/TweaksRoot.kt | 22 +++++++++++++++++++ .../components/sections/Language.kt | 14 ++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index cadff1ac..ec0ab217 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -121,6 +121,8 @@ App language Changes menus, buttons, and messages throughout the app. Does not change content coming from GitHub. Follow system + Restart to apply the new language. + Restart NETWORK ABOUT diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index dab182ae..a415a87f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -122,6 +123,27 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { snackbarState.showSnackbar(getString(Res.string.translation_youdao_saved)) } } + + TweaksEvent.OnAppLanguageChangeRequiresRestart -> { + coroutineScope.launch { + val result = + snackbarState.showSnackbar( + message = getString(Res.string.language_restart_required), + actionLabel = getString(Res.string.language_restart_action), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + // Desktop-only path: `exitProcess` terminates + // the JVM; the user reopens the app, at which + // point `DesktopApp.main` reads the persisted + // language and applies it before Compose + // starts. On Android this event never fires — + // `MainActivity` handles runtime changes via + // `recreate()` directly. + kotlin.system.exitProcess(0) + } + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt index 6655f545..46ed650e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt @@ -1,5 +1,6 @@ package zed.rainxch.tweaks.presentation.components.sections +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -116,13 +117,16 @@ private fun LanguageDropdown( Box(modifier = Modifier.fillMaxWidth()) { // Anchor row — tappable area that shows the current value and - // toggles the menu. Sized like a standard OutlinedTextField so - // it reads as "pick one" rather than as static label text. + // toggles the menu. Uses a `surface`-tinted background so it + // reads as a pickable control against the parent card's + // `surfaceContainer`; the plain clickable row otherwise + // blends into the card. Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) .clickable { expanded = true } .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -150,6 +154,12 @@ private fun LanguageDropdown( expanded = expanded, onDismissRequest = { expanded = false }, shape = RoundedCornerShape(20.dp), + // Default menu container is `surfaceContainer`, the same + // tone the parent `ElevatedCard` uses — the menu would + // visually dissolve into the card. Step up to + // `surfaceContainerHigh` so it reads as a distinct popup + // layer with the correct elevation contrast. + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { // Follow-system first — it's the default and users // escaping a wrong-language lock-in look for this first. From e19f57c6d3b38d01f41abc5bd0052469369bc4a1 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:18:58 +0500 Subject: [PATCH 08/12] tweaks: implement language restart prompt and improve UI contrast - Add `language_restart_required` and `language_restart_action` strings for language change notifications. - Handle `OnAppLanguageChangeRequiresRestart` event in `TweaksRoot` by showing a snackbar that triggers a JVM exit on desktop platforms. - Update `Language` picker UI to use a tinted `surface` background for better visibility against its container. - Set the language dropdown menu background to `surfaceContainerHigh` to provide better visual contrast against the parent card. --- .../src/commonMain/composeResources/values-ar/strings-ar.xml | 2 ++ .../src/commonMain/composeResources/values-bn/strings-bn.xml | 2 ++ .../src/commonMain/composeResources/values-es/strings-es.xml | 2 ++ .../src/commonMain/composeResources/values-fr/strings-fr.xml | 2 ++ .../src/commonMain/composeResources/values-hi/strings-hi.xml | 2 ++ .../src/commonMain/composeResources/values-it/strings-it.xml | 2 ++ .../src/commonMain/composeResources/values-ja/strings-ja.xml | 2 ++ .../src/commonMain/composeResources/values-ko/strings-ko.xml | 2 ++ .../src/commonMain/composeResources/values-pl/strings-pl.xml | 2 ++ .../src/commonMain/composeResources/values-ru/strings-ru.xml | 2 ++ .../src/commonMain/composeResources/values-tr/strings-tr.xml | 2 ++ .../composeResources/values-zh-rCN/strings-zh-rCN.xml | 2 ++ 12 files changed, 24 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 7783dbe1..4d0d7f74 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -118,6 +118,8 @@ لغة التطبيق يغير القوائم والأزرار والرسائل في جميع أنحاء التطبيق. لا يغير المحتوى القادم من GitHub. اتباع النظام + أعد التشغيل لتطبيق اللغة الجديدة. + إعادة التشغيل الشبكة حول 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 306a298e..15027dbe 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -118,6 +118,8 @@ অ্যাপের ভাষা সম্পূর্ণ অ্যাপের মেনু, বোতাম এবং বার্তা পরিবর্তন করে। GitHub থেকে আসা বিষয়বস্তু পরিবর্তন করে না। সিস্টেম অনুসরণ করুন + নতুন ভাষা প্রয়োগ করতে পুনরায় চালু করুন। + পুনরায় চালু করুন সম্পর্কে নেটওয়ার্ক 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 ea73e609..42565839 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -100,6 +100,8 @@ Idioma de la aplicación Cambia los menús, botones y mensajes en toda la aplicación. No cambia el contenido que viene de GitHub. Seguir el sistema + Reinicia para aplicar el nuevo idioma. + Reiniciar ACERCA DE RED 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 0ee9f8ad..1bd5c199 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -100,6 +100,8 @@ Langue de l\'application Modifie les menus, boutons et messages dans toute l\'application. Ne modifie pas le contenu provenant de GitHub. Suivre le système + Redémarrez pour appliquer la nouvelle langue. + Redémarrer À PROPOS RÉSEAU 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 de9d9086..3ef871ca 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -118,6 +118,8 @@ ऐप भाषा पूरे ऐप में मेनू, बटन और संदेश बदलता है। GitHub से आने वाली सामग्री नहीं बदलती। सिस्टम का पालन करें + नई भाषा लागू करने के लिए पुनरारंभ करें। + पुनरारंभ करें के बारे में नेटवर्क 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 7bb801a7..d5f09e88 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -118,6 +118,8 @@ Lingua dell\'app Cambia menu, pulsanti e messaggi in tutta l\'app. Non modifica i contenuti provenienti da GitHub. Segui il sistema + Riavvia per applicare la nuova lingua. + Riavvia INFORMAZIONI RETE 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 714f0af2..f9ac4bea 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -100,6 +100,8 @@ アプリの言語 アプリ全体のメニュー、ボタン、メッセージを変更します。GitHubからのコンテンツは変更されません。 システムに従う + 新しい言語を適用するには再起動してください。 + 再起動 情報 ネットワーク diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index da2caa20..c02cee6e 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -116,6 +116,8 @@ 앱 언어 앱 전체의 메뉴, 버튼, 메시지를 변경합니다. GitHub의 콘텐츠는 변경하지 않습니다. 시스템 따름 + 새 언어를 적용하려면 다시 시작하세요. + 다시 시작 정보 네트워크 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 b9ed19b2..d12f351f 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -101,6 +101,8 @@ Język aplikacji Zmienia menu, przyciski i komunikaty w całej aplikacji. Nie zmienia treści pochodzącej z GitHub. Taki jak system + Uruchom ponownie, aby zastosować nowy język. + Uruchom ponownie O APLIKACJI SIEĆ 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 7ab43e8b..eb050feb 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -100,6 +100,8 @@ Язык приложения Изменяет меню, кнопки и сообщения во всём приложении. Не изменяет содержимое с GitHub. Как в системе + Перезапустите, чтобы применить новый язык. + Перезапустить О ПРИЛОЖЕНИИ СЕТЬ 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 48d7a3d4..2c941830 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -117,6 +117,8 @@ Uygulama dili Uygulamadaki menüleri, düğmeleri ve mesajları değiştirir. GitHub\'dan gelen içeriği değiştirmez. Sistemi izle + Yeni dili uygulamak için yeniden başlatın. + Yeniden başlat HAKKINDA 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 26717339..033e1e8a 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 @@ -102,6 +102,8 @@ 应用语言 更改应用中的菜单、按钮和消息。不会更改来自 GitHub 的内容。 跟随系统 + 重新启动以应用新语言。 + 重新启动 关于 网络 From edc3e8c3bc501ed495a907b5fb8b56e4d8ee787d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:23:35 +0500 Subject: [PATCH 09/12] presentation: implement best-effort app restart for language changes on Desktop - Introduce `restartAppAfterLanguageChange` expect/actual function to handle platform-specific restart logic. - Implement JVM-specific logic using `ProcessHandle` and `ProcessBuilder` to attempt a fresh relaunch of the app with original arguments before exiting. - Add a no-op implementation for Android, where language changes are already handled via `Activity.recreate()`. - Update `TweaksRoot` to invoke the new restart logic when the user performs the snackbar action. --- .../tweaks/presentation/RestartApp.android.kt | 13 ++++++ .../rainxch/tweaks/presentation/RestartApp.kt | 19 ++++++++ .../rainxch/tweaks/presentation/TweaksRoot.kt | 16 +++---- .../tweaks/presentation/RestartApp.jvm.kt | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt create mode 100644 feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt create mode 100644 feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt diff --git a/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt new file mode 100644 index 00000000..32b3b03a --- /dev/null +++ b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt @@ -0,0 +1,13 @@ +package zed.rainxch.tweaks.presentation + +/** + * No-op on Android — runtime language changes are applied via + * `Activity.recreate()` from `MainActivity`, and the triggering + * event (`OnAppLanguageChangeRequiresRestart`) is never emitted on + * this platform. We keep an actual so common code compiles; if the + * invariant ever breaks we'd rather silently skip than kill the + * process. + */ +actual fun restartAppAfterLanguageChange() { + // Intentionally empty. +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt new file mode 100644 index 00000000..885fb28f --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt @@ -0,0 +1,19 @@ +package zed.rainxch.tweaks.presentation + +/** + * Platform hook for the "restart now" Snackbar action after a + * language change on Desktop. Tries to spawn a fresh JVM with the + * same command line as the current process and then exit — so the + * user ends up in a freshly-started app with their new locale + * applied by `DesktopApp.main`. If that isn't possible (IDE/Gradle + * runs, sandbox restrictions, etc.) the implementation falls back + * to a plain exit; the user's preference is already persisted, so + * they just need to reopen the app manually. + * + * This should never be invoked on Android — `MainActivity` handles + * runtime language changes via `Activity.recreate()`, and the + * `OnAppLanguageChangeRequiresRestart` event that triggers this is + * never emitted there. The Android actual is therefore a no-op for + * safety. + */ +expect fun restartAppAfterLanguageChange() diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index a415a87f..c8c7ec8c 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -133,14 +133,14 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { withDismissAction = true, ) if (result == SnackbarResult.ActionPerformed) { - // Desktop-only path: `exitProcess` terminates - // the JVM; the user reopens the app, at which - // point `DesktopApp.main` reads the persisted - // language and applies it before Compose - // starts. On Android this event never fires — - // `MainActivity` handles runtime changes via - // `recreate()` directly. - kotlin.system.exitProcess(0) + // Best-effort relaunch on Desktop; see + // `RestartApp.jvm.kt`. Falls back to plain + // exit if a clean relaunch isn't possible + // (IDE runs, sandbox restrictions) — the + // preference is already persisted so the new + // locale takes effect on the next manual + // launch either way. + restartAppAfterLanguageChange() } } } diff --git a/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt new file mode 100644 index 00000000..2891b528 --- /dev/null +++ b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt @@ -0,0 +1,46 @@ +package zed.rainxch.tweaks.presentation + +import kotlin.system.exitProcess + +/** + * Best-effort "relaunch this JVM" for the Desktop language-change + * flow. In `jpackage`-built installers (DMG/MSI/DEB) the current + * process's command line is a clean invocation of the app launcher, + * and [ProcessHandle] reliably gives us the executable path plus the + * original arguments — `ProcessBuilder` can spawn a fresh instance + * from that and we just exit this one. From IDE runs / `./gradlew + * run` the command line reflects the Gradle-managed forked JVM, + * which may or may not relaunch cleanly depending on classpath and + * stdout wiring; if the spawn fails we still want to exit so the + * user can reopen manually rather than be stuck in a half-applied + * state. + * + * [inheritIO] so the relaunched process shares our stdin/stdout/ + * stderr — mostly relevant in terminal runs; packaged apps have no + * attached terminal so it's a no-op there. + */ +actual fun restartAppAfterLanguageChange() { + try { + val info = ProcessHandle.current().info() + val command = info.command().orElse(null) + if (command != null) { + val arguments = info.arguments().orElse(emptyArray()) + ProcessBuilder(listOf(command) + arguments.toList()) + .inheritIO() + .start() + } else { + System.err.println( + "restartAppAfterLanguageChange: ProcessHandle has no command; exiting without relaunch", + ) + } + } catch (t: Throwable) { + // Swallow: we'd rather exit cleanly than leave the user in a + // limbo where the app is stuck with the old locale because + // the relaunch errored out. stderr so packaging regressions + // are still noticeable in logs without adding a logging dep. + System.err.println( + "restartAppAfterLanguageChange: relaunch failed (${t.javaClass.simpleName}: ${t.message}), falling back to plain exit", + ) + } + exitProcess(0) +} From 82daca6a77a1baa14dd45fcc7ee75679e1966ce6 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:58:57 +0500 Subject: [PATCH 10/12] presentation: implement best-effort app restart for language changes on Desktop - Introduce `restartAppAfterLanguageChange` expect/actual function to handle platform-specific restart logic. - Implement JVM-specific logic using `ProcessHandle` and `ProcessBuilder` to attempt a fresh relaunch of the app with original arguments before exiting. - Add a no-op implementation for Android, where language changes are already handled via `Activity.recreate()`. - Update `TweaksRoot` to invoke the new restart logic when the user performs the snackbar action. --- CLAUDE.md | 2 +- .../zed/rainxch/githubstore/MainActivity.kt | 17 ++++++++++++++++- .../data/repository/TweaksRepositoryImpl.kt | 13 +++++++++---- .../rainxch/core/domain/model/AppLanguage.kt | 2 ++ .../composeResources/values-ar/strings-ar.xml | 2 +- .../composeResources/values-bn/strings-bn.xml | 2 +- .../composeResources/values-es/strings-es.xml | 2 +- .../composeResources/values-fr/strings-fr.xml | 2 +- .../composeResources/values-hi/strings-hi.xml | 2 +- .../composeResources/values-it/strings-it.xml | 4 ++-- .../composeResources/values-ja/strings-ja.xml | 2 +- .../composeResources/values-ko/strings-ko.xml | 2 +- .../composeResources/values-pl/strings-pl.xml | 2 +- .../composeResources/values-ru/strings-ru.xml | 2 +- .../composeResources/values-tr/strings-tr.xml | 2 +- .../values-zh-rCN/strings-zh-rCN.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../tweaks/presentation/TweaksViewModel.kt | 6 ++++++ 18 files changed, 48 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ade7b1fa..5d91e412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Routes defined in `composeApp/.../app/navigation/GithubStoreGraph.kt`, wired in |--------|---------|--------------| | `core/domain` | Shared contracts | Repository interfaces (`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `ThemesRepository`, `ProxyRepository`, `RateLimitRepository`), models (`GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `ProxyConfig`, `InstallerType`, `ShizukuAvailability`), system interfaces (`Installer`, `InstallerInfoExtractor`, `InstallerStatusProvider`, `PackageMonitor`) | | `core/data` | Shared implementations | `HttpClientFactory` (Ktor + interceptors), `AppDatabase` (Room), `ProxyManager`, `TokenStore`, `LocalizationManager`, platform-specific clients (OkHttp for Android, CIO for Desktop), Shizuku integration (Android: `ShizukuServiceManager`, `ShizukuInstallerWrapper`, `ShizukuInstallerServiceImpl`, `AndroidInstallerStatusProvider`; Desktop: `DesktopInstallerStatusProvider`) | -| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (11 languages) | +| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (13 languages) | ## Tech Stack diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index d5e5ef04..2ab42bb2 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.koin.android.ext.android.inject import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.utils.AndroidShareManager @@ -27,6 +28,8 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.app.deeplink.DeepLinkParser +private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L + class MainActivity : ComponentActivity() { private var deepLinkUri by mutableStateOf(null) private val shareManager: ShareManager by inject() @@ -46,8 +49,20 @@ class MainActivity : ComponentActivity() { // cheap and we only block once per Activity creation (including // the post-language-swap recreate() path below). Without this, // recreate() would briefly flash the old locale before settling. + // + // The 2s timeout + catch-all is defence against a stalled or + // corrupted DataStore: we'd rather boot in system language than + // leave the Activity stuck before super.onCreate(), which would + // hang the whole app with no visible error. runBlocking { - val tag = tweaksRepository.getAppLanguage().first() + val tag = + try { + withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { + tweaksRepository.getAppLanguage().first() + } + } catch (_: Throwable) { + null + } localizationManager.setActiveLanguageTag(tag) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 06ed028c..36ad81b1 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import zed.rainxch.core.domain.model.AppLanguages import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme @@ -213,15 +214,19 @@ class TweaksRepositoryImpl( override fun getAppLanguage(): Flow = preferences.data.map { prefs -> - // Treat empty/blank as "unset" so a stale/malformed write - // doesn't pin the UI to an unresolvable locale. - prefs[APP_LANGUAGE_KEY]?.takeIf { it.isNotBlank() } + // Treat blank *or* unknown tags as "unset" — guards against + // stale writes from older builds that shipped a language + // we no longer bundle resources for, which would otherwise + // pin the UI to an unresolvable locale. + prefs[APP_LANGUAGE_KEY] + ?.trim() + ?.takeIf { it.isNotEmpty() && AppLanguages.containsTag(it) } } override suspend fun setAppLanguage(tag: String?) { preferences.edit { prefs -> val normalized = tag?.trim().orEmpty() - if (normalized.isEmpty()) { + if (normalized.isEmpty() || !AppLanguages.containsTag(normalized)) { prefs.remove(APP_LANGUAGE_KEY) } else { prefs[APP_LANGUAGE_KEY] = normalized diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt index 8d401e62..3acfed51 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt @@ -44,4 +44,6 @@ object AppLanguages { fun findByTag(tag: String?): AppLanguage? = if (tag.isNullOrBlank()) null else ALL.find { it.tag == tag } + + fun containsTag(tag: String?): Boolean = findByTag(tag) != null } diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 4d0d7f74..1b4a6b08 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -114,7 +114,7 @@ المظهر اللغة - تجاوز لغة واجهة التطبيق. تُطبَّق فورًا — لا حاجة لإعادة التشغيل. + تجاوز لغة واجهة التطبيق. لغة التطبيق يغير القوائم والأزرار والرسائل في جميع أنحاء التطبيق. لا يغير المحتوى القادم من GitHub. اتباع النظام 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 15027dbe..6b8feb18 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -114,7 +114,7 @@ চেহারা ভাষা - অ্যাপের UI ভাষা ওভাররাইড করুন। তৎক্ষণাৎ প্রয়োগ হয় — পুনরায় চালু করার প্রয়োজন নেই। + অ্যাপের UI ভাষা ওভাররাইড করুন। অ্যাপের ভাষা সম্পূর্ণ অ্যাপের মেনু, বোতাম এবং বার্তা পরিবর্তন করে। GitHub থেকে আসা বিষয়বস্তু পরিবর্তন করে না। সিস্টেম অনুসরণ করুন 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 42565839..55793062 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -96,7 +96,7 @@ APARIENCIA IDIOMA - Reemplaza el idioma de la interfaz. Se aplica al instante — no requiere reinicio. + Reemplaza el idioma de la interfaz. Idioma de la aplicación Cambia los menús, botones y mensajes en toda la aplicación. No cambia el contenido que viene de GitHub. Seguir el sistema 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 1bd5c199..6dc20b16 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -96,7 +96,7 @@ APPARENCE LANGUE - Remplace la langue de l\'interface. Prise en compte immédiate — aucun redémarrage requis. + Remplace la langue de l\'interface. Langue de l\'application Modifie les menus, boutons et messages dans toute l\'application. Ne modifie pas le contenu provenant de GitHub. Suivre le système 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 3ef871ca..abba20bb 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -114,7 +114,7 @@ उपस्थिति भाषा - ऐप की UI भाषा को बदलें। तुरंत लागू होता है — पुनरारंभ की आवश्यकता नहीं। + ऐप की UI भाषा को बदलें। ऐप भाषा पूरे ऐप में मेनू, बटन और संदेश बदलता है। GitHub से आने वाली सामग्री नहीं बदलती। सिस्टम का पालन करें 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 d5f09e88..81e17b63 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -114,8 +114,8 @@ ASPETTO LINGUA - Sovrascrivi la lingua dell\'interfaccia. Si applica immediatamente, senza riavvio. - Lingua dell\'app + Sovrascrivi la lingua dell\'interfaccia. + Lingua dell\'App Cambia menu, pulsanti e messaggi in tutta l\'app. Non modifica i contenuti provenienti da GitHub. Segui il sistema Riavvia per applicare la nuova lingua. 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 f9ac4bea..7412b147 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -96,7 +96,7 @@ 外観 言語 - アプリのUI言語を上書きします。再起動不要で即座に適用されます。 + アプリのUI言語を上書きします。 アプリの言語 アプリ全体のメニュー、ボタン、メッセージを変更します。GitHubからのコンテンツは変更されません。 システムに従う diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index c02cee6e..ef957582 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -112,7 +112,7 @@ 외관 언어 - 앱의 UI 언어를 재정의합니다. 즉시 적용되며 재시작이 필요 없습니다. + 앱의 UI 언어를 재정의합니다. 앱 언어 앱 전체의 메뉴, 버튼, 메시지를 변경합니다. GitHub의 콘텐츠는 변경하지 않습니다. 시스템 따름 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 d12f351f..87d47f6e 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -97,7 +97,7 @@ WYGLĄD JĘZYK - Zmień język interfejsu aplikacji. Stosowany natychmiast — bez restartu. + Zmień język interfejsu aplikacji. Język aplikacji Zmienia menu, przyciski i komunikaty w całej aplikacji. Nie zmienia treści pochodzącej z GitHub. Taki jak system 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 eb050feb..eded63c4 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -96,7 +96,7 @@ ВНЕШНИЙ ВИД ЯЗЫК - Переопределить язык интерфейса приложения. Применяется мгновенно — перезапуск не требуется. + Переопределить язык интерфейса приложения. Язык приложения Изменяет меню, кнопки и сообщения во всём приложении. Не изменяет содержимое с GitHub. Как в системе 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 2c941830..5c5afc99 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -113,7 +113,7 @@ GÖRÜNÜM DİL - Uygulama arayüz dilini geçersiz kılar. Anında uygulanır — yeniden başlatma gerekmez. + Uygulama arayüz dilini geçersiz kılar. Uygulama dili Uygulamadaki menüleri, düğmeleri ve mesajları değiştirir. GitHub\'dan gelen içeriği değiştirmez. Sistemi izle 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 033e1e8a..fe4c6bc8 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 @@ -98,7 +98,7 @@ 外观 语言 - 覆盖应用界面语言。即时生效,无需重启。 + 覆盖应用界面语言。 应用语言 更改应用中的菜单、按钮和消息。不会更改来自 GitHub 的内容。 跟随系统 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index ec0ab217..d74c8430 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -117,7 +117,7 @@ APPEARANCE LANGUAGE - Override the app\'s UI language. Applies instantly — no restart needed. + Override the app\'s UI language. App language Changes menus, buttons, and messages throughout the app. Does not change content coming from GitHub. Follow system diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 94f548cf..e05ae743 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -752,6 +752,12 @@ class TweaksViewModel( } is TweaksAction.OnAppLanguageSelected -> { + // Skip the write + restart prompt when the user re-picks + // the language that's already active — tapping the + // current option shouldn't look like a change on + // Desktop (would fire a spurious "restart to apply" + // snackbar) or churn DataStore on Android. + if (action.tag == _state.value.selectedAppLanguage) return viewModelScope.launch { tweaksRepository.setAppLanguage(action.tag) // Android: `MainActivity` is subscribed to the From ad816203281c9540ba89a603bb9ab4c2e67baede Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 14:59:06 +0500 Subject: [PATCH 11/12] Add CrashReporter and improve app startup resilience - Implement `CrashReporter` for JVM to log stdout/stderr to a session file and write detailed crash dumps on uncaught exceptions. - Add OS-specific log directory resolution for Windows, macOS, and Linux. - Wrap the initial language preference read in `runBlocking` with a 2-second timeout to prevent potential DataStore stalls from blocking app launch. - Initialize `CrashReporter` at the entry point to capture early initialization errors. --- .../zed/rainxch/githubstore/CrashReporter.kt | 125 ++++++++++++++++++ .../zed/rainxch/githubstore/DesktopApp.kt | 21 ++- 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt new file mode 100644 index 00000000..0ddfdfa2 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt @@ -0,0 +1,125 @@ +package zed.rainxch.githubstore + +import java.io.File +import java.io.FileOutputStream +import java.io.PrintStream +import java.io.PrintWriter +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object CrashReporter { + private const val MAX_SESSION_LOG_BYTES = 5L * 1024 * 1024 + + private val logDir: File by lazy { resolveLogDir().also { it.mkdirs() } } + + fun install() { + val teed = + runCatching { + val file = File(logDir, "session.log") + rotateIfLarge(file) + PrintStream(FileOutputStream(file, true), true, Charsets.UTF_8) + .also { stream -> + System.setOut(TeePrintStream(System.out, stream)) + System.setErr(TeePrintStream(System.err, stream)) + } + }.getOrNull() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { writeCrashDump(thread, throwable) } + runCatching { throwable.printStackTrace(System.err) } + } + + if (teed != null) { + println("=== GitHub Store session ${Instant.now()} ===") + println( + "OS=${System.getProperty("os.name")} ${System.getProperty("os.version")} " + + "(${System.getProperty("os.arch")})", + ) + println( + "Java=${System.getProperty("java.version")} (${System.getProperty("java.vendor")})", + ) + println("LogDir=${logDir.absolutePath}") + } + } + + private fun writeCrashDump( + thread: Thread, + throwable: Throwable, + ) { + val file = File(logDir, "crash-${timestamp()}.log") + PrintWriter(file, Charsets.UTF_8).use { writer -> + writer.println("=== GitHub Store crash ===") + writer.println("Time: ${Instant.now()}") + writer.println("Thread: ${thread.name}") + writer.println( + "OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} " + + "(${System.getProperty("os.arch")})", + ) + writer.println( + "Java: ${System.getProperty("java.version")} (${System.getProperty("java.vendor")})", + ) + writer.println() + throwable.printStackTrace(writer) + } + } + + private fun rotateIfLarge(file: File) { + if (!file.exists() || file.length() <= MAX_SESSION_LOG_BYTES) return + val rotated = File(file.parentFile, "session.1.log") + if (rotated.exists()) rotated.delete() + file.renameTo(rotated) + } + + private fun resolveLogDir(): File { + val home = File(System.getProperty("user.home")) + val osName = System.getProperty("os.name").orEmpty().lowercase() + return when { + "mac" in osName -> { + File(home, "Library/Logs/GitHub-Store") + } + + "win" in osName -> { + val localAppData = System.getenv("LOCALAPPDATA")?.let(::File) ?: home + File(localAppData, "GitHub-Store/logs") + } + + else -> { + val stateHome = + System.getenv("XDG_STATE_HOME")?.let(::File) + ?: File(home, ".local/state") + File(stateHome, "GitHub-Store/logs") + } + } + } + + private fun timestamp(): String = + DateTimeFormatter + .ofPattern("yyyyMMdd-HHmmss-SSS") + .withZone(ZoneId.systemDefault()) + .format(Instant.now()) +} + +private class TeePrintStream( + private val primary: PrintStream, + private val secondary: PrintStream, +) : PrintStream(primary) { + override fun write(b: Int) { + primary.write(b) + runCatching { secondary.write(b) } + } + + override fun write( + buf: ByteArray, + off: Int, + len: Int, + ) { + primary.write(buf, off, len) + runCatching { secondary.write(buf, off, len) } + } + + override fun flush() { + primary.flush() + runCatching { secondary.flush() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index f6ea126b..ca6bec04 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.GlobalContext @@ -28,7 +29,14 @@ import zed.rainxch.githubstore.core.presentation.res.app_name import java.awt.Desktop import kotlin.system.exitProcess +private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L + fun main(args: Array) { + // Install first so anything that blows up during Koin init or + // resource loading leaves a diagnosable trail on disk (see + // `CrashReporter.resolveLogDir` for the per-OS path). + CrashReporter.install() + // Reduce JVM DNS cache TTL so network changes (VPN on/off) are picked up quickly. // Default JVM caches positive lookups for 30s and negative lookups forever, // which breaks connectivity when a VPN changes DNS/routing mid-session. @@ -43,11 +51,22 @@ fun main(args: Array) { // language swaps surface as a "restart required" snackbar from the // Tweaks screen; this block just covers the cold-start path so // users see their chosen language immediately on next launch. + // + // Timeout guards against a stalled DataStore read blocking window + // creation and deep-link dispatch — we fall back to system language + // rather than hang the launch. runBlocking { val koin = GlobalContext.get() val tweaksRepo = koin.get() val localization = koin.get() - val tag = tweaksRepo.getAppLanguage().first() + val tag = + try { + withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { + tweaksRepo.getAppLanguage().first() + } + } catch (_: Throwable) { + null + } localization.setActiveLanguageTag(tag) } From b362dc2dffbe165714c00d9b30db8882ad7ed398 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 20 Apr 2026 15:11:42 +0500 Subject: [PATCH 12/12] Refine exception handling for language preference loading - Change caught exception type from `Throwable` to `Exception` when reading the app language preference in both `DesktopApp.kt` and `MainActivity.kt`. --- .../androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt | 2 +- .../src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index 2ab42bb2..93b90641 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -60,7 +60,7 @@ class MainActivity : ComponentActivity() { withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { tweaksRepository.getAppLanguage().first() } - } catch (_: Throwable) { + } catch (_: Exception) { null } localizationManager.setActiveLanguageTag(tag) diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index ca6bec04..e9fc4acd 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -64,7 +64,7 @@ fun main(args: Array) { withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { tweaksRepo.getAppLanguage().first() } - } catch (_: Throwable) { + } catch (_: Exception) { null } localization.setActiveLanguageTag(tag)