diff --git a/yobble/Network/BlockedUsersService.swift b/yobble/Network/BlockedUsersService.swift new file mode 100644 index 0000000..5e11cea --- /dev/null +++ b/yobble/Network/BlockedUsersService.swift @@ -0,0 +1,172 @@ +import Foundation + +enum BlockedUsersServiceError: 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: "Blocked users service decoding error") + } + } +} + +struct BlockedUserPayload: Decodable { + let userId: UUID + let login: String + let fullName: String? + let customName: String? + let createdAt: Date +} + +final class BlockedUsersService { + 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 fetchBlockedUsers(completion: @escaping (Result<[BlockedUserPayload], Error>) -> Void) { + client.request( + path: "/v1/user/blacklist/list", + method: .get, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse<[BlockedUserPayload]>.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service unexpected status") + completion(.failure(BlockedUsersServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[BlockedUsersService] decode blocked users failed: \(debugMessage)") + } + completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(BlockedUsersServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func fetchBlockedUsers() async throws -> [BlockedUserPayload] { + try await withCheckedThrowingContinuation { continuation in + fetchBlockedUsers { 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 38083a6..5cb4ac2 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -930,6 +930,9 @@ } } }, + "Не удалось загрузить список." : { + "comment" : "Blocked users service decoding error\nBlocked users service unexpected status" + }, "Не удалось загрузить текст правил." : { }, diff --git a/yobble/Views/Tab/Settings/BlockedUsersView.swift b/yobble/Views/Tab/Settings/BlockedUsersView.swift index 52c294e..fdb2519 100644 --- a/yobble/Views/Tab/Settings/BlockedUsersView.swift +++ b/yobble/Views/Tab/Settings/BlockedUsersView.swift @@ -2,10 +2,18 @@ import SwiftUI struct BlockedUsersView: View { @State private var blockedUsers: [BlockedUser] = [] + @State private var isLoading = false + @State private var loadError: String? + + private let blockedUsersService = BlockedUsersService() var body: some View { List { - if blockedUsers.isEmpty { + if isLoading && blockedUsers.isEmpty { + loadingState + } else if let loadError, blockedUsers.isEmpty { + errorState(loadError) + } else if blockedUsers.isEmpty { emptyState } else { Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) { @@ -46,6 +54,9 @@ struct BlockedUsersView: View { .task { await loadBlockedUsers() } + .refreshable { + await loadBlockedUsers() + } } private var emptyState: some View { @@ -67,8 +78,41 @@ struct BlockedUsersView: View { .listRowSeparator(.hidden) } + 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) + } + } + + @MainActor private func loadBlockedUsers() async { - // TODO: integrate with real data source once available + if isLoading { + return + } + + isLoading = true + loadError = nil + + do { + let payloads = try await blockedUsersService.fetchBlockedUsers() + blockedUsers = payloads.map(BlockedUser.init) + } catch { + loadError = error.localizedDescription + if AppConfig.DEBUG { + print("[BlockedUsersView] load blocked users failed: \(error)") + } + } + + isLoading = false } private func unblock(_ user: BlockedUser) { @@ -79,8 +123,13 @@ struct BlockedUsersView: View { private struct BlockedUser: Identifiable, Equatable { let id: UUID - let displayName: String - let handle: String? + let login: String + let fullName: String? + let customName: String? + let createdAt: Date + + private(set) var displayName: String + private(set) var handle: String? var initials: String { let components = displayName.split(separator: " ") @@ -100,4 +149,26 @@ private struct BlockedUser: Identifiable, Equatable { return "??" } + + init(payload: BlockedUserPayload) { + self.id = payload.userId + self.login = payload.login + self.fullName = payload.fullName + self.customName = payload.customName + 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 + } + } }