389 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|