Files
singlechat/ios/YpChat/UI/YpChatRoot.swift
Torsten Schulz (local) 810b084e10 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.
2026-05-12 14:25:55 +02:00

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