diff --git a/yobble/Network/ContactsService.swift b/yobble/Network/ContactsService.swift new file mode 100644 index 0000000..edab789 --- /dev/null +++ b/yobble/Network/ContactsService.swift @@ -0,0 +1,173 @@ +import Foundation + +enum ContactsServiceError: LocalizedError { + case unexpectedStatus(String) + case decoding(debugDescription: String) + + var errorDescription: String? { + switch self { + case .unexpectedStatus(let message): + return message + case .decoding(let debugDescription): + return AppConfig.DEBUG + ? debugDescription + : NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service decoding error") + } + } +} + +struct ContactPayload: Decodable { + let userId: UUID + let login: String + let fullName: String? + let customName: String? + let friendCode: Bool + let createdAt: Date +} + +final class ContactsService { + private let client: NetworkClient + private let decoder: JSONDecoder + + init(client: NetworkClient = .shared) { + self.client = client + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) + } + + func fetchContacts(completion: @escaping (Result<[ContactPayload], Error>) -> Void) { + client.request( + path: "/v1/user/contact/list", + method: .get, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse<[ContactPayload]>.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status") + completion(.failure(ContactsServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ContactsService] decode contacts failed: \(debugMessage)") + } + completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(ContactsServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func fetchContacts() async throws -> [ContactPayload] { + try await withCheckedThrowingContinuation { continuation in + fetchContacts { result in + continuation.resume(with: result) + } + } + } + + private static func decodeDate(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = iso8601WithFractionalSeconds.date(from: string) { + return date + } + if let date = iso8601Simple.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Невозможно декодировать дату: \(string)" + ) + } + + private static func describeDecodingError(error: Error, data: Data) -> String { + var parts: [String] = [] + + if let decodingError = error as? DecodingError { + parts.append(decodingDescription(from: decodingError)) + } else { + parts.append(error.localizedDescription) + } + + if let payload = truncatedPayload(from: data) { + parts.append("payload=\(payload)") + } + + return parts.joined(separator: "\n") + } + + private static func decodingDescription(from error: DecodingError) -> String { + switch error { + case .typeMismatch(let type, let context): + return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .valueNotFound(let type, let context): + return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .keyNotFound(let key, let context): + return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)" + case .dataCorrupted(let context): + return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)" + @unknown default: + return error.localizedDescription + } + } + + private static func codingPath(from context: DecodingError.Context) -> String { + let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty } + return path.isEmpty ? "root" : path.joined(separator: ".") + } + + private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? { + guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty else { + return nil + } + + if string.count <= limit { + return string + } + + let index = string.index(string.startIndex, offsetBy: limit) + return String(string[string.startIndex.. String? { + if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + if let detail = apiError.detail, !detail.isEmpty { + return detail + } + if let message = apiError.data?.message, !message.isEmpty { + return message + } + } + if let string = String(data: data, encoding: .utf8), !string.isEmpty { + return string + } + return nil + } + + private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601Simple: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 1322eca..cdcdc46 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -389,6 +389,9 @@ "Добавление новых блокировок появится позже." : { "comment" : "Add blocked user placeholder message" }, + "Добавьте контакты, чтобы быстрее выходить на связь." : { + "comment" : "Contacts empty state subtitle" + }, "Другое" : { "localizations" : { "en" : { @@ -596,6 +599,12 @@ "Кликер в разработке" : { "comment" : "Concept tab placeholder title" }, + "Код дружбы" : { + "comment" : "Friend code badge" + }, + "Контактов пока нет" : { + "comment" : "Contacts empty state title" + }, "Контакты" : { "localizations" : { "en" : { @@ -919,6 +928,9 @@ }, "Не удалось загрузить историю чата." : { + }, + "Не удалось загрузить контакты." : { + "comment" : "Contacts service decoding error\nContacts service unexpected status" }, "Не удалось загрузить профиль." : { "comment" : "Profile service decoding error\nProfile unexpected status" @@ -1326,7 +1338,7 @@ } }, "Ошибка" : { - "comment" : "Common error title\nProfile update error title", + "comment" : "Common error title\nContacts load error title\nProfile update error title", "localizations" : { "en" : { "stringUnit" : { diff --git a/yobble/Views/Tab/ContactsTab.swift b/yobble/Views/Tab/ContactsTab.swift index df54a2d..99845e8 100644 --- a/yobble/Views/Tab/ContactsTab.swift +++ b/yobble/Views/Tab/ContactsTab.swift @@ -1,24 +1,213 @@ -// -// ContactsTab.swift -// yobble -// -// Created by cheykrym on 23.10.2025. -// - 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 { - VStack { - VStack { - Text("Здесь не будут чаты") - .font(.title) - .foregroundColor(.gray) - - Spacer() + 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) + } + } } } -// .background(Color(.secondarySystemBackground)) // Фон для всей вкладки + .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 + } } }