505 lines
18 KiB
Swift
Executable File
505 lines
18 KiB
Swift
Executable File
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)
|
||
}
|
||
}
|
||
}
|