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