From 26b8dfc71adf52c6c6c22beb6b04c60b26c21311 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Tue, 7 Oct 2025 05:24:35 +0300 Subject: [PATCH] connect to search --- yobble/Network/SearchModels.swift | 54 ++++++++ yobble/Network/SearchService.swift | 171 +++++++++++++++++++++++++ yobble/Resources/Localizable.xcstrings | 22 +++- yobble/Views/Tab/ChatsTab.swift | 170 +++++++++++++++++++++++- 4 files changed, 409 insertions(+), 8 deletions(-) create mode 100644 yobble/Network/SearchModels.swift create mode 100644 yobble/Network/SearchService.swift diff --git a/yobble/Network/SearchModels.swift b/yobble/Network/SearchModels.swift new file mode 100644 index 0000000..c6876af --- /dev/null +++ b/yobble/Network/SearchModels.swift @@ -0,0 +1,54 @@ +import Foundation + +struct SearchDataPayload: Decodable { + let users: [UserSearchResult] +} + +struct UserSearchResult: Decodable, Identifiable { + let userId: UUID + let login: String + let fullName: String? + let customName: String? + let createdAt: Date? + let profile: SearchProfile? + + var id: UUID { userId } +} + +struct SearchProfile: Decodable { + let userId: UUID + let login: String? + let fullName: String? + let customName: String? + let bio: String? + let lastSeen: Int? + let createdAt: Date? +} + +extension UserSearchResult { + var displayName: String { + if let customName = customName, !customName.isEmpty { + return customName + } + if let fullName = fullName, !fullName.isEmpty { + return fullName + } + if let profileName = profile?.customName, !profileName.isEmpty { + return profileName + } + if let profileFullName = profile?.fullName, !profileFullName.isEmpty { + return profileFullName + } + return "@\(login)" + } + + var secondaryText: String? { + if let fullName = fullName, !fullName.isEmpty, fullName != displayName { + return fullName + } + if let profileLogin = profile?.login, !profileLogin.isEmpty, profileLogin != login { + return "@\(profileLogin)" + } + return nil + } +} diff --git a/yobble/Network/SearchService.swift b/yobble/Network/SearchService.swift new file mode 100644 index 0000000..2032651 --- /dev/null +++ b/yobble/Network/SearchService.swift @@ -0,0 +1,171 @@ +import Foundation + +enum SearchServiceError: 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: "Search service decoding error") + } + } +} + +final class SearchService { + 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 search(query: String, completion: @escaping (Result) -> Void) { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { + completion(.success(SearchDataPayload(users: []))) + return + } + + client.request( + path: "/v1/feed/search", + method: .get, + query: ["query": trimmedQuery], + 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: "Search unexpected status") + completion(.failure(SearchServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[SearchService] decode search response failed: \(debugMessage)") + } + completion(.failure(SearchServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(SearchServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func search(query: String) async throws -> SearchDataPayload { + try await withCheckedThrowingContinuation { continuation in + search(query: query) { 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 + } +} diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 5c039fc..8a42c7b 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -384,6 +384,9 @@ } } }, + "Ищем пользователей…" : { + "comment" : "Global search loading" + }, "Как сбросить пароль?" : { "comment" : "FAQ question: reset password" }, @@ -539,6 +542,9 @@ } } }, + "Не удалось выполнить поиск." : { + "comment" : "Search error fallback\nSearch service decoding error" + }, "Не удалось загрузить список чатов." : { "localizations" : { "en" : { @@ -559,6 +565,9 @@ } } }, + "Не удалось найти результаты." : { + "comment" : "Search unexpected status" + }, "Не удалось обновить пароль." : { "localizations" : { "en" : { @@ -736,7 +745,7 @@ } }, "Ничего не найдено" : { - "comment" : "Global search placeholder" + "comment" : "Global search empty state" }, "Новый пароль" : { "comment" : "Новый пароль", @@ -853,6 +862,7 @@ } }, "Ошибка соединения с сервером." : { + "comment" : "Search error connection", "localizations" : { "en" : { "stringUnit" : { @@ -966,6 +976,9 @@ }, "Поиск" : { + }, + "Поиск отменён." : { + "comment" : "Search cancelled" }, "Пока что у вас нет чатов" : { "localizations" : { @@ -1056,6 +1069,9 @@ } } }, + "Произошла неизвестная ошибка." : { + "comment" : "Search unknown error" + }, "Произошла ошибка." : { "localizations" : { "en" : { @@ -1143,6 +1159,9 @@ "Связаться с разработчиками" : { "comment" : "FAQ: contact developers link" }, + "Сервер вернул ошибку (%d)." : { + "comment" : "Search error server status" + }, "Сервер не отвечает. Попробуйте позже." : { "localizations" : { "en" : { @@ -1154,6 +1173,7 @@ } }, "Сессия истекла. Войдите снова." : { + "comment" : "Search error unauthorized", "localizations" : { "en" : { "stringUnit" : { diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index 31d2b74..9ec4fae 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -14,10 +14,15 @@ struct ChatsTab: View { var currentUserId: String? @Binding var searchRevealProgress: CGFloat @Binding var searchText: String + private let searchService = SearchService() @StateObject private var viewModel = PrivateChatsViewModel() @State private var selectedChatId: String? @State private var searchDragStartProgress: CGFloat = 0 @State private var isSearchGestureActive: Bool = false + @State private var globalSearchResults: [UserSearchResult] = [] + @State private var isGlobalSearchLoading: Bool = false + @State private var globalSearchError: String? + @State private var globalSearchTask: Task? = nil private let searchRevealDistance: CGFloat = 90 @@ -32,10 +37,18 @@ struct ChatsTab: View { .background(Color(UIColor.systemBackground)) .onAppear { viewModel.loadInitialChats() + handleSearchQueryChange(searchText) } .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in viewModel.refresh() } + .onChange(of: searchText) { newValue in + handleSearchQueryChange(newValue) + } + .onDisappear { + globalSearchTask?.cancel() + globalSearchTask = nil + } } @ViewBuilder @@ -93,13 +106,7 @@ struct ChatsTab: View { } Section(header: globalSearchHeader) { - Text(NSLocalizedString("Ничего не найдено", comment: "Global search placeholder")) - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .leading) - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .listRowSeparator(.hidden) + globalSearchContent } } else { ForEach(viewModel.chats) { chat in @@ -196,6 +203,21 @@ struct ChatsTab: View { Text(NSLocalizedString("Глобальный поиск", comment: "Global search section")) } + @ViewBuilder + private var globalSearchContent: some View { + if isGlobalSearchLoading { + globalSearchLoadingRow + } else if let error = globalSearchError { + globalSearchErrorRow(message: error) + } else if globalSearchResults.isEmpty { + globalSearchEmptyRow + } else { + ForEach(globalSearchResults) { user in + globalSearchRow(for: user) + } + } + } + private var emptySearchResultView: some View { VStack(spacing: 8) { Image(systemName: "text.magnifyingglass") @@ -304,6 +326,59 @@ struct ChatsTab: View { viewModel.loadMoreIfNeeded(currentItem: chat) } } + + private var globalSearchLoadingRow: some View { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text(NSLocalizedString("Ищем пользователей…", comment: "Global search loading")) + .font(.footnote) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowSeparator(.hidden) + } + + private func globalSearchErrorRow(message: String) -> some View { + Text(message) + .font(.footnote) + .foregroundColor(.orange) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowSeparator(.hidden) + } + + private var globalSearchEmptyRow: some View { + Text(NSLocalizedString("Ничего не найдено", comment: "Global search empty state")) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowSeparator(.hidden) + } + + private func globalSearchRow(for user: UserSearchResult) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(user.displayName) + .font(.headline) + + if user.displayName != "@\(user.login)" { + Text("@\(user.login)") + .font(.subheadline) + .foregroundColor(.secondary) + } else if let secondary = user.secondaryText { + Text(secondary) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } } private extension ChatsTab { @@ -312,6 +387,87 @@ private extension ChatsTab { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) #endif } + + func handleSearchQueryChange(_ query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + resetGlobalSearch() + return + } + + globalSearchTask?.cancel() + globalSearchTask = nil + + guard trimmed.count >= 2 else { + globalSearchResults = [] + globalSearchError = nil + isGlobalSearchLoading = false + return + } + + isGlobalSearchLoading = true + globalSearchError = nil + + globalSearchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + + do { + let data = try await searchService.search(query: trimmed) + guard !Task.isCancelled else { return } + await MainActor.run { + globalSearchResults = data.users + isGlobalSearchLoading = false + globalSearchError = nil + globalSearchTask = nil + } + } catch is CancellationError { + // Ignore cancellation + } catch { + guard !Task.isCancelled else { return } + await MainActor.run { + globalSearchResults = [] + isGlobalSearchLoading = false + globalSearchError = friendlyErrorMessage(for: error) + globalSearchTask = nil + } + } + } + } + + func resetGlobalSearch() { + globalSearchTask?.cancel() + globalSearchTask = nil + globalSearchResults = [] + globalSearchError = nil + isGlobalSearchLoading = false + } + + func friendlyErrorMessage(for error: Error) -> String { + if let searchError = error as? SearchServiceError { + return searchError.errorDescription ?? NSLocalizedString("Не удалось выполнить поиск.", comment: "Search error fallback") + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized: + return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Search error unauthorized") + case .invalidURL, .noResponse: + return NSLocalizedString("Ошибка соединения с сервером.", comment: "Search error connection") + case .network(let underlying): + return underlying.localizedDescription + case .server(let statusCode, _): + return String(format: NSLocalizedString("Сервер вернул ошибку (%d).", comment: "Search error server status"), statusCode) + } + } + + if (error as NSError).code == NSURLErrorCancelled { // доп. подстраховка + return NSLocalizedString("Поиск отменён.", comment: "Search cancelled") + } + + return NSLocalizedString("Произошла неизвестная ошибка.", comment: "Search unknown error") + } } private struct ScrollDismissesKeyboardModifier: ViewModifier {