Files
singlechat/ios/YpChat/UI/YpChatMoreChatViews.swift
Torsten Schulz (local) 810b084e10 Refactor application structure and configuration
- Updated the application namespace and ID from "net.ypchat.app" to "de.ypchat.android" for better alignment with branding.
- Increased Gradle heap size settings to optimize build performance.
- Disabled dependency constraints to simplify dependency management.
- Removed obsolete files related to the previous application structure, including MainActivity, YpChatApp, and various core components, streamlining the codebase.

These changes collectively enhance the application's configuration and structure, improving maintainability and performance.
2026-05-12 14:25:55 +02:00

596 lines
24 KiB
Swift

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