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:
121
ios/YpChat/UI/YpChatL10n.swift
Normal file
121
ios/YpChat/UI/YpChatL10n.swift
Normal 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
|
||||
}
|
||||
595
ios/YpChat/UI/YpChatMoreChatViews.swift
Normal file
595
ios/YpChat/UI/YpChatMoreChatViews.swift
Normal 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
|
||||
}
|
||||
}
|
||||
504
ios/YpChat/UI/YpChatRoot.swift
Normal file
504
ios/YpChat/UI/YpChatRoot.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
ios/YpChat/UI/YpChatTheme.swift
Normal file
21
ios/YpChat/UI/YpChatTheme.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user