- Add profile endpoints (getProfile/updateProfile) to APIService - Fix beacon dwell time: 5 → 30 samples to match Android (~3s) - Make chat WebSocket dev-aware (uses IS_DEV flag) - Add activeTaskCount field to Employment model - Fix categoryName: remove incorrect fallback to taskTypeName Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
204 lines
6.2 KiB
Swift
204 lines
6.2 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Chat Event Types
|
|
|
|
enum ChatEventType {
|
|
case joined, userJoined, userLeft, chatEnded, disconnected, error
|
|
}
|
|
|
|
struct ChatEvent {
|
|
let type: ChatEventType
|
|
let message: String?
|
|
let data: [String: Any]?
|
|
|
|
init(type: ChatEventType, message: String? = nil, data: [String: Any]? = nil) {
|
|
self.type = type
|
|
self.message = message
|
|
self.data = data
|
|
}
|
|
}
|
|
|
|
struct TypingEvent {
|
|
let userType: String
|
|
let userName: String
|
|
let isTyping: Bool
|
|
}
|
|
|
|
// MARK: - Chat Service (WebSocket)
|
|
|
|
@MainActor
|
|
final class ChatService: ObservableObject {
|
|
// Dev-aware WebSocket URL - matches API dev/prod switching
|
|
private static var wsBaseURL: String {
|
|
IS_DEV ? "wss://dev.payfrit.com:3001" : "wss://app.payfrit.com:3001"
|
|
}
|
|
|
|
@Published var isConnected = false
|
|
@Published var chatClosed = false
|
|
|
|
private var webSocketTask: URLSessionWebSocketTask?
|
|
private var currentTaskId: Int?
|
|
private var userType: String?
|
|
private var pingTimer: Timer?
|
|
|
|
// Callbacks
|
|
var onMessage: ((ChatMessage) -> Void)?
|
|
var onTyping: ((TypingEvent) -> Void)?
|
|
var onEvent: ((ChatEvent) -> Void)?
|
|
|
|
// MARK: - Connect
|
|
|
|
func connect(taskId: Int, userToken: String, userType: String) async -> Bool {
|
|
if webSocketTask != nil { disconnect() }
|
|
|
|
currentTaskId = taskId
|
|
self.userType = userType
|
|
|
|
guard let url = URL(string: Self.wsBaseURL) else { return false }
|
|
|
|
let session = URLSession(configuration: .default)
|
|
webSocketTask = session.webSocketTask(with: url)
|
|
webSocketTask?.resume()
|
|
|
|
// Send join event
|
|
let joinPayload: [String: Any] = [
|
|
"event": "join-chat",
|
|
"task_id": taskId,
|
|
"user_token": userToken,
|
|
"user_type": userType
|
|
]
|
|
|
|
guard let joinData = try? JSONSerialization.data(withJSONObject: joinPayload),
|
|
let joinString = String(data: joinData, encoding: .utf8) else { return false }
|
|
|
|
do {
|
|
try await webSocketTask?.send(.string(joinString))
|
|
isConnected = true
|
|
startListening()
|
|
startPing()
|
|
return true
|
|
} catch {
|
|
isConnected = false
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Listen
|
|
|
|
private func startListening() {
|
|
webSocketTask?.receive { [weak self] result in
|
|
Task { @MainActor in
|
|
guard let self = self else { return }
|
|
switch result {
|
|
case .success(let message):
|
|
switch message {
|
|
case .string(let text):
|
|
self.handleMessage(text)
|
|
case .data(let data):
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
self.handleMessage(text)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
self.startListening()
|
|
case .failure:
|
|
self.isConnected = false
|
|
self.onEvent?(ChatEvent(type: .disconnected))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleMessage(_ text: String) {
|
|
guard let data = text.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let event = json["event"] as? String else { return }
|
|
|
|
switch event {
|
|
case "joined":
|
|
onEvent?(ChatEvent(type: .joined, data: json))
|
|
case "new-message":
|
|
if let msgData = json["data"] as? [String: Any] {
|
|
let msg = ChatMessage(json: msgData)
|
|
onMessage?(msg)
|
|
}
|
|
case "user-typing":
|
|
let data = json["data"] as? [String: Any] ?? json
|
|
onTyping?(TypingEvent(
|
|
userType: data["userType"] as? String ?? "",
|
|
userName: data["userName"] as? String ?? "",
|
|
isTyping: data["isTyping"] as? Bool ?? false
|
|
))
|
|
case "user-joined":
|
|
onEvent?(ChatEvent(type: .userJoined, data: json["data"] as? [String: Any]))
|
|
case "user-left":
|
|
onEvent?(ChatEvent(type: .userLeft, data: json["data"] as? [String: Any]))
|
|
case "chat-ended", "chat-closed":
|
|
chatClosed = true
|
|
onEvent?(ChatEvent(type: .chatEnded, message: (json["data"] as? [String: Any])?["message"] as? String ?? "Chat has ended"))
|
|
case "error":
|
|
onEvent?(ChatEvent(type: .error, message: (json["data"] as? [String: Any])?["message"] as? String ?? "Unknown error"))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Send
|
|
|
|
func sendMessage(_ text: String) {
|
|
guard let taskId = currentTaskId else { return }
|
|
let payload: [String: Any] = [
|
|
"event": "send-message",
|
|
"task_id": taskId,
|
|
"message": text
|
|
]
|
|
sendJSON(payload)
|
|
}
|
|
|
|
func setTyping(_ isTyping: Bool) {
|
|
guard let taskId = currentTaskId else { return }
|
|
let payload: [String: Any] = [
|
|
"event": "typing",
|
|
"task_id": taskId,
|
|
"is_typing": isTyping
|
|
]
|
|
sendJSON(payload)
|
|
}
|
|
|
|
func closeChatWS() {
|
|
guard let taskId = currentTaskId else { return }
|
|
let payload: [String: Any] = [
|
|
"event": "chat-closed",
|
|
"task_id": taskId
|
|
]
|
|
sendJSON(payload)
|
|
}
|
|
|
|
private func sendJSON(_ payload: [String: Any]) {
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
let string = String(data: data, encoding: .utf8) else { return }
|
|
webSocketTask?.send(.string(string)) { _ in }
|
|
}
|
|
|
|
// MARK: - Ping
|
|
|
|
private func startPing() {
|
|
pingTimer?.invalidate()
|
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|
self?.webSocketTask?.sendPing { _ in }
|
|
}
|
|
}
|
|
|
|
// MARK: - Disconnect
|
|
|
|
func disconnect() {
|
|
pingTimer?.invalidate()
|
|
pingTimer = nil
|
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
webSocketTask = nil
|
|
isConnected = false
|
|
currentTaskId = nil
|
|
userType = nil
|
|
}
|
|
}
|