import SwiftUI import Foundation struct ContactsTab: View { @State private var contacts: [Contact] = [] @State private var isLoading = false @State private var loadError: String? @State private var activeAlert: ContactsAlert? private let contactsService = ContactsService() var body: some View { List { if isLoading && contacts.isEmpty { loadingState } else if let loadError, contacts.isEmpty { errorState(loadError) } else if contacts.isEmpty { emptyState } else { ForEach(contacts) { contact in Button { activeAlert = .info( title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"), message: String(format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"), contact.displayName) ) } label: { ContactRow(contact: contact) .contentShape(Rectangle()) } .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) } } } .background(Color(UIColor.systemBackground)) .listStyle(.plain) .task { await loadContacts() } .refreshable { await loadContacts() } .alert(item: $activeAlert) { alert in switch alert { case .error(_, let message): return Alert( title: Text(NSLocalizedString("Ошибка", comment: "Contacts load error title")), message: Text(message), dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK"))) ) case .info(_, let title, let message): return Alert( title: Text(title), message: Text(message), dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK"))) ) } } } private var loadingState: some View { HStack { Spacer() ProgressView() .progressViewStyle(CircularProgressViewStyle()) Spacer() } .padding(.vertical, 18) .listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12)) .listRowSeparator(.hidden) } private func errorState(_ message: String) -> some View { HStack(alignment: .center, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(message) .font(.subheadline) .foregroundColor(.orange) Spacer() Button(action: { Task { await loadContacts() } }) { Text(NSLocalizedString("Обновить", comment: "Contacts retry button")) .font(.subheadline) } } .padding(.vertical, 10) .listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) .listRowSeparator(.hidden) } private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "person.crop.circle.badge.questionmark") .font(.system(size: 52)) .foregroundColor(.secondary) Text(NSLocalizedString("Контактов пока нет", comment: "Contacts empty state title")) .font(.headline) .multilineTextAlignment(.center) Text(NSLocalizedString("Добавьте контакты, чтобы быстрее выходить на связь.", comment: "Contacts empty state subtitle")) .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 28) .listRowInsets(EdgeInsets(top: 20, leading: 12, bottom: 20, trailing: 12)) .listRowSeparator(.hidden) } @MainActor private func loadContacts() async { if isLoading { return } isLoading = true loadError = nil do { let payloads = try await contactsService.fetchContacts() contacts = payloads.map(Contact.init) } catch { loadError = error.localizedDescription activeAlert = .error(message: error.localizedDescription) if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") } } isLoading = false } } private struct ContactRow: View { let contact: Contact var body: some View { HStack(alignment: .top, spacing: 10) { Circle() .fill(Color.accentColor.opacity(0.15)) .frame(width: 40, height: 40) .overlay( Text(contact.initials) .font(.headline) .foregroundColor(.accentColor) ) VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { Text(contact.displayName) .font(.subheadline.weight(.semibold)) .foregroundColor(.primary) Spacer() Text(contact.formattedCreatedAt) .font(.caption2) .foregroundColor(.secondary) } if let handle = contact.handle { Text(handle) .font(.caption2) .foregroundColor(.secondary) } if contact.friendCode { friendCodeBadge } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 6) } private var friendCodeBadge: some View { Text(NSLocalizedString("Код дружбы", comment: "Friend code badge")) .font(.caption2.weight(.medium)) .foregroundColor(Color.accentColor) .padding(.horizontal, 6) .padding(.vertical, 3) .background(Color.accentColor.opacity(0.12)) .clipShape(Capsule()) } } private struct Contact: Identifiable, Equatable { let id: UUID let login: String let fullName: String? let customName: String? let friendCode: Bool let createdAt: Date let displayName: String let handle: String? var initials: String { let components = displayName.split(separator: " ") let nameInitials = components.prefix(2).compactMap { $0.first } if !nameInitials.isEmpty { return nameInitials .map { String($0).uppercased() } .joined() } let filtered = login.filter { $0.isLetter }.prefix(2) if !filtered.isEmpty { return filtered.uppercased() } return "??" } var formattedCreatedAt: String { Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date()) } init(payload: ContactPayload) { self.id = payload.userId self.login = payload.login self.fullName = payload.fullName self.customName = payload.customName self.friendCode = payload.friendCode self.createdAt = payload.createdAt if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.displayName = customName } else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.displayName = fullName } else { self.displayName = payload.login } if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.handle = "@\(payload.login)" } else { self.handle = nil } } private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short return formatter }() } private enum ContactsAlert: Identifiable { case error(id: UUID = UUID(), message: String) case info(id: UUID = UUID(), title: String, message: String) var id: String { switch self { case .error(let id, _), .info(let id, _, _): return id.uuidString } } }