import SwiftUI struct ChatScreen: View { let taskId: Int let userType: String // "customer" or "worker" var otherPartyName: String? var otherPartyPhotoUrl: String? var servicePointName: String? var taskColor: Color = .blue @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 workerClosedChat = false // Track if this worker closed the chat // Polling timer private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 0) { // Customer header (for workers) if userType == "worker" { customerHeader } // 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") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(taskColor, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .tint(.white) .navigationBarBackButtonHidden(chatEnded) .toolbar { if chatEnded { ToolbarItem(placement: .navigationBarLeading) { Button { appState.popToTaskList() } label: { HStack(spacing: 4) { Image(systemName: "chevron.left") Text("Tasks") } } } } if userType == "worker" && !chatEnded { ToolbarItem(placement: .navigationBarTrailing) { Button { showCloseChatAlert = true } label: { Image(systemName: "xmark.circle") } } } } .alert("Close Chat", isPresented: $showCloseChatAlert) { Button("Close Chat") { closeChatAction() } Button("Cancel", role: .cancel) { } } message: { Text("Are you sure you want to close this chat?") } .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 { let canSend = !isSending && !messageText.trimmingCharacters(in: .whitespaces).isEmpty return HStack(spacing: 8) { TextField("Type a message...", text: $messageText) .textFieldStyle(.roundedBorder) .onSubmit { sendMessage() } Button(action: sendMessage) { ZStack { Circle() .fill(canSend ? Color.accentColor : Color.accentColor.opacity(0.4)) .frame(width: 40, height: 40) if isSending { ProgressView() .tint(.white) } else { Image(systemName: "paperplane.fill") .foregroundColor(.white) .font(.system(size: 16)) } } } .disabled(!canSend) } .padding(.horizontal, 12) .padding(.vertical, 8) .background(.ultraThinMaterial) } // MARK: - Customer Header private var customerHeader: some View { HStack(spacing: 12) { // Customer avatar ZStack { Circle() .fill(taskColor.opacity(0.2)) .frame(width: 44, height: 44) if let photoUrl = otherPartyPhotoUrl, !photoUrl.isEmpty, let url = URL(string: photoUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() default: customerInitials } } .frame(width: 44, height: 44) .clipShape(Circle()) } else { customerInitials } } VStack(alignment: .leading, spacing: 2) { Text(otherPartyName ?? "Customer") .font(.headline) if let sp = servicePointName, !sp.isEmpty { HStack(spacing: 4) { Image(systemName: "mappin.circle.fill") .font(.caption2) Text(sp) .font(.caption) } .foregroundColor(.secondary) } } Spacer() } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color(.secondarySystemBackground)) } private var customerInitials: some View { let name = otherPartyName ?? "" let parts = name.split(separator: " ") let initials: String if parts.count >= 2 { initials = "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased() } else if let first = parts.first { initials = String(first.prefix(1)).uppercased() } else { initials = "C" } return Text(initials) .font(.headline.bold()) .foregroundColor(taskColor) } // 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 } workerClosedChat = true // Mark that worker is closing, not customer Task { do { // Close chat and complete task try await APIService.shared.closeChat(taskId: taskId) try await APIService.shared.completeTask(taskId: taskId) chatService.closeChatWS() chatEnded = true // Go back to task list appState.popToTaskList() } 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) } }