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:
@@ -1,12 +1,12 @@
|
|||||||
# Gap Android → iOS: allineamento app nativa
|
# Gap Android → iOS: allineamento app nativa
|
||||||
|
|
||||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android `2.0.4-native`**.
|
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android `2.0.5-native`**.
|
||||||
|
|
||||||
**Aggiornato:** 24 luglio 2026
|
**Aggiornato:** 24 luglio 2026
|
||||||
**Riferimento Android (produzione / telefono):** `2.0.4-native` (`versionCode` **25**), API `https://www.matchlivetv.it`
|
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
|
||||||
**Stato iOS attuale:** marketing `2.0.0`, build `21` — i18n **solo Login parziale**
|
**Stato iOS attuale:** marketing `2.0.0`, build `21` — i18n **solo Login parziale**
|
||||||
|
|
||||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione.
|
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog,
|
|||||||
|
|
||||||
Su Android (rilasciato) la lingua scelta dall’utente vale su **tutta l’app** usabile:
|
Su Android (rilasciato) la lingua scelta dall’utente vale su **tutta l’app** usabile:
|
||||||
|
|
||||||
- Persistenza + apply (`AppLocale` / SharedPreferences `mltv_locale` + recreate)
|
- Persistenza + apply live (`AppLocale` / SharedPreferences `mltv_locale` + `ProvideAppLocale`)
|
||||||
- Icona **globo** su Login e Matches
|
- Icona **globo** su Login (TopAppBar) e Matches
|
||||||
- Hub Partite localizzato
|
- Hub Partite localizzato
|
||||||
- Bottom sheet e dialog Matches (es. «Choose team», nuova partita, programma, elimina, riprendi)
|
- Bottom sheet e dialog Matches (es. «Choose team», nuova partita, programma, elimina, riprendi)
|
||||||
- Wizard 3 step + branding colori
|
- Wizard 3 step + branding colori
|
||||||
@@ -28,12 +28,42 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stato a confronto (target = Android 2.0.4)
|
## Lezioni Android 2.0.5 (obbligatorie su iOS)
|
||||||
|
|
||||||
| Area | Android 2.0.4 | iOS oggi | Da fare su iOS |
|
Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
|
||||||
|
|
||||||
|
| Problema | Sintomo | Fix Android (da replicare in spirito) |
|
||||||
|
|----------|---------|----------------------------------------|
|
||||||
|
| Recreate Activity al cambio lingua | Torna al Login / perde nav | **Niente recreate** obbligatorio: aggiorna UI via stato lingua + refresh stringhe |
|
||||||
|
| Context “nudo” per le stringhe | Crash wizard: `No ActivityResultRegistryOwner` / picker foto | Mantieni **Activity** come host; localizza solo Resources / environment |
|
||||||
|
| `findActivity()` su context configurazione | Cambio lingua non applica (apply silent no-op) | Applica lingua con `applicationContext` / store globale, non dipendere da Activity dal dialog |
|
||||||
|
| Globe sotto lo scroll del form Login | Tap ignorato | Globe in **toolbar/top bar**, non overlay sotto `ScrollView`/`Column` |
|
||||||
|
| Splash che richiede rete per restare loggato | Dopo cambio lingua → Login se API lenta | Se token locale presente → resta autenticato; refresh rete best-effort |
|
||||||
|
|
||||||
|
### Pattern Android di riferimento (Compose)
|
||||||
|
|
||||||
|
1. `AppLocale.tagFlow` + prefs `mltv_locale`
|
||||||
|
2. `ProvideAppLocale`: `LocalConfiguration` + `LocalizedContextWrapper(Activity, localizedResources)`
|
||||||
|
→ stringhe aggiornate **e** `LocalActivityResultRegistryOwner` integro
|
||||||
|
3. `LanguagePickerDialog` chiama `AppLocale.apply(context, tag)` (applicationContext), non `findActivity()` obbligatorio
|
||||||
|
4. Splash: `currentSession() != null` → Matches; `validateOrRefresh()` in background
|
||||||
|
|
||||||
|
### Equivalente SwiftUI consigliato
|
||||||
|
|
||||||
|
1. `AppLanguage` / `@AppStorage` + `NotificationCenter` (già presente) o `environment(\.locale, …)`
|
||||||
|
2. Root: `.environment(\.locale, locale)` **e** `.id(languageTick)` sulle schermate che mostrano testo
|
||||||
|
3. **Non** distruggere `NavigationStack` / session store al cambio lingua
|
||||||
|
4. Photo picker / `PhotosPicker` / `UIImagePicker`: restano agganciati alla `View` host, non a un context “fake”
|
||||||
|
5. Cold start: se token in Keychain/UserDefaults → vai a Matches anche se `/me` fallisce temporaneamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stato a confronto (target = Android 2.0.5)
|
||||||
|
|
||||||
|
| Area | Android 2.0.5 | iOS oggi | Da fare su iOS |
|
||||||
|------|---------------|----------|----------------|
|
|------|---------------|----------|----------------|
|
||||||
| Persistenza lingua | SharedPreferences + wrap + recreate | UserDefaults + notification | Verificare refresh UI su **tutte** le schermate (non solo Login/Matches root) |
|
| Persistenza lingua | SharedPreferences + `ProvideAppLocale` (no recreate nav) | UserDefaults + notification | Refresh UI su **tutte** le schermate **senza** reset sessione/nav |
|
||||||
| Picker Login | Icona globo top-right | Testo / picker presente | Allineare a **icona globo** come Android |
|
| Picker Login | Icona globo in **TopAppBar** | Testo / picker presente | Allineare a **icona globo** in toolbar |
|
||||||
| Picker Matches | Icona globo top bar | Presente | OK se già icona; altrimenti allineare |
|
| Picker Matches | Icona globo top bar | Presente | OK se già icona; altrimenti allineare |
|
||||||
| Login stringhe | Sì | Sì (`L10n`) | OK |
|
| Login stringhe | Sì | Sì (`L10n`) | OK |
|
||||||
| Slogan wordmark | `app_slogan` | Chiave `app.slogan` **non usata** | Collegare `L10n.t("app.slogan")` |
|
| Slogan wordmark | `app_slogan` | Chiave `app.slogan` **non usata** | Collegare `L10n.t("app.slogan")` |
|
||||||
@@ -46,7 +76,7 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
| Broadcast + score dialog | Sì (`broadcast_*`, `score_*`) | IT | Localizzare |
|
| Broadcast + score dialog | Sì (`broadcast_*`, `score_*`) | IT | Localizzare |
|
||||||
| Catalogo stringhe | `values` + `values-en/fr/de/es` (~230 chiavi) | ~14 chiavi in `AppLanguage.swift` | Espandere `L10n` (o migrare a `Localizable.xcstrings`) |
|
| Catalogo stringhe | `values` + `values-en/fr/de/es` (~230 chiavi) | ~14 chiavi in `AppLanguage.swift` | Espandere `L10n` (o migrare a `Localizable.xcstrings`) |
|
||||||
| Logout UI | Icona | Testo | Icona SF Symbol |
|
| Logout UI | Icona | Testo | Icona SF Symbol |
|
||||||
| Versione | `2.0.4-native` / **25** | `2.0.0` / **21** | Bump a **2.0.4** / **25** (o build successiva se 22–25 già usati su TestFlight) |
|
| Versione | `2.0.5-native` / **26** | `2.0.0` / **21** | Bump a **2.0.5** / **26** (o build successiva se conflitto TestFlight) |
|
||||||
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK in prod; allineare solo semantica dev se serve |
|
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK in prod; allineare solo semantica dev se serve |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,9 +87,10 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
|
|
||||||
| Ruolo | Path |
|
| Ruolo | Path |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Locale | `native/android/.../core/AppLocale.kt` |
|
| Locale + ProvideAppLocale | `native/android/.../core/AppLocale.kt` |
|
||||||
| Picker | `native/android/.../ui/components/LanguagePickerDialog.kt` |
|
| Picker | `native/android/.../ui/components/LanguagePickerDialog.kt` |
|
||||||
| Activity | `native/android/.../MainActivity.kt` |
|
| Activity | `native/android/.../MainActivity.kt` |
|
||||||
|
| Splash (sessione locale) | `native/android/.../ui/splash/SplashScreen.kt` |
|
||||||
| Stringhe | `native/android/app/src/main/res/values/strings.xml` + `values-{en,fr,de,es}/` |
|
| Stringhe | `native/android/app/src/main/res/values/strings.xml` + `values-{en,fr,de,es}/` |
|
||||||
| Login | `.../ui/login/LoginScreen.kt` |
|
| Login | `.../ui/login/LoginScreen.kt` |
|
||||||
| Hub | `.../ui/matches/MatchesScreen.kt` |
|
| Hub | `.../ui/matches/MatchesScreen.kt` |
|
||||||
@@ -79,6 +110,7 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
| Wizard | `UI/Wizard/` (o path equivalente) |
|
| Wizard | `UI/Wizard/` (o path equivalente) |
|
||||||
| Broadcast | `UI/Broadcast/` (o path equivalente) |
|
| Broadcast | `UI/Broadcast/` (o path equivalente) |
|
||||||
| Wordmark | `native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift` |
|
| Wordmark | `native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift` |
|
||||||
|
| Auth / splash equivalent | entry root / session restore |
|
||||||
| Versioni | `generate_xcodeproj.py`, `project.pbxproj`, `Info.plist` |
|
| Versioni | `generate_xcodeproj.py`, `project.pbxproj`, `Info.plist` |
|
||||||
| Build | `scripts/build_ios_release.sh` |
|
| Build | `scripts/build_ios_release.sh` |
|
||||||
|
|
||||||
@@ -99,9 +131,9 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
|
|
||||||
### Fase B — Login + wordmark (rapido)
|
### Fase B — Login + wordmark (rapido)
|
||||||
|
|
||||||
5. Login: icona globo in alto a destra (come Android), non solo testo.
|
5. Login: icona globo in toolbar (come Android TopAppBar), non overlay che lo scroll può coprire.
|
||||||
6. `MatchLiveWordmark`: `L10n.t("app.slogan")`.
|
6. `MatchLiveWordmark`: `L10n.t("app.slogan")`.
|
||||||
7. Verificare che al cambio lingua Login si ricrei/rinfreschi (notification già presente).
|
7. Cambio lingua: aggiorna stringhe **senza** buttare fuori l’utente (se già loggato altrove, stessa regola).
|
||||||
|
|
||||||
### Fase C — Hub Matches + sheet/dialog (priorità UX — screenshot «Scegli squadra»)
|
### Fase C — Hub Matches + sheet/dialog (priorità UX — screenshot «Scegli squadra»)
|
||||||
|
|
||||||
@@ -120,17 +152,19 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` —
|
|||||||
|
|
||||||
13. Localizzare step Partita / Trasmissione / Test rete + branding editor + color hue (`wizard.*`).
|
13. Localizzare step Partita / Trasmissione / Test rete + branding editor + color hue (`wizard.*`).
|
||||||
14. Errori validazione e CTA AVANTI/INDIETRO/INIZIA.
|
14. Errori validazione e CTA AVANTI/INDIETRO/INIZIA.
|
||||||
|
15. Verificare photo/logo picker dopo cambio lingua (su Android crashava senza Activity host).
|
||||||
|
|
||||||
### Fase E — Broadcast
|
### Fase E — Broadcast
|
||||||
|
|
||||||
15. Overlay controlli, contentDescription, snackbar pausa/ripresa/regia.
|
16. Overlay controlli, contentDescription, snackbar pausa/ripresa/regia.
|
||||||
16. Dialog termina diretta, set concluso, chiudi set, partita terminata (`broadcast.*`, `score.*`).
|
17. Dialog termina diretta, set concluso, chiudi set, partita terminata (`broadcast.*`, `score.*`).
|
||||||
|
|
||||||
### Fase F — Versione e release
|
### Fase F — Sessione + versione e release
|
||||||
|
|
||||||
17. Bump `MARKETING_VERSION` → `2.0.4`, `CURRENT_PROJECT_VERSION` → `25` (o successivo se conflitto TestFlight).
|
18. Cold start / cambio lingua: token locale → resta in Matches (non forzare Login se `/me` fallisce).
|
||||||
18. Aggiornare `generate_xcodeproj.py` **e** `Info.plist`, poi `python3 generate_xcodeproj.py`.
|
19. Bump `MARKETING_VERSION` → `2.0.5`, `CURRENT_PROJECT_VERSION` → `26` (o successivo se conflitto TestFlight).
|
||||||
19. `./scripts/build_ios_release.sh` (API default già produzione).
|
20. Aggiornare `generate_xcodeproj.py` **e** `Info.plist`, poi `python3 generate_xcodeproj.py`.
|
||||||
|
21. `./scripts/build_ios_release.sh` (API default già produzione).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,14 +190,15 @@ Non lasciare fuori scope wizard/broadcast: su Android **sono già tradotti**; iO
|
|||||||
1. `cd native/ios && python3 generate_xcodeproj.py`
|
1. `cd native/ios && python3 generate_xcodeproj.py`
|
||||||
2. Build simulatore; login produzione.
|
2. Build simulatore; login produzione.
|
||||||
3. Login: globo → English → label/errori/slogan in EN.
|
3. Login: globo → English → label/errori/slogan in EN.
|
||||||
4. Matches: saluto, CTA, empty state in EN.
|
4. **Loggato:** cambio lingua da Matches → resti su Matches, testi EN (niente ritorno al Login).
|
||||||
5. Apri «Change» / scegli squadra: titolo **«Choose team»** (non «Scegli squadra»).
|
5. Matches: saluto, CTA, empty state in EN.
|
||||||
6. Nuova partita / programma / elimina: tutto EN.
|
6. Apri «Change» / scegli squadra: titolo **«Choose team»** (non «Scegli squadra»).
|
||||||
7. Wizard 3 step in EN.
|
7. Nuova partita → Avvia ora → wizard si apre **senza crash**; step in EN.
|
||||||
8. (Se possibile) overlay diretta: dialog/termina in EN.
|
8. Wizard 3 step in EN; logo picker funziona.
|
||||||
9. Kill app → riapri: lingua persistita.
|
9. (Se possibile) overlay diretta: dialog/termina in EN.
|
||||||
10. Spot-check FR/DE/ES.
|
10. Kill app → riapri: lingua persistita **e** sessione resta se token valido.
|
||||||
11. Release: `./scripts/build_ios_release.sh`
|
11. Spot-check FR/DE/ES.
|
||||||
|
12. Release: `./scripts/build_ios_release.sh`
|
||||||
|
|
||||||
Test:
|
Test:
|
||||||
|
|
||||||
@@ -189,15 +224,28 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
|||||||
|
|
||||||
Nessuna modifica server richiesta.
|
Nessuna modifica server richiesta.
|
||||||
|
|
||||||
|
### Artefatti Android Play (riferimento)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# APK installazione diretta
|
||||||
|
./scripts/build_native_android_apk_prod.sh
|
||||||
|
|
||||||
|
# AAB per Google Play Console
|
||||||
|
cd native/android && ./gradlew bundleRelease -PAPI_BASE_URL=https://www.matchlivetv.it
|
||||||
|
# → app/build/outputs/bundle/release/app-release.aab
|
||||||
|
```
|
||||||
|
|
||||||
|
Versione corrente store: **`2.0.5-native` / versionCode `26`**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sintesi priorità (ordine di esecuzione)
|
## Sintesi priorità (ordine di esecuzione)
|
||||||
|
|
||||||
1. **A** Catalogo stringhe completo (copiare da Android `values*`)
|
1. **A** Catalogo stringhe completo (copiare da Android `values*`)
|
||||||
2. **C** Hub + sheet/dialog (fix «Scegli squadra» in EN)
|
2. **C** Hub + sheet/dialog (fix «Scegli squadra» in EN)
|
||||||
3. **B** Slogan + globo Login
|
3. **B** Slogan + globo Login (toolbar)
|
||||||
4. **D** Wizard
|
4. **D** Wizard (+ no crash picker)
|
||||||
5. **E** Broadcast
|
5. **E** Broadcast
|
||||||
6. **F** Versione `2.0.4` / `25` + build release
|
6. **F** Sessione stabile al cambio lingua + versione `2.0.5` / `26` + build release
|
||||||
|
|
||||||
Quando A–F sono chiusi, iOS è allineato ad Android **2.0.4-native** per UX multilingua e versioning store.
|
Quando A–F sono chiusi, iOS è allineato ad Android **2.0.5-native** per UX multilingua, stabilità sessione e versioning store.
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 25
|
versionCode = 26
|
||||||
versionName = "2.0.4-native"
|
versionName = "2.0.5-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
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.navigation.AppNavHost
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||||
|
|
||||||
@@ -28,9 +28,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
val localeTag = AppLocale.currentTag(this)
|
// Inizializza il flow dalla preferenza salvata prima del primo frame Compose.
|
||||||
|
AppLocale.currentTag(this)
|
||||||
setContent {
|
setContent {
|
||||||
key(localeTag) {
|
ProvideAppLocale {
|
||||||
MatchLiveTheme {
|
MatchLiveTheme {
|
||||||
AppNavHost(container)
|
AppNavHost(container)
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-19
@@ -8,12 +8,25 @@ import android.os.Handler
|
|||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
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 androidx.core.os.LocaleListCompat
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lingua app persistita in SharedPreferences + context wrapping.
|
* Lingua app persistita in SharedPreferences.
|
||||||
* Più affidabile di soli AppCompat locales su ComponentActivity/Compose.
|
* L'UI Compose si aggiorna via [ProvideAppLocale] senza recreate Activity
|
||||||
|
* (così non si perde navigazione/sessione).
|
||||||
*/
|
*/
|
||||||
object AppLocale {
|
object AppLocale {
|
||||||
const val SYSTEM = ""
|
const val SYSTEM = ""
|
||||||
@@ -21,6 +34,11 @@ object AppLocale {
|
|||||||
private const val PREFS = "mltv_locale"
|
private const val PREFS = "mltv_locale"
|
||||||
private const val KEY_TAG = "tag"
|
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)
|
data class Option(val tag: String, val labelRes: Int)
|
||||||
|
|
||||||
val options = listOf(
|
val options = listOf(
|
||||||
@@ -38,46 +56,117 @@ object AppLocale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun currentTag(context: Context): String {
|
fun currentTag(context: Context): String {
|
||||||
|
tagState.value?.let { return normalize(it) }
|
||||||
val stored = context.applicationContext
|
val stored = context.applicationContext
|
||||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_TAG, SYSTEM)
|
.getString(KEY_TAG, SYSTEM)
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
return normalize(stored)
|
return normalize(stored).also { tagState.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun wrap(context: Context): Context {
|
fun wrap(context: Context): Context {
|
||||||
val tag = currentTag(context)
|
val tag = currentTag(context)
|
||||||
if (tag.isBlank()) return context
|
return wrapWithTag(context, tag)
|
||||||
|
|
||||||
val locale = Locale.forLanguageTag(tag)
|
|
||||||
Locale.setDefault(locale)
|
|
||||||
val config = Configuration(context.resources.configuration)
|
|
||||||
config.setLocales(LocaleList(locale))
|
|
||||||
return context.createConfigurationContext(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 normalized = normalize(tag)
|
||||||
val prefs = activity.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
if (normalized.isBlank()) {
|
||||||
if (normalized == currentTag(activity)) return
|
// 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()
|
prefs.edit().putString(KEY_TAG, normalized).commit()
|
||||||
|
tagState.value = normalized
|
||||||
|
|
||||||
val locales = if (normalized.isBlank()) {
|
val locales = if (normalized.isBlank()) {
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
} else {
|
} else {
|
||||||
LocaleListCompat.forLanguageTags(normalized)
|
LocaleListCompat.forLanguageTags(normalized)
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
runCatching { AppCompatDelegate.setApplicationLocales(locales) }
|
||||||
activity.recreate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyAfterDismiss(activity: Activity?, tag: String, dismiss: () -> Unit) {
|
fun applyAfterDismiss(context: Context, tag: String, dismiss: () -> Unit) {
|
||||||
dismiss()
|
dismiss()
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
if (activity == null || activity.isFinishing || activity.isDestroyed) return@post
|
apply(context, tag)
|
||||||
apply(activity, 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 androidx.compose.ui.unit.dp
|
||||||
import com.matchlivetv.match_live_tv.R
|
import com.matchlivetv.match_live_tv.R
|
||||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
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
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -26,7 +25,6 @@ fun LanguagePickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context.findActivity()
|
|
||||||
val selected = AppLocale.currentTag(context)
|
val selected = AppLocale.currentTag(context)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -37,7 +35,7 @@ fun LanguagePickerDialog(
|
|||||||
val optionTag = AppLocale.normalize(option.tag)
|
val optionTag = AppLocale.normalize(option.tag)
|
||||||
val isSelected = selected == optionTag
|
val isSelected = selected == optionTag
|
||||||
fun choose() {
|
fun choose() {
|
||||||
AppLocale.applyAfterDismiss(activity, option.tag, onDismiss)
|
AppLocale.applyAfterDismiss(context, option.tag, onDismiss)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+25
-14
@@ -1,6 +1,5 @@
|
|||||||
package com.matchlivetv.match_live_tv.ui.login
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.Language
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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 com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -82,26 +86,33 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MatchScreenScaffold {
|
MatchScreenScaffold(
|
||||||
if (showLanguagePicker) {
|
topBar = {
|
||||||
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
|
TopAppBar(
|
||||||
}
|
title = {},
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
IconButton(
|
containerColor = MatchColors.Background,
|
||||||
onClick = { showLanguagePicker = true },
|
actionIconContentColor = MatchColors.TextSecondary,
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
),
|
||||||
) {
|
actions = {
|
||||||
|
IconButton(onClick = { showLanguagePicker = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Language,
|
imageVector = Icons.Filled.Language,
|
||||||
contentDescription = stringResource(R.string.language_label),
|
contentDescription = stringResource(R.string.language_label),
|
||||||
tint = MatchColors.TextSecondary,
|
tint = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (showLanguagePicker) {
|
||||||
|
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
MatchLiveWordmark(showSlogan = true)
|
MatchLiveWordmark(showSlogan = true)
|
||||||
@@ -181,8 +192,8 @@ fun LoginScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun matchTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
private fun matchTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
focusedTextColor = androidx.compose.ui.graphics.Color.White,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = androidx.compose.ui.graphics.Color.White,
|
unfocusedTextColor = Color.White,
|
||||||
focusedBorderColor = MatchColors.PrimaryRed,
|
focusedBorderColor = MatchColors.PrimaryRed,
|
||||||
unfocusedBorderColor = MatchColors.Outline,
|
unfocusedBorderColor = MatchColors.Outline,
|
||||||
focusedLabelColor = MatchColors.PrimaryRed,
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.matchlivetv.match_live_tv.R
|
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.isScheduledFuture
|
||||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
import com.matchlivetv.match_live_tv.domain.Match
|
||||||
@@ -139,11 +140,12 @@ fun ScheduleMatchBottomSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun pickDateTime() {
|
fun pickDateTime() {
|
||||||
|
val dialogContext = context.findActivity() ?: context
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
context,
|
dialogContext,
|
||||||
{ _, year, month, day ->
|
{ _, year, month, day ->
|
||||||
TimePickerDialog(
|
TimePickerDialog(
|
||||||
context,
|
dialogContext,
|
||||||
{ _, hour, minute ->
|
{ _, hour, minute ->
|
||||||
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
|
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-2
@@ -22,8 +22,15 @@ fun SplashScreen(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
container.bootstrapAuth()
|
container.bootstrapAuth()
|
||||||
val session = container.authRepository.validateOrRefresh()
|
val stored = container.authRepository.currentSession()
|
||||||
if (session != null) onAuthenticated() else onUnauthenticated()
|
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 {
|
MatchScreenScaffold {
|
||||||
|
|||||||
Reference in New Issue
Block a user