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