payfrit-works-ios/PayfritWorks/Views/ChatScreen.swift
John Pinkyfloyd c71b9f7dea Add ios-marketing idiom for App Store icon display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:37:59 -08:00

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