Files
singlechat/ios/YpChat/UI/YpChatMoreChatViews.swift
Torsten Schulz (local) bfe28a25fa
All checks were successful
Deploy SingleChat / deploy (push) Successful in 40s
android app, extension of stats
2026-08-10 14:03:34 +02:00

596 lines
24 KiB
Swift
Executable File

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