325 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			325 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
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 {
 | 
						|
                        showContactPlaceholder(for: contact)
 | 
						|
                    } label: {
 | 
						|
                        ContactRow(contact: contact)
 | 
						|
                            .contentShape(Rectangle())
 | 
						|
                    }
 | 
						|
                    .buttonStyle(.plain)
 | 
						|
                    .contextMenu {
 | 
						|
                        Button {
 | 
						|
                            handleContactAction(.edit, for: contact)
 | 
						|
                        } label: {
 | 
						|
                            Label(
 | 
						|
                                NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
 | 
						|
                                systemImage: "square.and.pencil"
 | 
						|
                            )
 | 
						|
                        }
 | 
						|
 | 
						|
                        Button {
 | 
						|
                            handleContactAction(.block, for: contact)
 | 
						|
                        } label: {
 | 
						|
                            Label(
 | 
						|
                                NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
 | 
						|
                                systemImage: "hand.raised.fill"
 | 
						|
                            )
 | 
						|
                        }
 | 
						|
 | 
						|
                        Button(role: .destructive) {
 | 
						|
                            handleContactAction(.delete, for: contact)
 | 
						|
                        } label: {
 | 
						|
                            Label(
 | 
						|
                                NSLocalizedString("Удалить контакт", comment: "Contacts context action delete"),
 | 
						|
                                systemImage: "trash"
 | 
						|
                            )
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    .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 func showContactPlaceholder(for contact: Contact) {
 | 
						|
        activeAlert = .info(
 | 
						|
            title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
 | 
						|
            message: String(
 | 
						|
                format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"),
 | 
						|
                contact.displayName
 | 
						|
            )
 | 
						|
        )
 | 
						|
    }
 | 
						|
 | 
						|
    private func handleContactAction(_ action: ContactAction, for contact: Contact) {
 | 
						|
        activeAlert = .info(
 | 
						|
            title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
 | 
						|
            message: action.placeholderMessage(for: contact)
 | 
						|
        )
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
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
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
private enum ContactAction {
 | 
						|
    case edit
 | 
						|
    case block
 | 
						|
    case delete
 | 
						|
 | 
						|
    func placeholderMessage(for contact: Contact) -> String {
 | 
						|
        switch self {
 | 
						|
        case .edit:
 | 
						|
            return String(
 | 
						|
                format: NSLocalizedString("Изменение контакта \"%1$@\" появится позже.", comment: "Contacts edit placeholder message"),
 | 
						|
                contact.displayName
 | 
						|
            )
 | 
						|
        case .block:
 | 
						|
            return String(
 | 
						|
                format: NSLocalizedString("Блокировка контакта \"%1$@\" появится позже.", comment: "Contacts block placeholder message"),
 | 
						|
                contact.displayName
 | 
						|
            )
 | 
						|
        case .delete:
 | 
						|
            return String(
 | 
						|
                format: NSLocalizedString("Удаление контакта \"%1$@\" появится позже.", comment: "Contacts delete placeholder message"),
 | 
						|
                contact.displayName
 | 
						|
            )
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |