import PhotosUI import SwiftUI // MARK: - Inbox / History struct InboxTabView: View { @EnvironmentObject private var services: AppServices private var repo: ChatRepository { services.repository } private var chat: ChatState { repo.state } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { Text(L10n.tr("tab_inbox")) .font(.title2.weight(.bold)) .padding(.horizontal, 16) if chat.inboxResults.isEmpty { Text(L10n.tr("inbox_empty")) .foregroundStyle(YpChatTheme.textMuted) .padding(.horizontal, 16) } ForEach(chat.inboxResults, id: \.userName) { item in Button { repo.openConversation(userName: item.userName) } label: { HStack { Text(item.userName).fontWeight(.semibold) Spacer() Text(L10n.inboxNew(item.unreadCount)) .font(.subheadline) .foregroundStyle(YpChatTheme.textMuted) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) .padding(.horizontal, 16) } } .padding(.vertical, 16) } } } struct HistoryTabView: View { @EnvironmentObject private var services: AppServices private var repo: ChatRepository { services.repository } private var chat: ChatState { repo.state } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { Text(L10n.tr("tab_history")) .font(.title2.weight(.bold)) .padding(.horizontal, 16) if chat.historyResults.isEmpty { Text(L10n.tr("history_empty")) .foregroundStyle(YpChatTheme.textMuted) .padding(.horizontal, 16) } ForEach(chat.historyResults, id: \.userName) { item in Button { repo.openConversation(userName: item.userName) } label: { VStack(alignment: .leading, spacing: 4) { Text(item.userName).fontWeight(.semibold) Text(item.lastMessage?.message ?? L10n.tr("no_message")) .font(.subheadline) .foregroundStyle(YpChatTheme.textMuted) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) .padding(.horizontal, 16) } } .padding(.vertical, 16) } } } // MARK: - Console struct ConsoleTabView: View { @EnvironmentObject private var services: AppServices private var repo: ChatRepository { services.repository } private var chat: ChatState { repo.state } @State private var input = "" private var placeholder: String { if chat.awaitingLoginUsername { return L10n.tr("feedback_admin_user") } if chat.awaitingLoginPassword { return L10n.tr("feedback_admin_password") } return L10n.tr("console_placeholder") } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { Text(L10n.tr("console_title")) .font(.title2.weight(.bold)) TextField(placeholder, text: $input) .textFieldStyle(.roundedBorder) Button(L10n.tr("console_send")) { repo.sendMessage(text: input) input = "" } .buttonStyle(.borderedProminent) .tint(YpChatTheme.primary600) .disabled(input.isEmpty) if chat.commandLines.isEmpty, chat.commandTable == nil { Text(L10n.tr("console_empty")) .foregroundStyle(YpChatTheme.textMuted) } ForEach(Array(chat.commandLines.enumerated()), id: \.offset) { _, line in consoleLineCard(line: localizeRuntimeMessage(line), kind: chat.commandKind) } if let table = chat.commandTable { commandTableCard(table) } } .padding(16) } } private func consoleLineCard(line: String, kind: String?) -> some View { let k = kind ?? "" let bg: Color = { if k.hasPrefix("login") { return YpChatTheme.surfaceSoftBlue } if k == "info" { return YpChatTheme.surfaceSoftGreen } if k == "error" { return YpChatTheme.surfaceSoftRed } return YpChatTheme.surfaceSubtle }() let border: Color = { if k.hasPrefix("login") { return Color(red: 0.79, green: 0.86, blue: 0.93) } if k == "info" { return Color(red: 0.81, green: 0.89, blue: 0.83) } if k == "error" { return Color(red: 0.94, green: 0.79, blue: 0.79) } return YpChatTheme.border }() let fg = k == "error" ? YpChatTheme.danger : YpChatTheme.textStrong return Text(line) .font(.body) .foregroundStyle(fg) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(bg, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(border, lineWidth: 1)) } private func commandTableCard(_ table: CommandTableState) -> some View { VStack(alignment: .leading, spacing: 10) { Text(table.title).fontWeight(.bold).foregroundStyle(YpChatTheme.textStrong) if !table.columns.isEmpty { HStack { ForEach(table.columns, id: \.self) { col in Text(col).fontWeight(.bold).foregroundStyle(YpChatTheme.primary700) Spacer(minLength: 8) } } .padding(.horizontal, 10) .padding(.vertical, 8) .background(YpChatTheme.primary100, in: RoundedRectangle(cornerRadius: 12)) } ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in HStack { ForEach(Array(row.enumerated()), id: \.offset) { _, cell in Text(cell).foregroundStyle(YpChatTheme.textMuted) Spacer(minLength: 8) } } .padding(.horizontal, 10) .padding(.vertical, 8) .background(YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 12)) } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(YpChatTheme.border, lineWidth: 1)) } } // MARK: - More struct MoreTabView: View { @Binding var section: MoreSection var body: some View { Group { switch section { case .overview: moreOverview case .feedback: FeedbackDetailView { section = .overview } case .partners: PartnersDetailView { section = .overview } case .faq: staticContent(title: L10n.tr("faq_title"), body: L10n.tr("faq_body")) { section = .overview } case .rules: staticContent(title: L10n.tr("rules_title"), body: L10n.tr("rules_body")) { section = .overview } case .safety: staticContent(title: L10n.tr("safety_title"), body: L10n.tr("safety_body")) { section = .overview } case .imprint: staticContent(title: L10n.tr("imprint_title"), body: L10n.tr("imprint_body")) { section = .overview } } } } private var moreOverview: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { Text(L10n.tr("more_title")) .font(.title2.weight(.bold)) moreLink(L10n.tr("more_feedback"), L10n.tr("feedback_comment")) { section = .feedback } moreLink(L10n.tr("more_partners"), L10n.tr("partners_intro")) { section = .partners } moreLink(L10n.tr("more_faq"), L10n.tr("faq_intro")) { section = .faq } moreLink(L10n.tr("more_rules"), L10n.tr("rules_intro")) { section = .rules } moreLink(L10n.tr("more_safety"), L10n.tr("safety_intro")) { section = .safety } moreLink(L10n.tr("more_imprint"), L10n.tr("imprint_intro")) { section = .imprint } } .padding(16) } } private func moreLink(_ title: String, _ subtitle: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(alignment: .leading, spacing: 6) { Text(title).fontWeight(.semibold).foregroundStyle(YpChatTheme.textStrong) Text(subtitle).font(.subheadline).foregroundStyle(YpChatTheme.textMuted) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } private func staticContent(title: String, body: String, onBack: @escaping () -> Void) -> some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { Button(L10n.tr("more_back"), action: onBack) .foregroundStyle(YpChatTheme.primary700) Text(title) .font(.title2.weight(.bold)) } Text(body).foregroundStyle(YpChatTheme.textStrong) } .padding(16) } } } struct FeedbackDetailView: View { @EnvironmentObject private var services: AppServices var onBack: () -> Void private var repo: ChatRepository { services.repository } private var chat: ChatState { repo.state } @State private var comment = "" @State private var adminUser = "" @State private var adminPassword = "" var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { Button(L10n.tr("more_back"), action: onBack) .foregroundStyle(YpChatTheme.primary700) Text(L10n.tr("feedback_title")) .font(.title2.weight(.bold)) } TextField(L10n.tr("feedback_comment"), text: $comment, axis: .vertical) .lineLimit(4 ... 8) .textFieldStyle(.roundedBorder) Button(L10n.tr("feedback_send")) { Task { await repo.submitFeedback(comment: comment) comment = "" } } .buttonStyle(.borderedProminent) .tint(YpChatTheme.primary600) .disabled(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) if let fm = chat.feedbackMessage { Text(localizeRuntimeMessage(fm)) .font(.footnote) .foregroundStyle(YpChatTheme.textMuted) } VStack(alignment: .leading, spacing: 8) { if chat.feedbackAdminAuthenticated { Text("\(L10n.tr("feedback_admin_user")): \(chat.feedbackAdminUserName ?? "")") .fontWeight(.semibold) Button(L10n.tr("feedback_admin_logout")) { Task { await repo.logoutFeedbackAdmin() } } .buttonStyle(.bordered) .tint(YpChatTheme.primary500) } else { TextField(L10n.tr("feedback_admin_user"), text: $adminUser) .textFieldStyle(.roundedBorder) SecureField(L10n.tr("feedback_admin_password"), text: $adminPassword) .textFieldStyle(.roundedBorder) Button(L10n.tr("feedback_admin_login")) { Task { await repo.loginFeedbackAdmin(username: adminUser, password: adminPassword) } } .buttonStyle(.bordered) .tint(YpChatTheme.primary500) } if let err = chat.feedbackAdminError { Text(localizeRuntimeMessage(err)) .foregroundStyle(YpChatTheme.danger) .font(.footnote) } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 12)) if chat.feedbackItems.isEmpty { Text(L10n.tr("feedback_empty")) .foregroundStyle(YpChatTheme.textMuted) } ForEach(Array(chat.feedbackItems.enumerated()), id: \.offset) { _, item in feedbackRow(item) } } .padding(16) } } private func feedbackRow(_ item: FeedbackItemDto) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { Text(item.name.flatMap { $0.isEmpty ? nil : $0 } ?? L10n.tr("anonymous")) .fontWeight(.semibold) Spacer() if chat.feedbackAdminAuthenticated { Button(L10n.tr("feedback_delete"), role: .destructive) { Task { await repo.deleteFeedback(id: item.id) } } .font(.caption) } } Text(item.comment).foregroundStyle(YpChatTheme.textStrong) let meta = [item.country, item.gender, item.age.map { String($0) }] .compactMap { $0 } .filter { !$0.isEmpty } if !meta.isEmpty { Text(meta.joined(separator: L10n.tr("feedback_meta_separator"))) .font(.caption) .foregroundStyle(YpChatTheme.textMuted) } if let ts = formatFeedbackTimestamp(item.createdAt) { Text(L10n.feedbackCreatedAt(ts)) .font(.caption2) .foregroundStyle(YpChatTheme.textMuted) } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } } struct PartnersDetailView: View { @EnvironmentObject private var services: AppServices var onBack: () -> Void @Environment(\.openURL) private var openURL private var chat: ChatState { services.repository.state } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { Button(L10n.tr("more_back"), action: onBack) .foregroundStyle(YpChatTheme.primary700) Text(L10n.tr("partners_title")) .font(.title2.weight(.bold)) } Text(L10n.tr("partners_intro")) .foregroundStyle(YpChatTheme.textMuted) if let err = chat.partnersError { Text(localizeRuntimeMessage(err)) .foregroundStyle(YpChatTheme.danger) } ForEach(Array(chat.partnerLinks.enumerated()), id: \.offset) { _, link in Button { if let u = URL(string: link.url) { openURL(u) } } label: { VStack(alignment: .leading, spacing: 4) { Text(link.pageName).fontWeight(.semibold) Text(link.url).foregroundStyle(YpChatTheme.primary700) Text(L10n.tr("external_link")) .font(.caption) .foregroundStyle(YpChatTheme.textMuted) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } .padding(16) } } } // MARK: - Chat struct YpChatConversationView: View { @EnvironmentObject private var services: AppServices private var repo: ChatRepository { services.repository } private var chat: ChatState { repo.state } @State private var draft = "" @State private var showSmileys = false @State private var photoItem: PhotosPickerItem? private static let maxImageBytes = 5 * 1024 * 1024 var body: some View { VStack(spacing: 0) { HStack { Button(L10n.tr("back")) { repo.closeConversation() } Text(chat.currentConversation ?? "") .fontWeight(.bold) .frame(maxWidth: .infinity) Button(L10n.tr("block")) { if let u = chat.currentConversation { repo.blockUser(userName: u) } } Button(L10n.tr("unblock")) { if let u = chat.currentConversation { repo.unblockUser(userName: u) } } } .padding(12) Divider() ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(Array(chat.messages.enumerated()), id: \.offset) { _, message in messageBubble(message) } } .padding(12) } if chat.isUploadingImage || chat.imageUploadMessage != nil { uploadBanner } HStack(alignment: .bottom) { TextField(L10n.tr("message_placeholder"), text: $draft, axis: .vertical) .lineLimit(1 ... 4) .textFieldStyle(.roundedBorder) .disabled(chat.isUploadingImage) Button { showSmileys.toggle() } label: { Image(systemName: "face.smiling") } .disabled(chat.isUploadingImage) PhotosPicker(selection: $photoItem, matching: .images) { Image(systemName: "photo") } .disabled(chat.isUploadingImage) Button(L10n.tr("button_send")) { repo.sendMessage(text: draft) draft = "" } .buttonStyle(.borderedProminent) .tint(YpChatTheme.primary600) .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || chat.isUploadingImage) } .padding(12) if showSmileys { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(ypChatSmileys) { sm in Button { draft += sm.token showSmileys = false } label: { Text(smileyEmoji(hexCode: sm.hexCode)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(YpChatTheme.primary100, in: Capsule()) .foregroundStyle(YpChatTheme.primary700) .fontWeight(.bold) } } } .padding(.horizontal, 12) } } if let err = chat.errorMessage { Text(localizeRuntimeMessage(err)) .font(.footnote) .foregroundStyle(YpChatTheme.danger) .padding(.horizontal, 12) } } .onChange(of: photoItem) { _, new in Task { await handlePhoto(new) } } } private var uploadBanner: some View { let msg: String = { if chat.isUploadingImage { return L10n.tr("image_upload_in_progress") } return localizeRuntimeMessage(chat.imageUploadMessage ?? "") }() let ok = chat.imageUploadMessage == "Image uploaded" let bg: Color = chat.isUploadingImage ? YpChatTheme.surfaceSoftBlue : (ok ? YpChatTheme.surfaceSoftGreen : YpChatTheme.surfaceSoftRed) let fg: Color = chat.isUploadingImage || ok ? YpChatTheme.primary700 : YpChatTheme.danger return Text(msg) .font(.subheadline) .foregroundStyle(fg) .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(bg) .padding(.horizontal, 12) } @ViewBuilder private func messageBubble(_ message: ChatMessageDto) -> some View { let selfMsg = message.from == (chat.currentUser?.userName ?? "") HStack { if selfMsg { Spacer(minLength: 40) } VStack(alignment: selfMsg ? .trailing : .leading) { if message.isImage, let urlStr = message.imageUrl, let url = URL(string: urlStr) { AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable().scaledToFit().frame(maxHeight: 220) case .failure: Text(L10n.tr("image_message")).foregroundStyle(YpChatTheme.textMuted) case .empty: ProgressView() @unknown default: EmptyView() } } } else { Text(message.message) } } .padding(12) .background(selfMsg ? YpChatTheme.bubbleSelf : YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 18)) if !selfMsg { Spacer(minLength: 40) } } } private func handlePhoto(_ item: PhotosPickerItem?) async { guard let item, let partner = chat.currentConversation else { return } repo.setImageUploadState(inProgress: true, message: nil) guard let data = try? await item.loadTransferable(type: Data.self) else { repo.setImageUploadState(inProgress: false, message: "Image could not be opened") photoItem = nil return } guard data.count <= Self.maxImageBytes else { repo.setImageUploadState(inProgress: false, message: "Image exceeds 5 MB") photoItem = nil return } do { let response = try await repo.uploadImage(data: data, fileName: "ypchat-image.jpg", mimeType: "image/jpeg") if response.success, let code = response.code, let url = response.url, !code.isEmpty { repo.sendImage(toUserName: partner, imageCode: code, imageUrl: url) repo.setImageUploadState(inProgress: false, message: "Image uploaded") } else { repo.setImageUploadState(inProgress: false, message: response.error ?? "Image upload failed") } } catch { repo.setImageUploadState(inProgress: false, message: error.localizedDescription) } photoItem = nil } }