Stabilizza i18n Android 2.0.5: lingua live senza perdere sessione né crashare il wizard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 09:36:56 +02:00
co-authored by Cursor
parent 97af398654
commit ff4de33cc5
8 changed files with 235 additions and 79 deletions
@@ -6,8 +6,8 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.key
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@@ -28,9 +28,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val localeTag = AppLocale.currentTag(this)
// Inizializza il flow dalla preferenza salvata prima del primo frame Compose.
AppLocale.currentTag(this)
setContent {
key(localeTag) {
ProvideAppLocale {
MatchLiveTheme {
AppNavHost(container)
}
@@ -8,12 +8,25 @@ import android.os.Handler
import android.os.LocaleList
import android.os.Looper
import androidx.appcompat.app.AppCompatDelegate
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
* Lingua app persistita in SharedPreferences + context wrapping.
* Più affidabile di soli AppCompat locales su ComponentActivity/Compose.
* Lingua app persistita in SharedPreferences.
* L'UI Compose si aggiorna via [ProvideAppLocale] senza recreate Activity
* (così non si perde navigazione/sessione).
*/
object AppLocale {
const val SYSTEM = ""
@@ -21,6 +34,11 @@ object AppLocale {
private const val PREFS = "mltv_locale"
private const val KEY_TAG = "tag"
private val tagState = MutableStateFlow<String?>(null)
/** Tag corrente (null = non ancora letto; i consumer usano [currentTag]). */
val tagFlow: StateFlow<String?> = tagState.asStateFlow()
data class Option(val tag: String, val labelRes: Int)
val options = listOf(
@@ -38,46 +56,117 @@ object AppLocale {
}
fun currentTag(context: Context): String {
tagState.value?.let { return normalize(it) }
val stored = context.applicationContext
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_TAG, SYSTEM)
.orEmpty()
return normalize(stored)
return normalize(stored).also { tagState.value = it }
}
fun wrap(context: Context): Context {
val tag = currentTag(context)
if (tag.isBlank()) return context
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
val config = Configuration(context.resources.configuration)
config.setLocales(LocaleList(locale))
return context.createConfigurationContext(config)
return wrapWithTag(context, tag)
}
fun apply(activity: Activity, tag: String) {
fun wrapWithTag(context: Context, tag: String): Context {
return context.createConfigurationContext(configurationFor(context, tag))
}
/**
* Context per Compose: mantiene l'Activity come base (serve a
* LocalActivityResultRegistryOwner / dialog / picker), ma espone
* Resources nella lingua scelta.
*/
fun wrapForCompose(context: Context, tag: String): Context {
val config = configurationFor(context, tag)
val localized = context.createConfigurationContext(config)
val activity = context.findActivity() ?: return localized
return LocalizedContextWrapper(activity, localized)
}
fun configurationFor(context: Context, tag: String): Configuration {
val normalized = normalize(tag)
val prefs = activity.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (normalized == currentTag(activity)) return
if (normalized.isBlank()) {
// Lingua di sistema: usa la config del device, non quella già wrappata dall'Activity.
return Configuration(context.applicationContext.resources.configuration)
}
val locale = Locale.forLanguageTag(normalized)
Locale.setDefault(locale)
val config = Configuration(context.applicationContext.resources.configuration)
config.setLocales(LocaleList(locale))
return config
}
/**
* Applica la lingua. Non richiede Activity: basta qualsiasi Context
* (anche createConfigurationContext) perché usa applicationContext.
*/
fun apply(context: Context, tag: String) {
val app = context.applicationContext
val normalized = normalize(tag)
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (normalized == currentTag(app)) return
// commit sincronizzato: deve essere scritto prima del recreate
prefs.edit().putString(KEY_TAG, normalized).commit()
tagState.value = normalized
val locales = if (normalized.isBlank()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(normalized)
}
AppCompatDelegate.setApplicationLocales(locales)
activity.recreate()
runCatching { AppCompatDelegate.setApplicationLocales(locales) }
}
fun applyAfterDismiss(activity: Activity?, tag: String, dismiss: () -> Unit) {
fun applyAfterDismiss(context: Context, tag: String, dismiss: () -> Unit) {
dismiss()
Handler(Looper.getMainLooper()).post {
if (activity == null || activity.isFinishing || activity.isDestroyed) return@post
apply(activity, tag)
apply(context, tag)
}
}
}
/**
* Wrapper che resta un [ContextWrapper] sull'Activity (findActivity / ActivityResult)
* ma legge stringhe/asset dal context localizzato.
*/
private class LocalizedContextWrapper(
base: Context,
private val localized: Context,
) : ContextWrapper(base) {
override fun getResources() = localized.resources
override fun getAssets() = localized.assets
}
@Composable
fun ProvideAppLocale(content: @Composable () -> Unit) {
val baseContext = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val activityResultOwner = LocalActivityResultRegistryOwner.current
?: baseContext.findActivity() as? ActivityResultRegistryOwner
val storedTag = remember(baseContext) { AppLocale.currentTag(baseContext) }
val tagFromFlow by AppLocale.tagFlow.collectAsState(initial = storedTag)
val tag = AppLocale.normalize(tagFromFlow ?: storedTag)
val localizedContext = remember(baseContext, tag, baseConfiguration) {
AppLocale.wrapForCompose(baseContext, tag)
}
val localizedConfiguration = remember(baseContext, tag, baseConfiguration) {
AppLocale.configurationFor(baseContext, tag)
}
CompositionLocalProvider(
LocalContext provides localizedContext,
LocalConfiguration provides localizedConfiguration,
) {
if (activityResultOwner != null) {
CompositionLocalProvider(
LocalActivityResultRegistryOwner provides activityResultOwner,
content = content,
)
} else {
content()
}
}
}
@@ -18,7 +18,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.findActivity
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
@@ -26,7 +25,6 @@ fun LanguagePickerDialog(
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val activity = context.findActivity()
val selected = AppLocale.currentTag(context)
AlertDialog(
onDismissRequest = onDismiss,
@@ -37,7 +35,7 @@ fun LanguagePickerDialog(
val optionTag = AppLocale.normalize(option.tag)
val isSelected = selected == optionTag
fun choose() {
AppLocale.applyAfterDismiss(activity, option.tag, onDismiss)
AppLocale.applyAfterDismiss(context, option.tag, onDismiss)
}
Row(
modifier = Modifier
@@ -1,6 +1,5 @@
package com.matchlivetv.match_live_tv.ui.login
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,11 +13,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
@@ -45,6 +48,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
container: AppContainer,
@@ -82,26 +86,33 @@ fun LoginScreen(
}
}
MatchScreenScaffold {
MatchScreenScaffold(
topBar = {
TopAppBar(
title = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
actionIconContentColor = MatchColors.TextSecondary,
),
actions = {
IconButton(onClick = { showLanguagePicker = true }) {
Icon(
imageVector = Icons.Filled.Language,
contentDescription = stringResource(R.string.language_label),
tint = MatchColors.TextSecondary,
)
}
},
)
},
) {
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
Box(modifier = Modifier.fillMaxWidth()) {
IconButton(
onClick = { showLanguagePicker = true },
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
Icons.Default.Language,
contentDescription = stringResource(R.string.language_label),
tint = MatchColors.TextSecondary,
)
}
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 32.dp),
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
MatchLiveWordmark(showSlogan = true)
@@ -181,8 +192,8 @@ fun LoginScreen(
@Composable
private fun matchTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedTextColor = androidx.compose.ui.graphics.Color.White,
unfocusedTextColor = androidx.compose.ui.graphics.Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = MatchColors.PrimaryRed,
unfocusedBorderColor = MatchColors.Outline,
focusedLabelColor = MatchColors.PrimaryRed,
@@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.findActivity
import com.matchlivetv.match_live_tv.core.isScheduledFuture
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.domain.Match
@@ -139,11 +140,12 @@ fun ScheduleMatchBottomSheet(
}
fun pickDateTime() {
val dialogContext = context.findActivity() ?: context
DatePickerDialog(
context,
dialogContext,
{ _, year, month, day ->
TimePickerDialog(
context,
dialogContext,
{ _, hour, minute ->
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
},
@@ -22,8 +22,15 @@ fun SplashScreen(
) {
LaunchedEffect(Unit) {
container.bootstrapAuth()
val session = container.authRepository.validateOrRefresh()
if (session != null) onAuthenticated() else onUnauthenticated()
val stored = container.authRepository.currentSession()
if (stored == null) {
onUnauthenticated()
return@LaunchedEffect
}
// Sessione locale presente: resta autenticato anche se la rete fallisce
// (es. recreate dopo cambio lingua). Refresh best-effort in background.
onAuthenticated()
runCatching { container.authRepository.validateOrRefresh() }
}
MatchScreenScaffold {