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:
@@ -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)
|
||||
}
|
||||
|
||||
+108
-19
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
+28
-17
@@ -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,
|
||||
|
||||
+4
-2
@@ -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)
|
||||
},
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user