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.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 14:25:55 +02:00
parent ec567b32eb
commit 810b084e10
45 changed files with 3656 additions and 66 deletions

View File

@@ -0,0 +1,121 @@
import Foundation
import SwiftUI
/// Schlüssel wie Android `strings.xml` Auflösung über `Localizable.strings` (de/en).
enum L10n {
static func tr(_ key: String) -> String {
String(localized: String.LocalizationValue(key))
}
static func timeoutIn(_ time: String) -> String {
String(format: tr("timeout_in"), time)
}
static func inboxNew(_ count: Int) -> String {
String(format: tr("inbox_new_count"), locale: .current, count)
}
static func feedbackCreatedAt(_ date: String) -> String {
String(format: tr("feedback_created_at"), locale: .current, date)
}
static func countriesLoadError(_ detail: String) -> String {
String(format: tr("countries_load_error"), locale: .current, detail)
}
static func userBlocked(_ name: String) -> String {
String(format: tr("user_blocked"), locale: .current, name)
}
static func userUnblocked(_ name: String) -> String {
String(format: tr("user_unblocked"), locale: .current, name)
}
}
func formatTimeout(totalSeconds: Int) -> String {
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
func localizeRuntimeMessage(_ message: String) -> String {
if message.hasPrefix("Country list could not be loaded:") {
let detail = String(message.dropFirst("Country list could not be loaded:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
return L10n.countriesLoadError(detail)
}
switch message {
case "Image uploaded": return L10n.tr("image_upload_success")
case "Image upload failed": return L10n.tr("image_upload_failed")
case "Image exceeds 5 MB": return L10n.tr("image_upload_too_large")
case "Image could not be opened": return L10n.tr("image_upload_open_failed")
case "Feedback saved": return L10n.tr("feedback_saved")
default:
break
}
if message.hasSuffix(" blocked") {
let name = String(message.dropLast(" blocked".count))
return L10n.userBlocked(name)
}
if message.hasSuffix(" unblocked") {
let name = String(message.dropLast(" unblocked".count))
return L10n.userUnblocked(name)
}
return message
}
func formatFeedbackTimestamp(_ createdAt: String) -> String? {
guard !createdAt.isEmpty else { return nil }
return ISO8601DateFormatter().date(from: createdAt).map {
DateFormatter.localizedString(from: $0, dateStyle: .medium, timeStyle: .short)
}
}
func smileyEmoji(hexCode: String) -> String {
guard let v = UInt32(hexCode, radix: 16), let scalar = UnicodeScalar(v) else { return "" }
return String(Character(scalar))
}
struct SmileyItem: Identifiable {
var id: String { token }
let token: String
let hexCode: String
}
let ypChatSmileys: [SmileyItem] = [
SmileyItem(token: ":)", hexCode: "1F642"),
SmileyItem(token: ":D", hexCode: "1F600"),
SmileyItem(token: ":(", hexCode: "1F641"),
SmileyItem(token: ";)", hexCode: "1F609"),
SmileyItem(token: ":p", hexCode: "1F60B"),
SmileyItem(token: ";p", hexCode: "1F61C"),
SmileyItem(token: "O)", hexCode: "1F607"),
SmileyItem(token: ":*", hexCode: "1F617"),
SmileyItem(token: "(h)", hexCode: "1FA77"),
SmileyItem(token: "xD", hexCode: "1F602"),
SmileyItem(token: ":@", hexCode: "1F635"),
SmileyItem(token: ":O", hexCode: "1F632"),
SmileyItem(token: ":3", hexCode: "1F63A"),
SmileyItem(token: ":|", hexCode: "1F610"),
SmileyItem(token: ":/", hexCode: "1FAE4"),
SmileyItem(token: ":#", hexCode: "1F912"),
SmileyItem(token: "#)", hexCode: "1F973"),
SmileyItem(token: "%)", hexCode: "1F974"),
SmileyItem(token: "(t)", hexCode: "1F44D"),
SmileyItem(token: ":'(", hexCode: "1F622"),
]
struct GenderOptionRow: Identifiable {
var id: String { value }
let value: String
let label: String
}
func displayCountryName(user: UserDto, countries: [CountryOption]) -> String {
if let byEnglish = countries.first(where: { $0.englishName == user.country }) {
return byEnglish.displayName
}
if let byIso = countries.first(where: { $0.isoCode.caseInsensitiveCompare(user.isoCountryCode) == .orderedSame }) {
return byIso.displayName
}
return user.country
}

View File

@@ -0,0 +1,595 @@
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
}
}

View File

@@ -0,0 +1,504 @@
import SwiftUI
enum AppTab: String, CaseIterable, Identifiable {
case online, search, inbox, history, console, more
var id: String { rawValue }
var titleKey: String {
switch self {
case .online: return "tab_online"
case .search: return "tab_search"
case .inbox: return "tab_inbox"
case .history: return "tab_history"
case .console: return "tab_console"
case .more: return "tab_more"
}
}
}
enum MoreSection: String, CaseIterable {
case overview, feedback, partners, faq, rules, safety, imprint
}
/// Entspricht `YpChatRoot` / `ChatShell` (Android).
struct YpChatRoot: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
var body: some View {
Group {
if !chat.isLoggedIn {
LoginScreen()
} else {
ChatShellView()
}
}
.task {
await repo.restoreSession()
}
}
}
// MARK: - Login
struct LoginScreen: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var name = ""
@State private var gender = ""
@State private var age = "18"
@State private var country = "Germany"
private var genderRows: [GenderOptionRow] {
[
GenderOptionRow(value: "F", label: L10n.tr("gender_female")),
GenderOptionRow(value: "M", label: L10n.tr("gender_male")),
GenderOptionRow(value: "P", label: L10n.tr("gender_pair")),
GenderOptionRow(value: "TF", label: L10n.tr("gender_trans_mf")),
GenderOptionRow(value: "TM", label: L10n.tr("gender_trans_fm")),
]
}
private var countryRows: [CountryOption] {
if chat.countries.isEmpty {
return [CountryOption(englishName: "Germany", displayName: "Germany", isoCode: "de")]
}
return chat.countries
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
landingIntroCard
profileCard
}
.padding(16)
}
.background(
LinearGradient(colors: [YpChatTheme.bgApp, YpChatTheme.bgShell], startPoint: .top, endPoint: .bottom)
)
.navigationTitle(L10n.tr("app_name"))
.navigationBarTitleDisplayMode(.inline)
}
.onChange(of: chat.savedProfile) { _, _ in syncProfile() }
.onAppear { syncProfile() }
}
private func syncProfile() {
name = chat.savedProfile.nickname
gender = chat.savedProfile.gender
age = "\(chat.savedProfile.age)"
country = chat.savedProfile.country
}
private var landingIntroCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.tr("landing_eyebrow"))
.font(.caption.weight(.bold))
.foregroundStyle(Color(red: 0.29, green: 0.38, blue: 0.33))
Text(L10n.tr("landing_title"))
.font(.title2.weight(.black))
.foregroundStyle(YpChatTheme.textStrong)
Text(L10n.tr("landing_copy"))
.font(.body)
.foregroundStyle(Color(red: 0.31, green: 0.36, blue: 0.33))
HStack(spacing: 8) {
featureChip(L10n.tr("feature_worldwide_chat"))
featureChip(L10n.tr("feature_image_exchange"))
}
featureChip(L10n.tr("feature_compact_controls"))
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(red: 0.96, green: 0.98, blue: 0.96))
.shadow(color: .black.opacity(0.04), radius: 2, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color(red: 0.81, green: 0.88, blue: 0.83), lineWidth: 1)
)
}
private func featureChip(_ text: String) -> some View {
Text(text)
.font(.caption.weight(.bold))
.foregroundStyle(YpChatTheme.primary700)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Color(red: 0.89, green: 0.94, blue: 0.90), in: Capsule())
}
private var profileCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text(L10n.tr("profile_title"))
.font(.headline.weight(.bold))
.foregroundStyle(YpChatTheme.textStrong)
Text(L10n.tr("profile_copy"))
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
TextField(L10n.tr("label_nick"), text: $name)
.textFieldStyle(.roundedBorder)
Picker(L10n.tr("label_gender"), selection: $gender) {
Text("").tag("")
ForEach(genderRows) { row in
Text(row.label).tag(row.value)
}
}
.pickerStyle(.menu)
TextField(L10n.tr("label_age"), text: $age)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Picker(L10n.tr("label_country"), selection: $country) {
ForEach(countryRows, id: \.englishName) { c in
Text(c.displayName).tag(c.englishName)
}
}
.pickerStyle(.menu)
Button {
let ageInt = Int(age) ?? 18
Task {
await repo.login(userName: name, gender: gender, age: ageInt, country: country)
}
} label: {
Text(L10n.tr("button_start_chat"))
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(YpChatTheme.primary600)
.disabled(name.count < 3 || gender.isEmpty || country.isEmpty)
if let err = chat.errorMessage {
Text(localizeRuntimeMessage(err))
.font(.footnote)
.foregroundStyle(YpChatTheme.danger)
}
Text(chat.isConnected ? L10n.tr("socket_connected") : L10n.tr("socket_connecting"))
.font(.footnote)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(YpChatTheme.surface.opacity(0.99))
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color(red: 0.83, green: 0.87, blue: 0.84), lineWidth: 1)
)
}
}
// MARK: - Shell
struct ChatShellView: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var selectedTab: AppTab = .online
@State private var moreSection: MoreSection = .overview
var body: some View {
VStack(spacing: 0) {
topStatusBar
Divider()
Group {
if chat.currentConversation != nil {
YpChatConversationView()
} else {
tabContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
if chat.currentConversation == nil {
bottomTabBar
}
}
.background(YpChatTheme.bgShell)
.onChange(of: selectedTab) { _, new in
switch new {
case .inbox: repo.requestInbox()
case .history: repo.requestHistory()
case .more:
Task {
await repo.loadFeedback()
await repo.loadFeedbackAdminStatus()
await repo.loadPartners()
}
default: break
}
}
}
private var topStatusBar: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.tr("app_name"))
.font(.headline.weight(.bold))
.foregroundStyle(YpChatTheme.primary700)
Text("\(chat.currentUser?.userName ?? "") \(chat.isConnected ? L10n.tr("status_online") : L10n.tr("status_connecting"))")
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
Text(L10n.timeoutIn(formatTimeout(totalSeconds: chat.remainingSecondsToTimeout)))
.font(.caption)
.foregroundStyle(YpChatTheme.textMuted)
}
Spacer()
Button(L10n.tr("logout")) {
Task { await repo.logout() }
}
.foregroundStyle(YpChatTheme.primary700)
}
.padding(16)
.background(
LinearGradient(
colors: [
Color(red: 0.82, green: 0.91, blue: 0.85),
Color(red: 0.92, green: 0.96, blue: 0.93),
Color(red: 0.97, green: 0.98, blue: 0.97),
],
startPoint: .top,
endPoint: .bottom
)
)
}
private var bottomTabBar: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases) { tab in
Button {
selectedTab = tab
if tab != .more { moreSection = .overview }
} label: {
VStack(spacing: 4) {
Image(systemName: icon(for: tab))
.font(.system(size: 18))
Text(tabLabel(tab))
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.foregroundStyle(selectedTab == tab ? YpChatTheme.primary700 : YpChatTheme.textMuted)
.background(selectedTab == tab ? YpChatTheme.primary100.opacity(0.6) : Color.clear)
}
.buttonStyle(.plain)
}
}
.background(YpChatTheme.surface)
.overlay(Divider(), alignment: .top)
}
private func icon(for tab: AppTab) -> String {
switch tab {
case .online: return "person.2"
case .search: return "magnifyingglass"
case .inbox: return "tray"
case .history: return "clock"
case .console: return "terminal"
case .more: return "ellipsis.circle"
}
}
private func tabLabel(_ tab: AppTab) -> String {
let base = L10n.tr(tab.titleKey)
if tab == .inbox, chat.unreadChatsCount > 0 {
return "\(base) (\(chat.unreadChatsCount))"
}
return base
}
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .online:
UserListView()
case .search:
SearchTabView()
case .inbox:
InboxTabView()
case .history:
HistoryTabView()
case .console:
ConsoleTabView()
case .more:
MoreTabView(section: $moreSection)
}
}
}
// MARK: - Online / Search
struct UserListView: 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_online"))
.font(.title2.weight(.bold))
.padding(.horizontal, 16)
if chat.users.isEmpty {
Text(L10n.tr("no_users_online"))
.foregroundStyle(YpChatTheme.textMuted)
.padding(.horizontal, 16)
}
ForEach(chat.users, id: \.userName) { user in
userRow(user)
}
}
.padding(.vertical, 16)
}
}
private func userRow(_ user: UserDto) -> some View {
Button {
repo.openConversation(userName: user.userName)
} label: {
HStack {
Text(user.userName).fontWeight(.semibold)
Spacer()
Text("\(user.age) \(displayCountryName(user: user, countries: chat.countries))")
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
}
}
struct SearchTabView: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var query = ""
@State private var minAge = ""
@State private var maxAge = ""
@State private var country = ""
@State private var gender = ""
@State private var localError: String?
@State private var hasSearched = false
private var countryRows: [CountryOption] {
if chat.countries.isEmpty {
return [CountryOption(englishName: "Germany", displayName: "Germany", isoCode: "de")]
}
return chat.countries
}
private var genderRows: [GenderOptionRow] {
[
GenderOptionRow(value: "", label: L10n.tr("search_all")),
GenderOptionRow(value: "F", label: L10n.tr("gender_female")),
GenderOptionRow(value: "M", label: L10n.tr("gender_male")),
GenderOptionRow(value: "P", label: L10n.tr("gender_pair")),
GenderOptionRow(value: "TF", label: L10n.tr("gender_trans_mf")),
GenderOptionRow(value: "TM", label: L10n.tr("gender_trans_fm")),
]
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.tr("tab_search"))
.font(.title2.weight(.bold))
TextField(L10n.tr("search_username_includes"), text: $query)
.textFieldStyle(.roundedBorder)
HStack {
TextField(L10n.tr("search_from_age"), text: $minAge)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
TextField(L10n.tr("search_to_age"), text: $maxAge)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
}
Picker(L10n.tr("label_country"), selection: $country) {
Text(L10n.tr("search_all")).tag("")
ForEach(countryRows, id: \.englishName) { c in
Text(c.displayName).tag(c.englishName)
}
}
.pickerStyle(.menu)
Picker(L10n.tr("label_gender"), selection: $gender) {
ForEach(genderRows) { row in
Text(row.label).tag(row.value)
}
}
.pickerStyle(.menu)
if let localError {
Text(localError).foregroundStyle(YpChatTheme.danger)
}
Button(L10n.tr("search_button")) {
let min = Int(minAge)
let max = Int(maxAge)
if let min, let max, min > max {
localError = L10n.tr("search_min_age_error")
return
}
localError = nil
hasSearched = true
repo.search(
nameIncludes: query.isEmpty ? nil : query,
minAge: min,
maxAge: max,
countries: country.isEmpty ? [] : [country],
genders: gender.isEmpty ? [] : [gender]
)
}
.buttonStyle(.borderedProminent)
.tint(YpChatTheme.primary600)
if hasSearched, chat.searchResults.isEmpty {
Text(L10n.tr("search_no_results"))
.foregroundStyle(YpChatTheme.textMuted)
}
ForEach(chat.searchResults, id: \.userName) { user in
Button {
repo.openConversation(userName: user.userName)
} label: {
HStack {
Text(user.userName).fontWeight(.semibold)
Spacer()
Text("\(user.age) \(displayCountryName(user: user, countries: chat.countries))")
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(16)
}
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
/// Farben wie `YpChatRoot.kt`.
enum YpChatTheme {
static let bgApp = Color(red: 0.96, green: 0.97, blue: 0.96)
static let bgShell = Color(red: 0.93, green: 0.95, blue: 0.93)
static let surface = Color.white
static let surfaceSubtle = Color(red: 0.965, green: 0.976, blue: 0.969)
static let surfaceSoftGreen = Color(red: 0.941, green: 0.969, blue: 0.949)
static let surfaceSoftBlue = Color(red: 0.945, green: 0.961, blue: 0.980)
static let surfaceSoftRed = Color(red: 0.984, green: 0.929, blue: 0.929)
static let border = Color(red: 0.843, green: 0.875, blue: 0.851)
static let textStrong = Color(red: 0.094, green: 0.125, blue: 0.106)
static let textMuted = Color(red: 0.388, green: 0.439, blue: 0.404)
static let primary700 = Color(red: 0.141, green: 0.361, blue: 0.227)
static let primary600 = Color(red: 0.184, green: 0.435, blue: 0.275)
static let primary500 = Color(red: 0.239, green: 0.525, blue: 0.329)
static let primary100 = Color(red: 0.906, green: 0.945, blue: 0.918)
static let danger = Color(red: 0.635, green: 0.251, blue: 0.251)
static let bubbleSelf = Color(red: 0.875, green: 0.941, blue: 0.894)
}