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
@@ -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)