add chat list
This commit is contained in:
parent
21dd8f01af
commit
bc3ec97d67
228
yobble/Network/ChatModels.swift
Normal file
228
yobble/Network/ChatModels.swift
Normal file
@ -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)"))
|
||||
}
|
||||
}
|
||||
145
yobble/Network/ChatService.swift
Normal file
145
yobble/Network/ChatService.swift
Normal file
@ -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<PrivateChatListData, Error>) -> 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<PrivateChatListData>.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..<index]) + "…"
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
@ -10,6 +10,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld" : {
|
||||
|
||||
},
|
||||
"🌍" : {
|
||||
"localizations" : {
|
||||
@ -82,6 +85,9 @@
|
||||
},
|
||||
"Ваше предложение" : {
|
||||
|
||||
},
|
||||
"Вложение" : {
|
||||
|
||||
},
|
||||
"Войти" : {
|
||||
"localizations" : {
|
||||
@ -142,6 +148,9 @@
|
||||
},
|
||||
"Заглушка: Хранилище данных" : {
|
||||
|
||||
},
|
||||
"Загружаем чаты…" : {
|
||||
|
||||
},
|
||||
"Загрузка..." : {
|
||||
"localizations" : {
|
||||
@ -185,15 +194,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Здесь будут чаты" : {
|
||||
|
||||
},
|
||||
"Здесь не будут чаты" : {
|
||||
|
||||
},
|
||||
"Идеи" : {
|
||||
|
||||
},
|
||||
"Избранные сообщения" : {
|
||||
|
||||
},
|
||||
"Инвайт-код (необязательно)" : {
|
||||
"comment" : "Инвайт-код",
|
||||
@ -334,6 +343,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Не удалось загрузить список чатов." : {
|
||||
|
||||
},
|
||||
"Не удалось загрузить чаты." : {
|
||||
|
||||
},
|
||||
"Не удалось обработать данные чатов." : {
|
||||
|
||||
},
|
||||
"Не удалось обработать ответ сервера." : {
|
||||
|
||||
@ -361,15 +379,27 @@
|
||||
},
|
||||
"Неизвестная ошибка." : {
|
||||
|
||||
},
|
||||
"Неизвестная ошибка. Попробуйте позже." : {
|
||||
|
||||
},
|
||||
"Неизвестный пользователь" : {
|
||||
|
||||
},
|
||||
"Некорректный ответ от сервера." : {
|
||||
|
||||
},
|
||||
"Нет аккаунта? Регистрация" : {
|
||||
"comment" : "Регистрация"
|
||||
},
|
||||
"Нет сообщений" : {
|
||||
|
||||
},
|
||||
"О приложении" : {
|
||||
|
||||
},
|
||||
"Обновить" : {
|
||||
|
||||
},
|
||||
"Обратная связь" : {
|
||||
|
||||
@ -394,12 +424,18 @@
|
||||
},
|
||||
"Ошибка регистрация" : {
|
||||
"comment" : "Ошибка"
|
||||
},
|
||||
"Ошибка сервера (%@)." : {
|
||||
|
||||
},
|
||||
"Ошибка сервера: %@" : {
|
||||
|
||||
},
|
||||
"Ошибка сети: %@" : {
|
||||
|
||||
},
|
||||
"Ошибка соединения с сервером." : {
|
||||
|
||||
},
|
||||
"Пароли не совпадают" : {
|
||||
"comment" : "Пароли не совпадают"
|
||||
@ -412,12 +448,18 @@
|
||||
},
|
||||
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
||||
"comment" : "FAQ answer: reset password"
|
||||
},
|
||||
"Повторить" : {
|
||||
|
||||
},
|
||||
"Поддержка" : {
|
||||
|
||||
},
|
||||
"Подтверждение пароля" : {
|
||||
"comment" : "Подтверждение пароля"
|
||||
},
|
||||
"Пока что у вас нет чатов" : {
|
||||
|
||||
},
|
||||
"Помощь" : {
|
||||
"comment" : "Help Center",
|
||||
@ -474,6 +516,9 @@
|
||||
},
|
||||
"Сервер не отвечает. Попробуйте позже." : {
|
||||
|
||||
},
|
||||
"Сессия истекла. Войдите снова." : {
|
||||
|
||||
},
|
||||
"Скан" : {
|
||||
"comment" : "Scan",
|
||||
@ -491,6 +536,9 @@
|
||||
},
|
||||
"Сменить пароль" : {
|
||||
|
||||
},
|
||||
"Сообщение" : {
|
||||
|
||||
},
|
||||
"Спасибо!" : {
|
||||
|
||||
|
||||
91
yobble/ViewModels/PrivateChatsViewModel.swift
Normal file
91
yobble/ViewModels/PrivateChatsViewModel.swift
Normal file
@ -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: "")
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,231 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatsTab: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Здесь будут чаты")
|
||||
.font(.title)
|
||||
.foregroundColor(.gray)
|
||||
@StateObject private var viewModel = PrivateChatsViewModel()
|
||||
|
||||
Spacer()
|
||||
var body: some View {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user