payfrit-works-ios/PayfritWorks/Services/ChatService.swift
John Pinkyfloyd d69270a4af iOS parity fixes from Android comparison
- 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>
2026-02-16 14:07:24 -08:00

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
}
}