diff --git a/yobble/Network/ContactsService.swift b/yobble/Network/ContactsService.swift index edab789..aa7d6f1 100644 --- a/yobble/Network/ContactsService.swift +++ b/yobble/Network/ContactsService.swift @@ -25,6 +25,11 @@ struct ContactPayload: Decodable { let createdAt: Date } +struct ContactsListPayload: Decodable { + let items: [ContactPayload] + let hasMore: Bool +} + final class ContactsService { private let client: NetworkClient private let decoder: JSONDecoder @@ -36,16 +41,20 @@ final class ContactsService { self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) } - func fetchContacts(completion: @escaping (Result<[ContactPayload], Error>) -> Void) { + func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result) -> Void) { client.request( path: "/v1/user/contact/list", method: .get, + query: [ + "limit": String(limit), + "offset": String(offset) + ], requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { - let apiResponse = try decoder.decode(APIResponse<[ContactPayload]>.self, from: response.data) + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status") completion(.failure(ContactsServiceError.unexpectedStatus(message))) @@ -71,9 +80,9 @@ final class ContactsService { } } - func fetchContacts() async throws -> [ContactPayload] { + func fetchContacts(limit: Int, offset: Int) async throws -> ContactsListPayload { try await withCheckedThrowingContinuation { continuation in - fetchContacts { result in + fetchContacts(limit: limit, offset: offset) { result in continuation.resume(with: result) } } diff --git a/yobble/Views/Tab/ContactsTab.swift b/yobble/Views/Tab/ContactsTab.swift index 41bc6e3..3ae993e 100644 --- a/yobble/Views/Tab/ContactsTab.swift +++ b/yobble/Views/Tab/ContactsTab.swift @@ -5,9 +5,13 @@ struct ContactsTab: View { @State private var contacts: [Contact] = [] @State private var isLoading = false @State private var loadError: String? + @State private var pagingError: String? @State private var activeAlert: ContactsAlert? + @State private var hasMore = true + @State private var offset = 0 private let contactsService = ContactsService() + private let pageSize = 25 var body: some View { List { @@ -20,7 +24,7 @@ struct ContactsTab: View { } else if contacts.isEmpty { emptyState } else { - ForEach(contacts) { contact in + ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in Button { showContactPlaceholder(for: contact) } label: { @@ -57,16 +61,29 @@ struct ContactsTab: View { } } .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) + .onAppear { + if index >= contacts.count - 5 { + Task { + await loadContacts(reset: false) + } + } + } + } + + if isLoading && !contacts.isEmpty { + loadingState + } else if let pagingError, !contacts.isEmpty { + pagingErrorState(pagingError) } } } .background(Color(UIColor.systemBackground)) .listStyle(.plain) .task { - await loadContacts() + await loadContacts(reset: false) } .refreshable { - await loadContacts() + await refreshContacts() } .alert(item: $activeAlert) { alert in switch alert { @@ -106,7 +123,25 @@ struct ContactsTab: View { .font(.subheadline) .foregroundColor(.orange) Spacer() - Button(action: { Task { await loadContacts() } }) { + Button(action: { Task { await refreshContacts() } }) { + Text(NSLocalizedString("Обновить", comment: "Contacts retry button")) + .font(.subheadline) + } + } + .padding(.vertical, 10) + .listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) + .listRowSeparator(.hidden) + } + + private func pagingErrorState(_ message: String) -> some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(message) + .font(.subheadline) + .foregroundColor(.orange) + Spacer() + Button(action: { Task { await loadContacts(reset: false) } }) { Text(NSLocalizedString("Обновить", comment: "Contacts retry button")) .font(.subheadline) } @@ -136,20 +171,43 @@ struct ContactsTab: View { } @MainActor - private func loadContacts() async { - if isLoading { - return - } + private func refreshContacts() async { + hasMore = true + offset = 0 + pagingError = nil + loadError = nil + contacts.removeAll() + await loadContacts(reset: true) + } + + @MainActor + private func loadContacts(reset: Bool) async { + if isLoading { return } + if !reset && !hasMore { return } isLoading = true - loadError = nil + if offset == 0 { + loadError = nil + } + pagingError = nil do { - let payloads = try await contactsService.fetchContacts() - contacts = payloads.map(Contact.init) + let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset) + let newContacts = payload.items.map(Contact.init) + if reset { + contacts = newContacts + } else { + contacts.append(contentsOf: newContacts) + } + offset += newContacts.count + hasMore = payload.hasMore } catch { - loadError = error.localizedDescription -// activeAlert = .error(message: error.localizedDescription) + let message = error.localizedDescription + if contacts.isEmpty { + loadError = message + } else { + pagingError = message + } if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") } }