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