diff --git a/yobble/Network/ChatModels.swift b/yobble/Network/ChatModels.swift new file mode 100644 index 0000000..12650a7 --- /dev/null +++ b/yobble/Network/ChatModels.swift @@ -0,0 +1,228 @@ +import Foundation + +struct PrivateChatListData: Decodable { + let items: [PrivateChatListItem] + let hasMore: Bool +} + +struct PrivateChatListItem: Decodable, Identifiable { + enum ChatType: String, Decodable { + case `self` + case privateChat = "private" + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = ChatType(rawValue: rawValue) ?? .unknown + } + } + + let chatId: String + let chatType: ChatType + let chatData: ChatProfile? + let lastMessage: MessageItem? + let createdAt: Date? + let unreadCount: Int + + var id: String { chatId } +} + +struct MessageItem: Decodable, Identifiable { + let messageId: String + let messageType: String + let chatId: String + let senderId: String + let senderData: ChatProfile? + let content: String? + let mediaLink: String? + let isViewed: Bool? + let createdAt: Date? + let updatedAt: Date? + let forwardMetadata: ForwardMetadata? + + var id: String { messageId } + + private enum CodingKeys: String, CodingKey { + case messageId + case messageType + case chatId + case senderId + case senderData + case content + case mediaLink + case isViewed + case createdAt + case updatedAt + case forwardMetadata + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.messageId = try container.decodeFlexibleString(forKey: .messageId) + self.messageType = try container.decodeFlexibleStringOrArray(forKey: .messageType) + self.chatId = try container.decodeFlexibleString(forKey: .chatId) + self.senderId = try container.decodeFlexibleString(forKey: .senderId) + self.senderData = try container.decodeIfPresent(ChatProfile.self, forKey: .senderData) + self.content = try container.decodeIfPresent(String.self, forKey: .content) + self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink) + self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed) + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata) + } +} + +struct ForwardMetadata: Decodable { + let forwardType: String? + let forwardSenderId: String? + let forwardMessageId: String? + let forwardChatData: ChatProfile? + + private enum CodingKeys: String, CodingKey { + case forwardType + case forwardSenderId + case forwardMessageId + case forwardChatData + } +} + +struct ChatProfile: Decodable { + let userId: String + let login: String? + let fullName: String? + let customName: String? + let bio: String? + let lastSeen: Int? + let createdAt: Date? + let stories: [JSONValue] + let permissions: ChatPermissions? + let profilePermissions: ChatProfilePermissions? + let relationship: RelationshipStatus? + + private enum CodingKeys: String, CodingKey { + case userId + case login + case fullName + case customName + case bio + case lastSeen + case createdAt + case stories + case permissions + case profilePermissions + case relationship + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.userId = try container.decodeFlexibleString(forKey: .userId) + self.login = try container.decodeIfPresent(String.self, forKey: .login) + self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName) + self.customName = try container.decodeIfPresent(String.self, forKey: .customName) + self.bio = try container.decodeIfPresent(String.self, forKey: .bio) + self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen) + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? [] + self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions) + self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions) + self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship) + } +} + +struct ChatPermissions: Decodable { + let youCanSendMessage: Bool + let youCanPublicInvitePermission: Bool + let youCanGroupInvitePermission: Bool + let youCanCallPermission: Bool +} + +struct ChatProfilePermissions: Decodable { + let isSearchable: Bool? + let allowMessageForwarding: Bool + let allowMessagesFromNonContacts: Bool + let allowServerChats: Bool + let forceAutoDeleteMessagesInPrivate: Bool + let maxMessageAutoDeleteSeconds: Int? +} + +struct RelationshipStatus: Decodable { + let isCurrentUserInContactsOfTarget: Bool + let isTargetUserBlockedByCurrentUser: Bool + let isCurrentUserInBlacklistOfTarget: Bool +} + +enum JSONValue: Decodable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Int.self) { + self = .int(value) + return + } + if let value = try? container.decode(Double.self) { + self = .double(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([JSONValue].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + return + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Не удалось декодировать значение JSONValue") + } +} + +private extension KeyedDecodingContainer { + func decodeFlexibleString(forKey key: Key) throws -> String { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + throw DecodingError.typeMismatch(String.self, DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Expected to decode String or number for key \(key.stringValue)")) + } + + func decodeFlexibleStringOrArray(forKey key: Key) throws -> String { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let stringArray = try? decode([String].self, forKey: key), let first = stringArray.first { + return first + } + if let intArray = try? decode([Int].self, forKey: key), let first = intArray.first { + return String(first) + } + if let doubleArray = try? decode([Double].self, forKey: key), let first = doubleArray.first { + return String(first) + } + + throw DecodingError.typeMismatch(String.self, DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Expected to decode String or array for key \(key.stringValue)")) + } +} diff --git a/yobble/Network/ChatService.swift b/yobble/Network/ChatService.swift new file mode 100644 index 0000000..cd3e39b --- /dev/null +++ b/yobble/Network/ChatService.swift @@ -0,0 +1,145 @@ +import Foundation + +enum ChatServiceError: 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: "") + } + } +} + +final class ChatService { + 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 fetchPrivateChats( + offset: Int, + limit: Int, + completion: @escaping (Result) -> Void + ) { + let query: [String: String?] = [ + "offset": String(offset), + "limit": String(limit) + ] + + client.request( + path: "/v1/chat/private/list", + method: .get, + query: query, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список чатов.", comment: "") + completion(.failure(ChatServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ChatService] decode private chats failed: \(debugMessage)") + } + completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + 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.. Сменить пароль\" и следуйте инструкциям." : { "comment" : "FAQ answer: reset password" + }, + "Повторить" : { + }, "Поддержка" : { }, "Подтверждение пароля" : { "comment" : "Подтверждение пароля" + }, + "Пока что у вас нет чатов" : { + }, "Помощь" : { "comment" : "Help Center", @@ -474,6 +516,9 @@ }, "Сервер не отвечает. Попробуйте позже." : { + }, + "Сессия истекла. Войдите снова." : { + }, "Скан" : { "comment" : "Scan", @@ -491,6 +536,9 @@ }, "Сменить пароль" : { + }, + "Сообщение" : { + }, "Спасибо!" : { diff --git a/yobble/ViewModels/PrivateChatsViewModel.swift b/yobble/ViewModels/PrivateChatsViewModel.swift new file mode 100644 index 0000000..b8a8616 --- /dev/null +++ b/yobble/ViewModels/PrivateChatsViewModel.swift @@ -0,0 +1,91 @@ +import Foundation + +final class PrivateChatsViewModel: ObservableObject { + @Published private(set) var chats: [PrivateChatListItem] = [] + @Published private(set) var isInitialLoading: Bool = false + @Published private(set) var isLoadingMore: Bool = false + @Published var errorMessage: String? + + private let chatService: ChatService + private let pageSize: Int + private var offset: Int = 0 + private var hasMore: Bool = true + + init(chatService: ChatService = ChatService(), pageSize: Int = 20) { + self.chatService = chatService + self.pageSize = pageSize + } + + func loadInitialChats(force: Bool = false) { + guard !isInitialLoading else { return } + if !force && !chats.isEmpty { return } + + isInitialLoading = true + errorMessage = nil + let previousOffset = offset + let previousHasMore = hasMore + offset = 0 + hasMore = true + + chatService.fetchPrivateChats(offset: 0, limit: pageSize) { [weak self] result in + guard let self else { return } + self.isInitialLoading = false + + switch result { + case .success(let data): + self.chats = data.items + self.offset = data.items.count + self.hasMore = data.hasMore + case .failure(let error): + self.errorMessage = self.message(for: error) + self.offset = previousOffset + self.hasMore = previousHasMore + } + } + } + + func refresh() { + loadInitialChats(force: true) + } + + func loadMoreIfNeeded(currentItem item: PrivateChatListItem) { + guard hasMore, !isLoadingMore, item.id == chats.last?.id else { return } + + isLoadingMore = true + + chatService.fetchPrivateChats(offset: offset, limit: pageSize) { [weak self] result in + guard let self else { return } + self.isLoadingMore = false + + switch result { + case .success(let data): + self.chats.append(contentsOf: data.items) + self.offset = self.chats.count + self.hasMore = data.hasMore + case .failure(let error): + self.errorMessage = self.message(for: error) + } + } + } + + private func message(for error: Error) -> String { + if let chatError = error as? ChatServiceError { + return chatError.errorDescription ?? NSLocalizedString("Не удалось загрузить чаты.", comment: "") + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized: + return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "") + case .invalidURL, .noResponse: + return NSLocalizedString("Ошибка соединения с сервером.", comment: "") + case .network(let underlying): + return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), underlying.localizedDescription) + case .server(let statusCode, _): + return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: ""), "\(statusCode)") + } + } + + return NSLocalizedString("Неизвестная ошибка. Попробуйте позже.", comment: "") + } +} diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index 4b22288..112895a 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -8,13 +8,231 @@ import SwiftUI struct ChatsTab: View { + @StateObject private var viewModel = PrivateChatsViewModel() + var body: some View { - VStack { - Text("Здесь будут чаты") - .font(.title) - .foregroundColor(.gray) - - Spacer() + content + .background(Color(UIColor.systemBackground)) + .onAppear { + viewModel.loadInitialChats() + } + } + + @ViewBuilder + private var content: some View { + if viewModel.isInitialLoading && viewModel.chats.isEmpty { + loadingState + } else if let message = viewModel.errorMessage, viewModel.chats.isEmpty { + errorState(message: message) + } else if viewModel.chats.isEmpty { + emptyState + } else { + chatList } } + + private var chatList: some View { + List { + if let message = viewModel.errorMessage { + Section { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(message) + .font(.subheadline) + .foregroundColor(.orange) + Spacer(minLength: 0) + Button(action: { viewModel.refresh() }) { + Text(NSLocalizedString("Обновить", comment: "")) + .font(.subheadline) + } + } + .padding(.vertical, 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + + ForEach(viewModel.chats) { chat in + ChatRowView(chat: chat) + .contentShape(Rectangle()) + .onAppear { + viewModel.loadMoreIfNeeded(currentItem: chat) + } + } + + if viewModel.isLoadingMore { + loadingMoreRow + } + } + .listStyle(.plain) + } + + private var loadingState: some View { + VStack(spacing: 12) { + ProgressView() + Text(NSLocalizedString("Загружаем чаты…", comment: "")) + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.bubble") + .font(.system(size: 48)) + .foregroundColor(.orange) + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + Button(action: { viewModel.loadInitialChats(force: true) }) { + Text(NSLocalizedString("Повторить", comment: "")) + .font(.headline) + } + .buttonStyle(.borderedProminent) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text(NSLocalizedString("Пока что у вас нет чатов", comment: "")) + .font(.body) + .foregroundColor(.secondary) + Button(action: { viewModel.refresh() }) { + Text(NSLocalizedString("Обновить", comment: "")) + } + .buttonStyle(.bordered) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var loadingMoreRow: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 12) + Spacer() + } + .listRowSeparator(.hidden) + } +} + +private struct ChatRowView: View { + let chat: PrivateChatListItem + + private var title: String { + switch chat.chatType { + case .self: + return NSLocalizedString("Избранные сообщения", comment: "") + case .privateChat, .unknown: + if let custom = chat.chatData?.customName, !custom.isEmpty { + return custom + } + if let full = chat.chatData?.fullName, !full.isEmpty { + return full + } + if let login = chat.chatData?.login, !login.isEmpty { + return "@\(login)" + } + return NSLocalizedString("Неизвестный пользователь", comment: "") + } + } + + private var subtitle: String { + guard let message = chat.lastMessage else { + return NSLocalizedString("Нет сообщений", comment: "") + } + + if let content = message.content, !content.isEmpty { + return content + } + + if message.mediaLink != nil { + return NSLocalizedString("Вложение", comment: "") + } + + return NSLocalizedString("Сообщение", comment: "") + } + + private var timestamp: String? { + let date = chat.lastMessage?.createdAt ?? chat.createdAt + guard let date else { return nil } + return ChatRowView.timeFormatter.string(from: date) + } + + private var initial: String { + return String(title.prefix(1)).uppercased() + } + + private var subtitleColor: Color { + chat.unreadCount > 0 ? .primary : .secondary + } + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 44, height: 44) + .overlay( + Text(initial) + .font(.headline) + .foregroundColor(Color.accentColor) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .fontWeight(chat.unreadCount > 0 ? .semibold : .regular) + .foregroundColor(.primary) + .lineLimit(1) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(subtitleColor) + .lineLimit(2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 6) { + if let timestamp { + Text(timestamp) + .font(.caption) + .foregroundColor(.secondary) + } + + if chat.unreadCount > 0 { + Text("\(chat.unreadCount)") + .font(.caption2.bold()) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule().fill(Color.accentColor) + ) + } + } + } + .padding(.vertical, 8) + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() +} + +struct ChatsTab_Previews: PreviewProvider { + static var previews: some View { + ChatsTab() + .environmentObject(ThemeManager()) + } }