Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio. Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
6.3 KiB
Swift
166 lines
6.3 KiB
Swift
import SwiftUI
|
|
|
|
struct LoginScreen: View {
|
|
@ObservedObject var container: AppContainer
|
|
let onLoggedIn: () -> Void
|
|
let onForgotPassword: () -> Void
|
|
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@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
|
|
@State private var announcements: [AnnouncementDto] = []
|
|
|
|
var body: some View {
|
|
MatchScreenScaffold {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
showLanguagePicker = true
|
|
} label: {
|
|
Image(systemName: "globe")
|
|
.foregroundStyle(MatchColors.textSecondary)
|
|
}
|
|
.accessibilityLabel(L10n.t("language.label"))
|
|
}
|
|
.padding(.top, 8)
|
|
MatchLiveWordmark(showSlogan: true)
|
|
.padding(.top, 16)
|
|
if !announcements.isEmpty {
|
|
AnnouncementBanners(
|
|
announcements: announcements,
|
|
onDismiss: { id in
|
|
container.announcementStore.dismiss(id)
|
|
announcements.removeAll { $0.id == id }
|
|
}
|
|
)
|
|
.padding(.top, 24)
|
|
}
|
|
Text(L10n.t("login.submit").uppercased())
|
|
.font(MatchTypography.headlineMedium)
|
|
.padding(.top, 32)
|
|
VStack(spacing: 16) {
|
|
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: L10n.t("login.email.placeholder"), keyboard: .emailAddress)
|
|
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
|
}
|
|
.padding(.top, 32)
|
|
HStack {
|
|
Spacer()
|
|
Button(action: onForgotPassword) {
|
|
Text(L10n.t("login.forgot.password"))
|
|
.font(MatchTypography.bodyMedium)
|
|
.foregroundStyle(MatchColors.textSecondary)
|
|
.underline()
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
if let error {
|
|
Text(error)
|
|
.foregroundStyle(MatchColors.primaryRed)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, 12)
|
|
}
|
|
MatchPrimaryButton(
|
|
label: L10n.t("login.submit").uppercased(),
|
|
action: submitLogin,
|
|
enabled: !email.isEmpty && !password.isEmpty,
|
|
loading: loading
|
|
)
|
|
.padding(.top, 32)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 32)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showLanguagePicker) {
|
|
LanguagePickerView()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
|
languageTick += 1
|
|
}
|
|
.id(languageTick)
|
|
.task {
|
|
announcements = await container.visibleAnnouncements()
|
|
}
|
|
}
|
|
|
|
private func submitLogin() {
|
|
guard !loading, !email.isEmpty, !password.isEmpty else { return }
|
|
loading = true
|
|
error = nil
|
|
Task {
|
|
do {
|
|
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
|
|
onLoggedIn()
|
|
} catch {
|
|
if let apiError = error as? APIError, case .unauthorized = apiError {
|
|
self.error = L10n.t("login.error.credentials")
|
|
} else if let message = UserFacingError.message(for: error),
|
|
message.localizedCaseInsensitiveContains("timeout") {
|
|
self.error = L10n.t("login.error.unreachable")
|
|
} else {
|
|
self.error = UserFacingError.message(for: error) ?? L10n.t("login.error.generic")
|
|
}
|
|
}
|
|
loading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MatchTextField: View {
|
|
let title: String
|
|
@Binding var text: String
|
|
var placeholder: String = ""
|
|
var keyboard: UIKeyboardType = .default
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(MatchTypography.bodyMedium)
|
|
.foregroundStyle(MatchColors.textSecondary)
|
|
TextField(placeholder, text: $text)
|
|
.textInputAutocapitalization(.never)
|
|
.keyboardType(keyboard)
|
|
.autocorrectionDisabled()
|
|
.padding(12)
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MatchSecureField: View {
|
|
let title: String
|
|
@Binding var text: String
|
|
@Binding var visible: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(MatchTypography.bodyMedium)
|
|
.foregroundStyle(MatchColors.textSecondary)
|
|
HStack {
|
|
Group {
|
|
if visible {
|
|
TextField("", text: $text)
|
|
} else {
|
|
SecureField("", text: $text)
|
|
}
|
|
}
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
Button(action: { visible.toggle() }) {
|
|
Image(systemName: visible ? "eye.slash" : "eye")
|
|
.foregroundStyle(MatchColors.textSecondary)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
|
}
|
|
}
|
|
}
|