payfrit-works-ios/PayfritWorks/Views/ChatScreen.swift
2026-02-01 23:38:34 -08:00

389 lines
13 KiB
Swift

import SwiftUI
struct ChatScreen: View {
let taskId: Int
let userType: String // "customer" or "worker"
var otherPartyName: String?
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@StateObject private var chatService = ChatService()
@State private var messages: [ChatMessage] = []
@State private var messageText = ""
@State private var isLoading = true
@State private var isSending = false
@State private var error: String?
@State private var otherUserTyping = false
@State private var otherUserName: String?
@State private var chatEnded = false
@State private var showCloseChatAlert = false
@State private var showingMyTasks = false
// Polling timer
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 0) {
// Chat ended banner
if chatEnded {
Text("This chat has ended")
.font(.callout.bold())
.frame(maxWidth: .infinity)
.padding(12)
.background(Color.payfritGreen.opacity(0.2))
}
// Error banner
if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.padding(8)
.background(Color.red.opacity(0.1))
.onTapGesture { self.error = nil }
}
// Messages
messageListView
// Typing indicator
if otherUserTyping {
HStack {
Text("\(otherUserName ?? "Other user") is typing...")
.font(.caption)
.foregroundColor(.secondary)
.italic()
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
// Input
if !chatEnded {
inputArea
}
}
.navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if chatService.isConnected {
Image(systemName: "wifi")
.foregroundColor(.green)
.font(.caption)
} else {
Image(systemName: "wifi.slash")
.foregroundColor(.payfritGreen)
.font(.caption)
}
}
if userType == "worker" && !chatEnded {
ToolbarItem(placement: .navigationBarTrailing) {
Button { showCloseChatAlert = true } label: {
Image(systemName: "xmark.circle")
}
}
}
}
.alert("Close Chat", isPresented: $showCloseChatAlert) {
Button("Cancel", role: .cancel) { }
Button("Close", role: .destructive) { closeChatAction() }
} message: {
Text("Are you sure you want to close this chat?")
}
.overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, chatEnded ? 16 : 60)
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task {
otherUserName = otherPartyName
await initializeChat()
}
.onReceive(pollTimer) { _ in
if !chatEnded && !chatService.isConnected {
pollNewMessages()
}
}
.onDisappear {
chatService.disconnect()
}
}
// MARK: - Message List
@ViewBuilder
private var messageListView: some View {
if isLoading {
Spacer()
ProgressView()
Spacer()
} else if messages.isEmpty {
Spacer()
VStack(spacing: 16) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No messages yet")
.foregroundColor(.secondary)
Text("Start the conversation!")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { msg in
messageBubble(msg)
.id(msg.messageId)
}
}
.padding(16)
}
.onChange(of: messages.count) { _ in
if let last = messages.last {
withAnimation {
proxy.scrollTo(last.messageId, anchor: .bottom)
}
}
}
}
}
}
private func messageBubble(_ msg: ChatMessage) -> some View {
let isMe = msg.senderType == userType
let time = msg.createdOn.formatted(date: .omitted, time: .shortened)
return HStack(alignment: .bottom, spacing: 8) {
if isMe { Spacer(minLength: 60) }
if !isMe {
Circle()
.fill(Color(.systemGray4))
.frame(width: 32, height: 32)
.overlay(
Text(msg.senderName.isEmpty
? (msg.senderType == "worker" ? "S" : "C")
: String(msg.senderName.prefix(1)).uppercased())
.font(.caption.bold())
)
}
VStack(alignment: isMe ? .trailing : .leading, spacing: 4) {
if !isMe && !msg.senderName.isEmpty {
Text(msg.senderName)
.font(.caption2.bold())
.foregroundColor(.secondary)
}
Text(msg.text)
.foregroundColor(isMe ? .white : .primary)
Text(time)
.font(.caption2)
.foregroundColor(isMe ? .white.opacity(0.7) : .secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isMe ? Color.accentColor : Color(.systemGray5))
.cornerRadius(16, corners: isMe
? [.topLeft, .topRight, .bottomLeft]
: [.topLeft, .topRight, .bottomRight])
if isMe {
let initials = {
let parts = (appState.userName ?? "").split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
} else if let first = parts.first {
return String(first.prefix(1)).uppercased()
}
return "Me"
}()
Circle()
.fill(Color.accentColor.opacity(0.7))
.frame(width: 32, height: 32)
.overlay(
Text(initials)
.font(.caption2)
.foregroundColor(.white)
)
}
if !isMe { Spacer(minLength: 60) }
}
}
// MARK: - Input
private var inputArea: some View {
HStack(spacing: 8) {
TextField("Type a message...", text: $messageText)
.textFieldStyle(.roundedBorder)
.onSubmit { sendMessage() }
Button(action: sendMessage) {
if isSending {
ProgressView()
.frame(width: 36, height: 36)
} else {
Image(systemName: "paperplane.fill")
.frame(width: 36, height: 36)
}
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.disabled(isSending || messageText.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
}
// MARK: - Actions
private func initializeChat() async {
// Load messages first
await loadMessages()
// Try WebSocket
let token = await APIService.shared.getToken()
guard let token = token, !token.isEmpty else { return }
chatService.onMessage = { msg in
if !messages.contains(where: { $0.messageId == msg.messageId }) {
messages.append(msg)
Task {
if msg.senderType != userType {
try? await APIService.shared.markChatMessagesRead(taskId: taskId, readerType: userType)
}
}
}
}
chatService.onTyping = { event in
if event.userType != userType {
otherUserTyping = event.isTyping
if !event.userName.isEmpty { otherUserName = event.userName }
}
}
chatService.onEvent = { event in
switch event.type {
case .chatEnded:
chatEnded = true
case .userJoined:
if let name = event.data?["userName"] as? String {
otherUserName = name
}
default: break
}
}
let _ = await chatService.connect(taskId: taskId, userToken: token, userType: userType)
}
private func loadMessages() async {
isLoading = true
error = nil
do {
let result = try await APIService.shared.getChatMessages(taskId: taskId)
messages = result.messages
if result.chatClosed { chatEnded = true }
isLoading = false
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func sendMessage() {
let text = messageText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !isSending, !chatEnded else { return }
isSending = true
chatService.setTyping(false)
if chatService.isConnected {
chatService.sendMessage(text)
messageText = ""
isSending = false
} else {
Task {
do {
let uid = await APIService.shared.getUserId()
_ = try await APIService.shared.sendChatMessage(
taskId: taskId, message: text, userId: uid, senderType: userType
)
messageText = ""
await loadMessages()
} catch {
self.error = error.localizedDescription
}
isSending = false
}
}
}
private func pollNewMessages() {
Task {
let lastId = messages.last?.messageId ?? 0
if let result = try? await APIService.shared.getChatMessages(taskId: taskId, afterMessageId: lastId) {
if result.chatClosed && !chatEnded { chatEnded = true }
for msg in result.messages {
if !messages.contains(where: { $0.messageId == msg.messageId }) {
messages.append(msg)
}
}
}
}
}
private func closeChatAction() {
guard userType == "worker" else { return }
Task {
do {
try await APIService.shared.closeChat(taskId: taskId)
chatService.closeChatWS()
chatEnded = true
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
}
// MARK: - Corner Radius Extension
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCornerShape(radius: radius, corners: corners))
}
}
struct RoundedCornerShape: Shape {
var radius: CGFloat
var corners: UIRectCorner
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}