// // NetworkService.swift // WatchRunner Watch App // // Created by LittleSheep on 2025/10/29. // import Foundation // MARK: - Network Service class NetworkService { private let session = URLSession.shared func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)! var queryItems = [URLQueryItem(name: "take", value: "20")] if filter.lowercased() != "explore" { queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased())) } if let cursor = cursor { queryItems.append(URLQueryItem(name: "cursor", value: cursor)) } components.queryItems = queryItems var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, _) = try await session.data(for: request) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase let activities = try decoder.decode([SnActivity].self, from: data) let hasMore = (activities.first?.type ?? "empty") != "empty" let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format() return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor) } func createPost(title: String, content: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/posts") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let body: [String: Any] = ["title": title, "content": content] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] components.queryItems = queryItems var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase let notifications = try decoder.decode([SnNotification].self, from: data) let httpResponse = response as? HTTPURLResponse let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0 let hasMore = offset + notifications.count < total return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore) } func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, _) = try await session.data(for: request) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(SnAccount.self, from: data) } func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(SnAccountStatus.self, from: data) } func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus { // Check if there's already a customized status let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") var body: [String: Any] = [ "attitude": attitude, "is_invisible": isInvisible, "is_not_disturb": isNotDisturb ] if let label = label, !label.isEmpty { body["label"] = label } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(SnAccountStatus.self, from: data) } func clearStatus(token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") var request = URLRequest(url: url) request.httpMethod = "DELETE" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } // MARK: - Chat API Methods func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, _) = try await session.data(for: request) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase let rooms = try decoder.decode([SnChatRoom].self, from: data) return ChatRoomsResponse(rooms: rooms) } func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 { throw URLError(.resourceUnavailable) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(SnChatRoom.self, from: data) } func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, _) = try await session.data(for: request) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase let invites = try decoder.decode([SnChatMember].self, from: data) return ChatInvitesResponse(invites: invites) } func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } // MARK: - Message API Methods func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } // Try a different pattern: /sphere/chat/messages with roomId as query param var components = URLComponents( url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"), resolvingAgainstBaseURL: false )! var queryItems = [ URLQueryItem(name: "take", value: String(take)) ] if let before = before { queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before))) } components.queryItems = queryItems var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse { _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body" if httpResponse.statusCode != 200 { print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } // Check if data is empty if data.isEmpty { print("[watchOS] fetchChatMessages received empty response data") return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase do { let messages = try decoder.decode([SnChatMessage].self, from: data) print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages") return messages } catch DecodingError.dataCorrupted(let context) { print(context) return [] } catch DecodingError.keyNotFound(let key, let context) { print("Key '\(key)' not found:", context.debugDescription) print("codingPath:", context.codingPath) return [] } catch DecodingError.valueNotFound(let value, let context) { print("Value '\(value)' not found:", context.debugDescription) print("codingPath:", context.codingPath) return [] } catch DecodingError.typeMismatch(let type, let context) { print("Type '\(type)' mismatch:", context.debugDescription) print("codingPath:", context.codingPath) return [] } catch { print("error: ", error) throw error } } }