214 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			214 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
import SwiftUI
 | 
						|
 | 
						|
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 {
 | 
						|
                Section(header: Text(NSLocalizedString("Контакты", comment: ""))) {
 | 
						|
                    ForEach(contacts) { contact in
 | 
						|
                        ContactRow(contact: contact)
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
        .listStyle(.insetGrouped)
 | 
						|
        .background(Color(.systemGroupedBackground))
 | 
						|
        .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")))
 | 
						|
                )
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private var loadingState: some View {
 | 
						|
        Section {
 | 
						|
            ProgressView()
 | 
						|
                .frame(maxWidth: .infinity, alignment: .center)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private func errorState(_ message: String) -> some View {
 | 
						|
        Section {
 | 
						|
            Text(message)
 | 
						|
                .foregroundColor(.red)
 | 
						|
                .frame(maxWidth: .infinity, alignment: .center)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private var emptyState: some View {
 | 
						|
        Section {
 | 
						|
            VStack(spacing: 12) {
 | 
						|
                Image(systemName: "person.crop.circle.badge.questionmark")
 | 
						|
                    .font(.system(size: 48))
 | 
						|
                    .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, 24)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    @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(spacing: 12) {
 | 
						|
            Circle()
 | 
						|
                .fill(Color.accentColor.opacity(0.15))
 | 
						|
                .frame(width: 44, height: 44)
 | 
						|
                .overlay(
 | 
						|
                    Text(contact.initials)
 | 
						|
                        .font(.headline)
 | 
						|
                        .foregroundColor(.accentColor)
 | 
						|
                )
 | 
						|
 | 
						|
            VStack(alignment: .leading, spacing: 4) {
 | 
						|
                Text(contact.displayName)
 | 
						|
                    .font(.body)
 | 
						|
                if let handle = contact.handle {
 | 
						|
                    Text(handle)
 | 
						|
                        .font(.caption)
 | 
						|
                        .foregroundColor(.secondary)
 | 
						|
                }
 | 
						|
                HStack(spacing: 8) {
 | 
						|
                    if contact.friendCode {
 | 
						|
                        Label(NSLocalizedString("Код дружбы", comment: "Friend code badge"), systemImage: "heart.circle")
 | 
						|
                            .font(.caption2)
 | 
						|
                            .foregroundColor(.secondary)
 | 
						|
                    }
 | 
						|
                    Text(contact.formattedCreatedAt)
 | 
						|
                        .font(.caption2)
 | 
						|
                        .foregroundColor(.secondary)
 | 
						|
                }
 | 
						|
            }
 | 
						|
            Spacer()
 | 
						|
        }
 | 
						|
        .padding(.vertical, 4)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
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)
 | 
						|
 | 
						|
    var id: String {
 | 
						|
        switch self {
 | 
						|
        case .error(let id, _):
 | 
						|
            return id.uuidString
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |