Files
singlechat/ios/YpChat/UI/YpChatRoot.swift
Torsten Schulz (local) bfe28a25fa
All checks were successful
Deploy SingleChat / deploy (push) Successful in 40s
android app, extension of stats
2026-08-10 14:03:34 +02:00

505 lines
18 KiB
Swift
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}