463 lines
15 KiB
Swift
463 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|