Переключение языка

This commit is contained in:
2026-05-18 15:35:06 +03:00
parent f3f99aed5a
commit f99d79fece
46 changed files with 1368 additions and 424 deletions

View File

@@ -67,6 +67,8 @@ dependencies {
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)

View File

@@ -21,6 +21,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Wallenc"
android:localeConfig="@xml/locales_config"
android:enableOnBackInvokedCallback="true"
tools:targetApi="37">
<activity

View File

@@ -5,9 +5,9 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -23,7 +23,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
@Inject
lateinit var yandexSignInService: YandexSignInService

View File

@@ -1,8 +1,10 @@
package com.github.nullptroma.wallenc.app
import android.app.Application
import android.content.Context
import androidx.work.Configuration
import com.github.nullptroma.wallenc.app.di.HiltWorkerFactoryEntryPoint
import com.github.nullptroma.wallenc.app.locale.AppLocaleStorage
import com.github.nullptroma.wallenc.app.sync.StorageSyncBootstrap
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.EntryPointAccessors
@@ -18,8 +20,15 @@ class WallencApplication : Application(), Configuration.Provider {
@Inject
lateinit var storageSyncBootstrap: StorageSyncBootstrap
override fun attachBaseContext(base: Context) {
AppLocaleStorage.applyStored(base)
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
AppLocaleStorage.migrateLegacyDataStoreIfNeeded(this)
AppLocaleStorage.applyStored(this)
taskPipelineForegroundBootstrap.start()
storageSyncBootstrap.start()
}
@@ -34,4 +43,4 @@ class WallencApplication : Application(), Configuration.Provider {
.setWorkerFactory(factory)
.build()
}
}
}

View File

@@ -0,0 +1,18 @@
package com.github.nullptroma.wallenc.app.di
import com.github.nullptroma.wallenc.app.locale.AppLocaleControllerImpl
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class LocaleModule {
@Binds
@Singleton
abstract fun bindAppLocaleController(impl: AppLocaleControllerImpl): AppLocaleController
}

View File

@@ -0,0 +1,25 @@
package com.github.nullptroma.wallenc.app.locale
import android.content.Context
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppLocaleControllerImpl @Inject constructor(
@param:ApplicationContext private val context: Context,
) : AppLocaleController {
override val language: Flow<AppLanguage> = AppLocaleStorage.languageFlow(context)
override suspend fun setLanguage(language: AppLanguage) {
AppLocaleStorage.persistLanguage(context, language)
}
override suspend fun applyStoredLocale() {
AppLocaleStorage.applyStored(context)
}
}

View File

@@ -0,0 +1,108 @@
package com.github.nullptroma.wallenc.app.locale
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
/**
* Хранение выбранного языка. SharedPreferences — для синхронного чтения в
* [android.app.Application.attachBaseContext]; DataStore — только для миграции со старой версии.
*/
internal object AppLocaleStorage {
private const val PREFS_NAME = "app_locale"
private const val PREFS_KEY_LANGUAGE = "app_language"
private val legacyLanguageKey = stringPreferencesKey(PREFS_KEY_LANGUAGE)
private val Context.legacyLocaleDataStore: DataStore<Preferences> by preferencesDataStore(
name = "app_locale",
)
/** В [android.app.Application.attachBaseContext] [Context.getApplicationContext] ещё null. */
private fun storageContext(context: Context): Context =
context.applicationContext ?: context
private fun prefs(context: Context) =
storageContext(context).getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun languageFlow(context: Context): Flow<AppLanguage> = callbackFlow {
val storage = storageContext(context)
val listener =
android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == PREFS_KEY_LANGUAGE) {
trySend(readLanguageSync(storage))
}
}
trySend(readLanguageSync(storage))
prefs(storage).registerOnSharedPreferenceChangeListener(listener)
awaitClose { prefs(storage).unregisterOnSharedPreferenceChangeListener(listener) }
}
fun persistLanguage(context: Context, language: AppLanguage) {
prefs(context).edit().putString(PREFS_KEY_LANGUAGE, language.storageValue).apply()
applyLanguage(language)
}
fun readLanguageSync(context: Context): AppLanguage {
val raw = prefs(context).getString(PREFS_KEY_LANGUAGE, null)
return raw?.toAppLanguage() ?: AppLanguage.System
}
/** Без блокировок; безопасно из [android.app.Application.attachBaseContext]. */
fun applyStored(context: Context) {
applyLanguage(readLanguageSync(context))
}
fun applyLanguage(language: AppLanguage) {
val localeList = when (language) {
AppLanguage.System -> LocaleListCompat.getEmptyLocaleList()
AppLanguage.English -> LocaleListCompat.forLanguageTags("en")
AppLanguage.Russian -> LocaleListCompat.forLanguageTags("ru")
}
AppCompatDelegate.setApplicationLocales(localeList)
}
/** Однократно переносит значение из DataStore (если SP ещё пуст). */
fun migrateLegacyDataStoreIfNeeded(context: Context) {
val storage = storageContext(context)
if (prefs(storage).contains(PREFS_KEY_LANGUAGE)) {
return
}
runBlocking {
runCatching {
val legacy = storage.legacyLocaleDataStore.data.first()[legacyLanguageKey]
if (legacy != null) {
prefs(storage).edit().putString(PREFS_KEY_LANGUAGE, legacy).apply()
}
}
}
}
private fun String.toAppLanguage(): AppLanguage = when (this) {
STORAGE_EN -> AppLanguage.English
STORAGE_RU -> AppLanguage.Russian
else -> AppLanguage.System
}
private val AppLanguage.storageValue: String
get() = when (this) {
AppLanguage.System -> STORAGE_SYSTEM
AppLanguage.English -> STORAGE_EN
AppLanguage.Russian -> STORAGE_RU
}
private const val STORAGE_SYSTEM = "system"
private const val STORAGE_EN = "en"
private const val STORAGE_RU = "ru"
}

View File

@@ -17,6 +17,8 @@ import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.resolve
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -43,6 +45,9 @@ class TaskPipelineForegroundService : Service() {
@Inject
lateinit var orchestrator: ITaskOrchestrator
@Inject
lateinit var uiStrings: UiStringResolver
private var repeat = false
private var canPush = true
private var lastUiState: TaskForegroundUiState? = null
@@ -291,7 +296,7 @@ class TaskPipelineForegroundService : Service() {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE)
remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.VISIBLE)
remoteViews.setTextViewText(TASK_TITLE_IDS[index], task.title)
val label = task.progress?.label?.trim().orEmpty()
val label = task.progress?.label?.resolve(uiStrings).orEmpty()
val fraction = task.progress?.fraction
if (fraction != null) {
if (label.isNotEmpty()) {

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Фоновые задачи</string>
<string name="task_notification_title">Задачи Wallenc</string>
<string name="task_notification_preparing">Подготовка…</string>
<string name="task_notification_indeterminate">Выполняется…</string>
<string name="task_notification_cancel">Отмена</string>
</resources>

View File

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Фоновые задачи</string>
<string name="task_notification_title">Задачи Wallenc</string>
<string name="task_notification_preparing">Подготовка</string>
<string name="task_notification_indeterminate">Выполняется</string>
<string name="task_notification_cancel">Отмена</string>
<string name="task_notification_channel_name">Background tasks</string>
<string name="task_notification_title">Wallenc tasks</string>
<string name="task_notification_preparing">Preparing</string>
<string name="task_notification_indeterminate">Running</string>
<string name="task_notification_cancel">Cancel</string>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Wallenc" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Wallenc" parent="Theme.AppCompat.Light.NoActionBar">
<!-- До первого кадра Compose и системный splash (12+) -->
<item name="android:windowBackground">@color/splash_screen_background</item>
<item name="android:windowSplashScreenBackground" tools:targetApi="31">

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
</locale-config>