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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user