Aggiunge i18n IT/EN/FR/DE/ES, pagine pubbliche squadre e UX archivio.

Il selettore lingua funziona sul web e sulle app native; su Android la preferenza è persistita e applicata al riavvio. Incluse anche eliminazione replay a scope e campi pagina pubblica squadra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 19:30:44 +02:00
co-authored by Cursor
parent 7d5d66840f
commit 1d97271555
71 changed files with 3509 additions and 197 deletions
@@ -65,6 +65,7 @@
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */; };
C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */; };
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D09291641417F99E27D5A /* ApiInstant.swift */; };
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */; };
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */; };
D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */; };
DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */; };
@@ -112,6 +113,7 @@
29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
354D09291641417F99E27D5A /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = "<group>"; };
3691F2136AAD4545B655F544 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
395345091E364AFF9FFB34B9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
@@ -218,6 +220,7 @@
children = (
13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */,
354D09291641417F99E27D5A /* ApiInstant.swift */,
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */,
395345091E364AFF9FFB34B9 /* AppConfig.swift */,
68D5ED55DB884EA2AC986F61 /* ColorHex.swift */,
258DECC661314224B6A98F47 /* DeviceTelemetry.swift */,
@@ -393,6 +396,7 @@
files = (
42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */,
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */,
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */,
066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */,
557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */,
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */,
@@ -0,0 +1,181 @@
import Foundation
import SwiftUI
/// Preferenza lingua app (override del sistema). Persistita in UserDefaults.
enum AppLanguage: String, CaseIterable, Identifiable {
case system
case it
case en
case fr
case de
case es
var id: String { rawValue }
var nativeLabel: String {
switch self {
case .system: return L10n.t("language.system")
case .it: return "Italiano"
case .en: return "English"
case .fr: return "Français"
case .de: return "Deutsch"
case .es: return "Español"
}
}
static var current: AppLanguage {
get {
let raw = UserDefaults.standard.string(forKey: storageKey) ?? system.rawValue
return AppLanguage(rawValue: raw) ?? .system
}
set {
if newValue == .system {
UserDefaults.standard.removeObject(forKey: storageKey)
} else {
UserDefaults.standard.set(newValue.rawValue, forKey: storageKey)
}
// Forza refresh UI tramite notification
NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
}
}
/// Locale effettiva per le stringhe (risolve "system").
static var resolvedCode: String {
switch current {
case .system:
let code = Locale.current.language.languageCode?.identifier ?? "it"
let supported = ["it", "en", "fr", "de", "es"]
return supported.contains(code) ? code : "en"
default:
return current.rawValue
}
}
private static let storageKey = "mltv.appLanguage"
}
extension Notification.Name {
static let appLanguageDidChange = Notification.Name("mltv.appLanguageDidChange")
}
enum L10n {
static func t(_ key: String) -> String {
let code = AppLanguage.resolvedCode
if let value = table[code]?[key] { return value }
if let value = table["it"]?[key] { return value }
return key
}
private static let table: [String: [String: String]] = [
"it": [
"language.label": "Lingua",
"language.system": "Sistema",
"action.logout": "Esci",
"action.login": "Accedi",
"action.cancel": "Annulla",
"login.email": "Email",
"login.password": "Password",
"login.submit": "Accedi",
"login.error.credentials": "Email o password non corretti",
"login.error.unreachable": "Server non raggiungibile. Verifica la connessione.",
"login.error.generic": "Login fallito",
"matches.title": "Partite",
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
],
"en": [
"language.label": "Language",
"language.system": "System",
"action.logout": "Log out",
"action.login": "Log in",
"action.cancel": "Cancel",
"login.email": "Email",
"login.password": "Password",
"login.submit": "Log in",
"login.error.credentials": "Incorrect email or password",
"login.error.unreachable": "Server unreachable. Check your connection.",
"login.error.generic": "Login failed",
"matches.title": "Matches",
"app.slogan": "Every match, every event, for your fans.",
],
"fr": [
"language.label": "Langue",
"language.system": "Système",
"action.logout": "Déconnexion",
"action.login": "Connexion",
"action.cancel": "Annuler",
"login.email": "E-mail",
"login.password": "Mot de passe",
"login.submit": "Connexion",
"login.error.credentials": "E-mail ou mot de passe incorrect",
"login.error.unreachable": "Serveur inaccessible. Vérifiez la connexion.",
"login.error.generic": "Échec de la connexion",
"matches.title": "Matchs",
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
],
"de": [
"language.label": "Sprache",
"language.system": "System",
"action.logout": "Abmelden",
"action.login": "Anmelden",
"action.cancel": "Abbrechen",
"login.email": "E-Mail",
"login.password": "Passwort",
"login.submit": "Anmelden",
"login.error.credentials": "Falsche E-Mail oder Passwort",
"login.error.unreachable": "Server nicht erreichbar. Verbindung prüfen.",
"login.error.generic": "Anmeldung fehlgeschlagen",
"matches.title": "Spiele",
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
],
"es": [
"language.label": "Idioma",
"language.system": "Sistema",
"action.logout": "Salir",
"action.login": "Acceder",
"action.cancel": "Cancelar",
"login.email": "Email",
"login.password": "Contraseña",
"login.submit": "Acceder",
"login.error.credentials": "Email o contraseña incorrectos",
"login.error.unreachable": "Servidor no disponible. Comprueba la conexión.",
"login.error.generic": "Error de acceso",
"matches.title": "Partidos",
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
],
]
}
struct LanguagePickerView: View {
@Environment(\.dismiss) private var dismiss
@State private var selected = AppLanguage.current
var body: some View {
NavigationStack {
List {
ForEach(AppLanguage.allCases) { lang in
Button {
selected = lang
AppLanguage.current = lang
dismiss()
} label: {
HStack {
Text(lang.nativeLabel)
.foregroundStyle(MatchColors.textSecondary)
Spacer()
if selected == lang {
Image(systemName: "checkmark")
.foregroundStyle(MatchColors.primaryRed)
}
}
}
}
}
.navigationTitle(L10n.t("language.label"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("action.cancel")) { dismiss() }
}
}
}
}
}
@@ -9,6 +9,8 @@ struct LoginScreen: View {
@State private var error: String?
@State private var loading = false
@State private var passwordVisible = false
@State private var showLanguagePicker = false
@State private var languageTick = 0
var body: some View {
MatchScreenScaffold {
@@ -16,17 +18,17 @@ struct LoginScreen: View {
VStack(spacing: 0) {
MatchLiveWordmark(showSlogan: true)
.padding(.top, 32)
Text("ACCEDI")
Button(L10n.t("language.label")) {
showLanguagePicker = true
}
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 16)
Text(L10n.t("login.submit").uppercased())
.font(MatchTypography.headlineMedium)
.padding(.top, 48)
Text("Gestisci le dirette della tua squadra")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.top, 32)
VStack(spacing: 16) {
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
}
.padding(.top, 32)
if let error {
@@ -36,7 +38,7 @@ struct LoginScreen: View {
.padding(.top, 12)
}
MatchPrimaryButton(
label: "ACCEDI",
label: L10n.t("login.submit").uppercased(),
action: submitLogin,
enabled: !email.isEmpty && !password.isEmpty,
loading: loading
@@ -47,6 +49,13 @@ struct LoginScreen: View {
.padding(.bottom, 32)
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView()
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
}
private func submitLogin() {
@@ -59,12 +68,12 @@ struct LoginScreen: View {
onLoggedIn()
} catch {
if let apiError = error as? APIError, case .unauthorized = apiError {
self.error = "Email o password non corretti"
self.error = L10n.t("login.error.credentials")
} else if let message = UserFacingError.message(for: error),
message.localizedCaseInsensitiveContains("timeout") {
self.error = "Server non raggiungibile. Verifica la connessione."
self.error = L10n.t("login.error.unreachable")
} else {
self.error = UserFacingError.message(for: error) ?? "Accesso non riuscito"
self.error = UserFacingError.message(for: error) ?? L10n.t("login.error.generic")
}
}
loading = false
@@ -20,6 +20,8 @@ struct MatchesScreen: View {
@State private var resumeMatch: Match?
@State private var deleteMatch: Match?
@State private var snackbar: String?
@State private var showLanguagePicker = false
@State private var languageTick = 0
var body: some View {
MatchScreenScaffold(
@@ -27,7 +29,14 @@ struct MatchesScreen: View {
HStack {
MatchLiveWordmark(compact: true)
Spacer()
Button("Esci") {
Button {
showLanguagePicker = true
} label: {
Image(systemName: "globe")
.foregroundStyle(MatchColors.textSecondary)
}
.accessibilityLabel(L10n.t("language.label"))
Button(L10n.t("action.logout")) {
Task {
await container.authRepository.logout()
onLogout()
@@ -177,6 +186,13 @@ struct MatchesScreen: View {
showTeamPicker = false
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView()
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
.overlay {
if actionLoading {
Color.black.opacity(0.35)