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.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 14:25:55 +02:00
parent ec567b32eb
commit 810b084e10
45 changed files with 3656 additions and 66 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -27,11 +27,11 @@ val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl)
val hasReleaseSigning = releaseStoreFile?.exists() == true val hasReleaseSigning = releaseStoreFile?.exists() == true
android { android {
namespace = "net.ypchat.app" namespace = "de.ypchat.android"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "net.ypchat.app" applicationId = "de.ypchat.android"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1

Binary file not shown.

View File

@@ -1,12 +1,12 @@
package net.ypchat.app package de.ypchat.android
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import net.ypchat.app.ui.ChatViewModel import de.ypchat.android.ui.ChatViewModel
import net.ypchat.app.ui.ChatViewModelFactory import de.ypchat.android.ui.ChatViewModelFactory
import net.ypchat.app.ui.YpChatRoot import de.ypchat.android.ui.YpChatRoot
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: ChatViewModel by viewModels { private val viewModel: ChatViewModel by viewModels {

View File

@@ -1,7 +1,7 @@
package net.ypchat.app package de.ypchat.android
import android.app.Application import android.app.Application
import net.ypchat.app.core.AppContainer import de.ypchat.android.core.AppContainer
class YpChatApp : Application() { class YpChatApp : Application() {
lateinit var container: AppContainer lateinit var container: AppContainer

View File

@@ -1,6 +1,6 @@
package net.ypchat.app.core package de.ypchat.android.core
import net.ypchat.app.BuildConfig import de.ypchat.android.BuildConfig
object AppConfig { object AppConfig {
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/') val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')

View File

@@ -1,9 +1,9 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context
import net.ypchat.app.data.api.RestApi import de.ypchat.android.data.api.RestApi
import net.ypchat.app.data.api.SocketClient import de.ypchat.android.data.api.SocketClient
import net.ypchat.app.data.repository.ChatRepository import de.ypchat.android.data.repository.ChatRepository
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context
import okhttp3.Cookie import okhttp3.Cookie

View File

@@ -1,14 +1,14 @@
package net.ypchat.app.data.api package de.ypchat.android.data.api
import net.ypchat.app.data.model.CountriesResponse import de.ypchat.android.data.model.CountriesResponse
import net.ypchat.app.data.model.FeedbackAdminLoginRequest import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import net.ypchat.app.data.model.FeedbackAdminStatusResponse import de.ypchat.android.data.model.FeedbackAdminStatusResponse
import net.ypchat.app.data.model.FeedbackRequest import de.ypchat.android.data.model.FeedbackRequest
import net.ypchat.app.data.model.FeedbackResponse import de.ypchat.android.data.model.FeedbackResponse
import net.ypchat.app.data.model.ImageUploadResponse import de.ypchat.android.data.model.ImageUploadResponse
import net.ypchat.app.data.model.LogoutResponse import de.ypchat.android.data.model.LogoutResponse
import net.ypchat.app.data.model.PartnerLinkDto import de.ypchat.android.data.model.PartnerLinkDto
import net.ypchat.app.data.model.SessionResponse import de.ypchat.android.data.model.SessionResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.api package de.ypchat.android.data.api
import io.socket.client.IO import io.socket.client.IO
import io.socket.client.Socket import io.socket.client.Socket
@@ -10,11 +10,11 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.data.model.ChatMessageDto import de.ypchat.android.data.model.ChatMessageDto
import net.ypchat.app.data.model.HistoryItemDto import de.ypchat.android.data.model.HistoryItemDto
import net.ypchat.app.data.model.InboxItemDto import de.ypchat.android.data.model.InboxItemDto
import net.ypchat.app.data.model.SocketEvent import de.ypchat.android.data.model.SocketEvent
import net.ypchat.app.data.model.UserDto import de.ypchat.android.data.model.UserDto
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.model package de.ypchat.android.data.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.model package de.ypchat.android.data.model
sealed interface SocketEvent { sealed interface SocketEvent {
data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.repository package de.ypchat.android.data.repository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,22 +9,22 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.core.AppConfig import de.ypchat.android.core.AppConfig
import net.ypchat.app.core.ProfileStore import de.ypchat.android.core.ProfileStore
import net.ypchat.app.core.SavedProfile import de.ypchat.android.core.SavedProfile
import net.ypchat.app.core.SessionCookieJar import de.ypchat.android.core.SessionCookieJar
import net.ypchat.app.data.api.RestApi import de.ypchat.android.data.api.RestApi
import net.ypchat.app.data.api.SocketClient import de.ypchat.android.data.api.SocketClient
import net.ypchat.app.data.model.ChatMessageDto import de.ypchat.android.data.model.ChatMessageDto
import net.ypchat.app.data.model.CountryOption import de.ypchat.android.data.model.CountryOption
import net.ypchat.app.data.model.FeedbackAdminLoginRequest import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import net.ypchat.app.data.model.FeedbackItemDto import de.ypchat.android.data.model.FeedbackItemDto
import net.ypchat.app.data.model.FeedbackRequest import de.ypchat.android.data.model.FeedbackRequest
import net.ypchat.app.data.model.HistoryItemDto import de.ypchat.android.data.model.HistoryItemDto
import net.ypchat.app.data.model.InboxItemDto import de.ypchat.android.data.model.InboxItemDto
import net.ypchat.app.data.model.PartnerLinkDto import de.ypchat.android.data.model.PartnerLinkDto
import net.ypchat.app.data.model.SocketEvent import de.ypchat.android.data.model.SocketEvent
import net.ypchat.app.data.model.UserDto import de.ypchat.android.data.model.UserDto
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.util.Locale import java.util.Locale

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.ui package de.ypchat.android.ui
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@@ -8,8 +8,8 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.data.repository.ChatRepository import de.ypchat.android.data.repository.ChatRepository
import net.ypchat.app.data.repository.ChatState import de.ypchat.android.data.repository.ChatState
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.ui package de.ypchat.android.ui
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
@@ -64,14 +64,14 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import net.ypchat.app.R import de.ypchat.android.R
import net.ypchat.app.data.model.ChatMessageDto import de.ypchat.android.data.model.ChatMessageDto
import net.ypchat.app.data.model.CountryOption import de.ypchat.android.data.model.CountryOption
import net.ypchat.app.data.model.FeedbackItemDto import de.ypchat.android.data.model.FeedbackItemDto
import net.ypchat.app.data.model.PartnerLinkDto import de.ypchat.android.data.model.PartnerLinkDto
import net.ypchat.app.data.model.UserDto import de.ypchat.android.data.model.UserDto
import net.ypchat.app.data.repository.ChatState import de.ypchat.android.data.repository.ChatState
import net.ypchat.app.data.repository.CommandTableState import de.ypchat.android.data.repository.CommandTableState
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter

View File

@@ -1,10 +1,10 @@
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8
android.r8.maxHeapSize=8g
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.code.style=official kotlin.code.style=official
android.dependency.useConstraints=true android.dependency.useConstraints=false
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
android.dependency.excludeLibraryComponentsFromConstraints=true
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
kotlin.daemon.jvmargs=-Xmx2048m kotlin.daemon.jvmargs=-Xmx2048m
android.lint.workerProcessMaxHeapSize=4g android.lint.workerProcessMaxHeapSize=4g

BIN
android/production Normal file

Binary file not shown.

View File

@@ -0,0 +1,275 @@
# iOS-App für YpChat Umsetzungsplan
Dieses Dokument beschreibt die **komplette** Planung einer nativen iOS-App mit **Feature-Parität** zur bestehenden Android-App (`android/app`, Paket `de.ypchat.android`). Die Android-Implementierung dient als fachliche und API-Referenz.
---
## 1. Ziele und Abgrenzung
### 1.1 Produktziel
- Nutzer können sich wie in der Android-App anmelden, chatten, suchen, Posteingang/Verlauf nutzen, Konsole-Befehle senden, Feedback und Partner-Links einsehen sowie Bilder hochladen und versenden.
- Gleiche Backend-URLs, REST-Endpunkte und Socket.IO-Ereignisse wie auf Android.
### 1.2 Nicht-Ziele (optional später)
- WatchOS, iPad-spezifisches Layout (erst iPhone-first).
- Eigener Push-Benachrichtigungsdienst (nur falls das Backend später APNs unterstützt).
---
## 2. Referenz: Android-Architektur (zu spiegeln)
| Schicht | Android | iOS-Empfehlung |
|--------|---------|----------------|
| Konfiguration | `BuildConfig.BASE_URL` / `local.properties` | Xcode Build-Konfiguration + `xcconfig` oder Info-Key `BASE_URL` |
| DI / Container | `AppContainer` | Eigene `AppServices`-Klasse oder schlankes Factory-Pattern beim App-Start |
| HTTP + Cookies | OkHttp + `SessionCookieJar` | `URLSession` mit `HTTPCookieStorage` (shared oder app-group bei Bedarf) |
| REST | Retrofit + Gson | `URLSession` + `Codable` (oder optional Alamofire) |
| Echtzeit | `io.socket` (Socket.IO) | [socket.io-client-swift](https://github.com/socketio/socket.io-client-swift) (oder vergleichbare aktive Library) |
| Zustand | `ChatRepository` + `StateFlow` | `ObservableObject` / `@Observable` + `async` oder Combine |
| UI | Jetpack Compose | **SwiftUI** (empfohlen) |
| Profil lokal | `ProfileStore` (SharedPreferences) | `UserDefaults` oder kleines Keychain-Wrapper nur wenn sensibel |
Wichtige Dateien zum Abgleich: `AppContainer.kt`, `RestApi.kt`, `SocketClient.kt`, `ChatRepository.kt`, `ChatViewModel.kt`, `YpChatRoot.kt`, `Models.kt`, `SocketEvent.kt`.
---
## 3. Technologie- und Projektentscheidungen
### 3.1 Sprache und UI
- **Swift 5.10+**, Deployment **iOS 17+** (oder 16, wenn Gerätebindung es erfordert dann API prüfen).
- **SwiftUI** für alle Screens; Navigation: `TabView` + eingebettete Unternavigation für „Mehr“.
### 3.2 Abhängigkeiten
- **Socket.IO-Client** (Swift-Paket via SPM): muss dieselben Transporte unterstützen wie Android (`websocket` + `polling`); Verbindungsoptionen an `SocketClient.kt` anlehnen (Reconnect, Timeout).
- **Kein** Retrofit native `URLSession` reicht für die überschaubare REST-Oberfläche.
- Bilder: **PhotosUI** (`PhotosPicker` / `PHPicker`) für die Bildauswahl; Upload als `multipart/form-data` wie Android.
### 3.3 Bundle-ID und Naming
- Vorschlag: `de.ypchat.ios` oder konsistent mit Android `de.ypchat.android` → z.B. `de.ypchat.app` (einheitlich mit Marketing/Store).
- Anzeigename: wie `R.string.app_name` auf Android.
---
## 4. Konfiguration und Build-Varianten
### 4.1 Base-URL
- Standard wie Android: `https://www.ypchat.net` (siehe `defaultBaseUrl` in `android/app/build.gradle.kts`).
- Pro Build-Konfiguration überschreibbar:
- **Debug**: lokale `Config/Debug.xcconfig` mit `BASE_URL = https://…` (oder Staging).
- **Release**: feste Produktions-URL.
- Zur Laufzeit: `Bundle` / generierte `Info`-Keys auslesen, trailing slash wie `AppConfig.kt` entfernen.
### 4.2 App Transport Security (ATS)
- Produktion: HTTPS wie Android Release (`usesCleartextTraffic` false).
- Debug: nur bei Bedarf `NSAppTransportSecurity` für HTTP-Testserver dokumentieren und nicht in Release aktiv lassen.
### 4.3 Berechtigungen (Info.plist)
- **Foto-Bibliothek** (Lesen): `NSPhotoLibraryUsageDescription` mit klarer Begründung (Bild im Chat senden).
- Keine Kamera zwingend nötig, wenn nur Galerie wie Android `PickVisualMedia`.
---
## 5. Datenmodell (Codable)
Alle DTOs aus `Models.kt` 1:1 als `struct` mit `Codable` abbilden, inkl. Sonderfall **`CountriesResponse`**: auf Android ein `LinkedHashMap<String, String>` auf iOS als `[String: String]` decodieren oder eigener `Decodable`-Wrapper.
**PartnerLinkDto**: JSON-Feld `"Page Name"``CodingKeys` mit `case pageName = "Page Name"`.
---
## 6. REST-API
Basis-URL + Pfad wie `RestApi.kt`:
| Methode | Pfad | Zweck |
|---------|------|--------|
| GET | `api/session` | Session / eingeloggter User |
| POST | `api/logout` | Logout |
| GET | `api/countries` | Länderliste |
| GET | `api/feedback` | Feedback-Liste |
| GET | `api/feedback/admin-status` | Admin-Session-Status |
| POST | `api/feedback` | Feedback senden |
| POST | `api/feedback/admin-login` | Admin-Login |
| POST | `api/feedback/admin-logout` | Admin-Logout |
| DELETE | `api/feedback/{id}` | Eintrag löschen (Admin) |
| GET | `api/partners` | Partner-Links |
| POST | `api/upload-image` | Multipart-Feld `image` |
**Cookie-Handling:** Nach `session`-Call und weiteren Requests müssen Cookies wie im Browser/OkHttp mitgeführt werden `URLSessionConfiguration.default` mit `httpCookieStorage` und `httpShouldSetCookies` / `httpCookieAcceptPolicy` prüfen; bei Problemen explizit `Cookie`-Header aus Storage für die Domain setzen.
**Fehlerbehandlung:** HTTP-Status und Body auswerten; Fehlermeldungen in den UI-State wie `ChatState.errorMessage` / `feedbackMessage`.
---
## 7. Socket.IO-Client
### 7.1 Verbindung
- URL: gleiche `baseUrl` wie REST (ohne zusätzlichen Pfad, sofern Server Root nutzt mit Android-Verhalten abgleichen).
- Optionen analog `SocketClient.kt`: Reconnect, `reconnectionAttempts`, Delays, Timeout; Transports WebSocket + Polling falls die Swift-Library das abbildet.
### 7.2 Authentifizierung / Session
- Beim Connect bzw. nach Connect: `setSessionId` mit Payload `{ "expressSessionId": "<id>" }` identisch zu Android.
- `pendingExpressSessionId` bei erneutem Connect erneut senden.
### 7.3 Client → Server (emit)
| Event | Payload-Felder (Kern) |
|-------|------------------------|
| `login` | userName, gender, age, country, expressSessionId |
| `message` | message, messageId, optional toUserName; für Bild: toUserName, isImage, imageUrl |
| `requestConversation` | withUserName |
| `userSearch` | nameIncludes, minAge, maxAge, countries[], genders[] |
| `requestHistory` | (leer) |
| `requestOpenConversations` | (leer) |
| `blockUser` / `unblockUser` | userName |
### 7.4 Server → Client (on)
Alle Events aus `SocketClient.kt` abbilden und in ein internes `enum` / sealed Äquivalent übersetzen: `connected`, `loginSuccess`, `userList`, `message`, `messageSent`, `conversation`, `searchResults`, `historyResults`, `inboxResults`, `unreadChats`, `userBlocked`, `userUnblocked`, `commandResult`, `commandTable`, `error`, sowie Verbindungsmetadaten (`connect`, `disconnect`, `connect_error`).
**JSON-Parsing:** Foundation `JSONSerialization` oder `JSONDecoder` mit `[String: Any]`-Hilfstypen Feldnamen und Typen strikt an Android-Mapper (`toUserDto`, `toMessageDto`, …) halten.
### 7.5 HTTP-Stack und Socket
- Android nutzt **dieselbe** `OkHttpClient`-Instanz für Socket und REST. Auf iOS: wo möglich eine gemeinsame Cookie-Quelle; falls die Socket-Library keinen `URLSession` teilt, nach dem Connect sicherstellen, dass `expressSessionId` per `setSessionId` gesetzt ist (wie Android bei getrenntem Transport).
---
## 8. Repository- und App-Logik (`ChatRepository`)
Die Reducer-Logik aus `ChatRepository.reduce` und die Methoden **funktional kopieren**:
- **restoreSession:** Profil laden, Länder laden, `feedbackAdminStatus`, `GET api/session`, dann Socket connect + `setSessionId`.
- **login:** Profil speichern, Session-ID beschaffen, Socket ggf. verbinden, `login` emit.
- **logout:** `POST api/logout`, Socket trennen, Cookies löschen, State reset mit erhaltenem `savedProfile` und `countries`.
- **Timeout:** 30 Minuten (`1800` s) Ticker jede Sekunde, bei 0 automatisch `logout` (wie `startTimeoutTicker` / `resetTimeout`).
- **openConversation / closeConversation**, **sendMessage** (inkl. Konsolen-Befehle mit `/`), **search**, **inbox/history**, **block/unblock**.
- **Bild:** Upload REST → bei Erfolg `sendImage` mit absoluter URL (Basis-URL voranstellen wenn relativ).
Zustand als eine **`ChatState`-Struktur** (alle Properties aus `ChatRepository.kt`).
---
## 9. Präsentationsschicht (SwiftUI)
### 9.1 ViewModel
- `@MainActor` ViewModel (oder Repository auf MainActor publizieren), das `ChatState` published und User-Aktionen an das Repository delegiert.
- `task { await repository.restoreSession() }` beim Erscheinen der Root-View.
### 9.2 Bildschirme (Parität zu `YpChatRoot`)
1. **Login:** Landing-Karte, Profilfelder, Gender/Country-Picker, Validierung (Nickname ≥ 3 Zeichen), Socket-Status.
2. **Haupt-Shell:** Top-Bar (App-Name, User, Online-Status, Timeout-Countdown, Logout).
3. **Tabs:** Online | Suche | Posteingang | Verlauf | Konsole | Mehr wie Android; Posteingang-Badge mit `unreadChatsCount`.
4. **Chat:** Zurück, Blockieren/Entblocken, Nachrichtenliste, Eingabe, Smiley-Leiste (gleiche Tokens wie `SmileyItems`), Bild wählen, Senden, Upload-Banner.
5. **Suche / Inbox / Verlauf:** Listen wie Android.
6. **Konsole:** Eingabe, Senden, Ausgabezeilen + Tabelle (`CommandTableState`).
7. **Mehr:** Unterseiten Feedback (inkl. Admin-Login), Partner (Safari öffnen), FAQ, Regeln, Sicherheit, Impressum Texte aus **lokalisierten Strings** (siehe `res/values/strings.xml` auf Android als Quelle für DE/EN).
### 9.3 Design
- Farben und Abstände an die Android-Farbkonstanten in `YpChatRoot.kt` anlehnen für ein konsistentes Erscheinungsbild.
- Bilder in Chat: AsyncImage / Kingfisher nur falls nötig; Caching beachten.
### 9.4 Lokalisierung
- `Localizable.xcstrings` (oder `.strings`) mindestens Deutsch; Android-`strings.xml` als Master-Liste.
---
## 10. Sicherheit und Datenschutz
- Passwörter (Feedback-Admin) nur im Speicher, nicht loggen.
- TLS in Release erzwingen.
- Keychain nur nötig, wenn sensible Tokens dauerhaft ohne Cookies gespeichert werden sollen (aktuell: Session eher cookie-basiert wie Web).
---
## 11. Qualitätssicherung
### 11.1 Tests
- **Unit-Tests:** JSON-Decodierung der REST- und Socket-Payloads (Fixtures aus echten Server-Responses erfassen).
- **Integration:** Manuell gegen Staging/Produktion: Login, Nachricht, Suche, Feedback, Bild-Upload, Logout, Timeout.
### 11.2 Edge Cases
- App in den Hintergrund: Socket-Verhalten (Disconnect/Reconnect) und Timeout-Reset gemäß Produktregel klären (Android tickt weiter iOS analog umsetzen).
- Schlechte Netzwerke: Fehlermeldungen aus `SocketEvent.Error` und REST anzeigen.
---
## 12. App Store / Vertrieb
- **Apple Developer Program**, App-ID, Provisioning Profiles.
- **Datenschutzerklärung** und **App-Privacy-Labels** (Netzwerk, Fotos, ggf. Nutzerinhalt).
- **TestFlight** für interne Tester vor Release.
- Versionierung: an `versionName` / `versionCode` der Android-App angleichen oder eigenes Schema dokumentieren.
---
## 13. Repository-Struktur (Vorschlag)
```
ios/
YpChat.xcodeproj oder YpChat.xcworkspace
YpChat/
App/
Core/ # AppConfig, Cookie/Session storage, ProfileStore
Data/ # APIClient, SocketClient, DTOs
Features/ # Login, Chat, Tabs, More, …
Resources/ # Assets, Localizable
Config/
Debug.xcconfig
Release.xcconfig
```
Root-`README` oder dieses Dokument verlinken, wie man `BASE_URL` setzt.
---
## 14. Phasenplan (Meilensteine)
| Phase | Inhalt | Ergebnis |
|-----|--------|----------|
| **P0** | Xcode-Projekt, Konfiguration, leere SwiftUI-App, BASE_URL | Laufende Shell-App |
| **P1** | REST-Client + Cookie-Speicher, `session` / `countries` / `logout` | Session funktioniert |
| **P2** | Socket-Client, `setSessionId`, `login`, `userList`, `message` | Minimaler Chat |
| **P3** | Vollständiger `ChatRepository`-State, alle Socket-Events, Timeout | Parität Kernlogik |
| **P4** | SwiftUI: Login + Tabs + Chat + Suche/Inbox/Verlauf | UI-Hauptteil |
| **P5** | Bild-Upload + Anzeige, Smileys | Medien-Parität |
| **P6** | Mehr: Feedback, Partner, statische Seiten | Rest-UI |
| **P7** | Lokalisierung, Feinschliff, Dark Mode optional | Polish |
| **P8** | TestFlight, Store-Metadaten, Screenshots | Release |
---
## 15. Risiken und Abhängigkeiten
- **Socket.IO-Swift-Version** muss zum **Server-Socket.IO** passen; bei Protokoll-Mismatch Verbindungsfehler mit Server-Version abgleichen.
- **Cookie-Domain / Pfad:** muss mit dem Backend identisch sein, sonst wirkt `session` wie „nicht eingeloggt“.
- **Bildgröße:** 5MB-Limit clientseitig wie Android (`MAX_IMAGE_BYTES`).
---
## 16. Nächster konkreter Schritt
1. Im Repo Ordner `ios/` anlegen und Xcode-Projekt (SwiftUI App) hinzufügen.
2. SPM: Socket.IO-Client einbinden, minimales `SocketClient`-Swift gegen Staging testen.
3. `GET api/session` mit gemeinsamem Cookie-Storage verifizieren.
4. Danach schrittweise `ChatRepository` portieren und UI anbinden.
---
*Stand: Abgleich mit Android-Codebase (Compose-App, Mai 2026). Bei Backend-Änderungen diesen Plan und die Event-Liste aktualisieren.*

4
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
xcuserdata/
*.xcuserstate
DerivedData/
.build/

View File

@@ -0,0 +1,2 @@
# Debug: BASE_URL bei Bedarf für Staging oder lokalen Server anpassen.
BASE_URL = https://www.ypchat.net

View File

@@ -0,0 +1,2 @@
# Release: Produktions-URL
BASE_URL = https://www.ypchat.net

42
ios/README.md Normal file
View File

@@ -0,0 +1,42 @@
# YPChat iOS
Native iOS-App (SwiftUI): **P0P3** wie zuvor; **P4** vollständige UI wie `YpChatRoot.kt`: Login, Tabs (Online, Suche, Posteingang, Verlauf, Konsole, Mehr), Chat mit Smileys/PhotosPicker, Feedback & Partner, **de/en** `Localizable.strings`.
Ausführlicher Gesamtplan: [docs/ios-app-umsetzungsplan.md](../docs/ios-app-umsetzungsplan.md).
## Voraussetzungen
- macOS mit **Xcode 15+**
- Apple-ID / Developer Team für Geräte-Builds (Simulator geht ohne kostenpflichtiges Programm)
## Projekt öffnen
```bash
open ios/YpChat.xcodeproj
```
Xcode lädt das Swift-Paket **socket.io-client-swift** (Produkt `SocketIO`) beim ersten Öffnen automatisch (**File → Packages → Resolve Package Versions** bei Problemen).
1. Target **YpChat****Signing & Capabilities**: **Team** wählen (sonst schlägt der Build auf dem Gerät fehl).
2. Scheme **YpChat**, Ziel **iPhone-Simulator** oder echtes Gerät.
3. **Run** (⌘R).
## BASE_URL
Wie unter Android (`ypchat.baseUrl` / Standard `https://www.ypchat.net`):
- `ios/Config/Debug.xcconfig` bzw. `Release.xcconfig`: Variable `BASE_URL`
- Wird per `$(BASE_URL)` in `YpChat/Resources/Info.plist` eingetragen und zur Laufzeit in `AppConfig.baseURL` gelesen.
## Aktueller Funktionsstand
- Start lädt automatisch **GET /api/session** (Pull-to-refresh ebenfalls).
- **Logout:** Socket trennen, **POST /api/logout**, Cookies leeren, Session erneut laden.
- **UI (P4):** `ContentView``YpChatRoot` (`UI/YpChatRoot.swift`, `YpChatMoreChatViews.swift`). Steuerung über `services.repository`. Lokalisierung: `Resources/de.lproj` + `en.lproj`.
- **Repository:** unverändert zentral; Timeout, Socket-`reduce`, REST wie Android.
Optional: Feinschliff (Dark Mode, größere Schrift), **P5** bereits durch Bild-Upload in der Chat-Ansicht abgedeckt.
## App-Icon
`AppIcon.appiconset` ist als Platzhalter ohne PNG angelegt. Für Archive/TestFlight in Xcode ein **1024×1024**-Icon hinterlegen oder Asset-Generator nutzen.

View File

@@ -0,0 +1,397 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
2A0000000000000000000401 /* YpChatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000101 /* YpChatApp.swift */; };
2A0000000000000000000402 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000102 /* ContentView.swift */; };
2A0000000000000000000403 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000103 /* AppConfig.swift */; };
2A0000000000000000000404 /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000104 /* AppServices.swift */; };
2A0000000000000000000405 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000105 /* Models.swift */; };
2A0000000000000000000406 /* RestAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000106 /* RestAPIClient.swift */; };
2A0000000000000000000407 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000108 /* Assets.xcassets */; };
2A0000000000000000000410 /* SocketEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000110 /* SocketEvent.swift */; };
2A0000000000000000000411 /* SocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000111 /* SocketClient.swift */; };
2A0000000000000000000412 /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000112 /* ProfileStore.swift */; };
2A0000000000000000000413 /* ChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000113 /* ChatRepository.swift */; };
2B0000000000000000000003 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 2B0000000000000000000002 /* SocketIO */; };
2A0000000000000000000414 /* YpChatTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000114 /* YpChatTheme.swift */; };
2A0000000000000000000415 /* YpChatL10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000115 /* YpChatL10n.swift */; };
2A0000000000000000000416 /* YpChatRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000116 /* YpChatRoot.swift */; };
2A0000000000000000000417 /* YpChatMoreChatViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000117 /* YpChatMoreChatViews.swift */; };
2A0000000000000000000408 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000701 /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2A0000000000000000000101 /* YpChatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatApp.swift; sourceTree = "<group>"; };
2A0000000000000000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
2A0000000000000000000103 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
2A0000000000000000000104 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
2A0000000000000000000105 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
2A0000000000000000000106 /* RestAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestAPIClient.swift; sourceTree = "<group>"; };
2A0000000000000000000110 /* SocketEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketEvent.swift; sourceTree = "<group>"; };
2A0000000000000000000111 /* SocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketClient.swift; sourceTree = "<group>"; };
2A0000000000000000000112 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
2A0000000000000000000113 /* ChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRepository.swift; sourceTree = "<group>"; };
2A0000000000000000000114 /* YpChatTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatTheme.swift; sourceTree = "<group>"; };
2A0000000000000000000115 /* YpChatL10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatL10n.swift; sourceTree = "<group>"; };
2A0000000000000000000116 /* YpChatRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatRoot.swift; sourceTree = "<group>"; };
2A0000000000000000000117 /* YpChatMoreChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatMoreChatViews.swift; sourceTree = "<group>"; };
2A0000000000000000000702 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
2A0000000000000000000703 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
2A0000000000000000000107 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2A0000000000000000000108 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2A0000000000000000000109 /* YpChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YpChat.app; sourceTree = BUILT_PRODUCTS_DIR; };
2A0000000000000000000601 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
2A0000000000000000000602 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
2A0000000000000000000302 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2B0000000000000000000003 /* SocketIO in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2A0000000000000000000200 = {
isa = PBXGroup;
children = (
2A0000000000000000000206 /* Config */,
2A0000000000000000000201 /* YpChat */,
2A0000000000000000000205 /* Products */,
);
sourceTree = "<group>";
};
2A0000000000000000000201 /* YpChat */ = {
isa = PBXGroup;
children = (
2A0000000000000000000101 /* YpChatApp.swift */,
2A0000000000000000000102 /* ContentView.swift */,
2A0000000000000000000202 /* Core */,
2A0000000000000000000203 /* Data */,
2A0000000000000000000207 /* UI */,
2A0000000000000000000204 /* Resources */,
);
path = YpChat;
sourceTree = "<group>";
};
2A0000000000000000000207 /* UI */ = {
isa = PBXGroup;
children = (
2A0000000000000000000114 /* YpChatTheme.swift */,
2A0000000000000000000115 /* YpChatL10n.swift */,
2A0000000000000000000116 /* YpChatRoot.swift */,
2A0000000000000000000117 /* YpChatMoreChatViews.swift */,
);
path = UI;
sourceTree = "<group>";
};
2A0000000000000000000202 /* Core */ = {
isa = PBXGroup;
children = (
2A0000000000000000000103 /* AppConfig.swift */,
2A0000000000000000000104 /* AppServices.swift */,
2A0000000000000000000112 /* ProfileStore.swift */,
);
path = Core;
sourceTree = "<group>";
};
2A0000000000000000000203 /* Data */ = {
isa = PBXGroup;
children = (
2A0000000000000000000105 /* Models.swift */,
2A0000000000000000000106 /* RestAPIClient.swift */,
2A0000000000000000000110 /* SocketEvent.swift */,
2A0000000000000000000111 /* SocketClient.swift */,
2A0000000000000000000113 /* ChatRepository.swift */,
);
path = Data;
sourceTree = "<group>";
};
2A0000000000000000000204 /* Resources */ = {
isa = PBXGroup;
children = (
2A0000000000000000000107 /* Info.plist */,
2A0000000000000000000108 /* Assets.xcassets */,
2A0000000000000000000701 /* Localizable.strings */,
);
path = Resources;
sourceTree = "<group>";
};
2A0000000000000000000205 /* Products */ = {
isa = PBXGroup;
children = (
2A0000000000000000000109 /* YpChat.app */,
);
name = Products;
sourceTree = "<group>";
};
2A0000000000000000000206 /* Config */ = {
isa = PBXGroup;
children = (
2A0000000000000000000601 /* Debug.xcconfig */,
2A0000000000000000000602 /* Release.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXVariantGroup section */
2A0000000000000000000701 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
2A0000000000000000000702 /* de */,
2A0000000000000000000703 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin PBXNativeTarget section */
2A0000000000000000000002 /* YpChat */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2A0000000000000000000004 /* Build configuration list for PBXNativeTarget "YpChat" */;
buildPhases = (
2A0000000000000000000301 /* Sources */,
2A0000000000000000000302 /* Frameworks */,
2A0000000000000000000303 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = YpChat;
packageProductDependencies = (
2B0000000000000000000002 /* SocketIO */,
);
productName = YpChat;
productReference = 2A0000000000000000000109 /* YpChat.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
2A0000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
2A0000000000000000000002 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 2A0000000000000000000003 /* Build configuration list for PBXProject "YpChat" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = de;
hasScannedForEncodings = 0;
knownRegions = (
Base,
de,
en,
);
mainGroup = 2A0000000000000000000200;
packageReferences = (
2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */,
);
productRefGroup = 2A0000000000000000000205 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
2A0000000000000000000002 /* YpChat */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
2A0000000000000000000303 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2A0000000000000000000407 /* Assets.xcassets in Resources */,
2A0000000000000000000408 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
2A0000000000000000000301 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2A0000000000000000000401 /* YpChatApp.swift in Sources */,
2A0000000000000000000402 /* ContentView.swift in Sources */,
2A0000000000000000000403 /* AppConfig.swift in Sources */,
2A0000000000000000000404 /* AppServices.swift in Sources */,
2A0000000000000000000405 /* Models.swift in Sources */,
2A0000000000000000000406 /* RestAPIClient.swift in Sources */,
2A0000000000000000000410 /* SocketEvent.swift in Sources */,
2A0000000000000000000411 /* SocketClient.swift in Sources */,
2A0000000000000000000412 /* ProfileStore.swift in Sources */,
2A0000000000000000000413 /* ChatRepository.swift in Sources */,
2A0000000000000000000414 /* YpChatTheme.swift in Sources */,
2A0000000000000000000415 /* YpChatL10n.swift in Sources */,
2A0000000000000000000416 /* YpChatRoot.swift in Sources */,
2A0000000000000000000417 /* YpChatMoreChatViews.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2A0000000000000000000501 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
2A0000000000000000000502 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
MTL_ENABLE_DEBUG_INFO = NO;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
2A0000000000000000000503 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2A0000000000000000000601 /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = YpChat/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ypchat.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
2A0000000000000000000504 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2A0000000000000000000602 /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = YpChat/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ypchat.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2A0000000000000000000003 /* Build configuration list for PBXProject "YpChat" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2A0000000000000000000501 /* Debug */,
2A0000000000000000000502 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
2A0000000000000000000004 /* Build configuration list for PBXNativeTarget "YpChat" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2A0000000000000000000503 /* Debug */,
2A0000000000000000000504 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 16.1.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
2B0000000000000000000002 /* SocketIO */ = {
isa = XCSwiftPackageProductDependency;
package = 2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */;
productName = SocketIO;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 2A0000000000000000000001 /* Project object */;
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2A0000000000000000000002"
BuildableName = "YpChat.app"
BlueprintName = "YpChat"
ReferencedContainer = "container:YpChat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2A0000000000000000000002"
BuildableName = "YpChat.app"
BlueprintName = "YpChat"
ReferencedContainer = "container:YpChat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2A0000000000000000000002"
BuildableName = "YpChat.app"
BlueprintName = "YpChat"
ReferencedContainer = "container:YpChat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,12 @@
import SwiftUI
struct ContentView: View {
var body: some View {
YpChatRoot()
}
}
#Preview {
ContentView()
.environmentObject(AppServices())
}

View File

@@ -0,0 +1,11 @@
import Foundation
enum AppConfig {
/// Entspricht `AppConfig.kt` / `BuildConfig.BASE_URL` (ohne trailing slash).
static var baseURL: String {
let raw =
Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String
?? "https://www.ypchat.net"
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
}

View File

@@ -0,0 +1,38 @@
import Combine
import Foundation
/// DI-Container light analog `AppContainer.kt` inkl. `ChatRepository`.
final class AppServices: ObservableObject {
let api: RestAPIClient
let socket: SocketClient
let profileStore: ProfileStore
let repository: ChatRepository
private var cancellables = Set<AnyCancellable>()
init() {
let config = URLSessionConfiguration.default
config.httpCookieStorage = .shared
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
let urlSession = URLSession(configuration: config)
let api = RestAPIClient(baseURLString: AppConfig.baseURL, session: urlSession)
let socket = SocketClient(baseURL: AppConfig.baseURL)
let profileStore = ProfileStore()
self.api = api
self.socket = socket
self.profileStore = profileStore
self.repository = ChatRepository(api: api, socket: socket, profileStore: profileStore)
repository.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
/// Entspricht `ProfileStore.kt` / `SavedProfile`.
struct SavedProfile: Equatable, Sendable {
var nickname: String = ""
var gender: String = ""
var age: Int = 18
var country: String = "Germany"
}
final class ProfileStore: @unchecked Sendable {
private let defaults: UserDefaults
private let prefix = "ypchat_profile."
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func read() -> SavedProfile {
SavedProfile(
nickname: defaults.string(forKey: prefix + "nickname") ?? "",
gender: defaults.string(forKey: prefix + "gender") ?? "",
age: {
let k = prefix + "age"
return defaults.object(forKey: k) == nil ? 18 : defaults.integer(forKey: k)
}(),
country: defaults.string(forKey: prefix + "country") ?? "Germany"
)
}
func write(_ profile: SavedProfile) {
defaults.set(profile.nickname.trimmingCharacters(in: .whitespacesAndNewlines), forKey: prefix + "nickname")
defaults.set(profile.gender, forKey: prefix + "gender")
defaults.set(profile.age, forKey: prefix + "age")
defaults.set(profile.country, forKey: prefix + "country")
}
func clear() {
for key in ["nickname", "gender", "age", "country"] {
defaults.removeObject(forKey: prefix + key)
}
}
}

View File

@@ -0,0 +1,493 @@
import Combine
import Foundation
/// Entspricht `CommandTableState` in `ChatRepository.kt`.
struct CommandTableState: Equatable, Sendable {
var title: String
var columns: [String]
var rows: [[String]]
}
/// Zentraler UI-Zustand wie `ChatState` in `ChatRepository.kt`.
struct ChatState: Equatable, Sendable {
var isConnected: Bool = false
var isLoggedIn: Bool = false
var expressSessionId: String?
var currentUser: UserDto?
var users: [UserDto] = []
var currentConversation: String?
var messages: [ChatMessageDto] = []
var searchResults: [UserDto] = []
var inboxResults: [InboxItemDto] = []
var historyResults: [HistoryItemDto] = []
var countries: [CountryOption] = []
var feedbackItems: [FeedbackItemDto] = []
var feedbackMessage: String?
var feedbackAdminAuthenticated: Bool = false
var feedbackAdminUserName: String?
var feedbackAdminError: String?
var partnerLinks: [PartnerLinkDto] = []
var partnersError: String?
var savedProfile: SavedProfile = SavedProfile()
var commandLines: [String] = []
var commandKind: String?
var commandTable: CommandTableState?
var awaitingLoginUsername: Bool = false
var awaitingLoginPassword: Bool = false
var remainingSecondsToTimeout: Int = 1800
var isUploadingImage: Bool = false
var imageUploadMessage: String?
var unreadChatsCount: Int = 0
var errorMessage: String?
}
/// Analog `ChatRepository.kt`: REST + Socket + `reduce` + Timeout.
final class ChatRepository: ObservableObject {
@Published private(set) var state = ChatState()
private let api: RestAPIClient
private let socket: SocketClient
private let profileStore: ProfileStore
private var cancellables = Set<AnyCancellable>()
private var timeoutTickerStarted = false
init(api: RestAPIClient, socket: SocketClient, profileStore: ProfileStore) {
self.api = api
self.socket = socket
self.profileStore = profileStore
socket.eventPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.reduce(event)
}
.store(in: &cancellables)
startTimeoutTicker()
}
// MARK: - Session / Profil
func restoreSession() async {
var s = state
s.savedProfile = profileStore.read()
state = s
await loadCountries()
await loadFeedbackAdminStatus()
do {
let session = try await api.sessionStatus()
var next = state
next.expressSessionId = session.sessionId
next.isLoggedIn = session.loggedIn && session.user != nil
next.currentUser = session.user
next.errorMessage = nil
state = next
if session.loggedIn, session.user != nil {
resetTimeout()
}
connectSocket(expressSessionId: session.sessionId)
} catch {
var next = state
next.errorMessage = error.localizedDescription
state = next
}
}
func loadCountries() async {
do {
let countries = try await api.countries()
let locale = Locale.current
let options: [CountryOption] = countries.map { englishName, code in
let normalized = code.uppercased()
let localized = Locale.current.localizedString(forRegionCode: normalized)
let display = localized.flatMap { $0.isEmpty ? nil : $0 } ?? englishName
return CountryOption(englishName: englishName, displayName: display, isoCode: code)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
var next = state
next.countries = options
state = next
} catch {
var next = state
next.errorMessage = "Country list could not be loaded: \(error.localizedDescription)"
state = next
}
}
func login(userName: String, gender: String, age: Int, country: String) async {
profileStore.write(SavedProfile(nickname: userName, gender: gender, age: age, country: country))
let session = try? await api.sessionStatus()
let sessionId = session?.sessionId ?? state.expressSessionId
var next = state
next.expressSessionId = sessionId
state = next
if !socket.isConnected {
connectSocket(expressSessionId: sessionId)
}
socket.login(userName: userName, gender: gender, age: age, country: country, expressSessionId: sessionId)
resetTimeout()
}
func logout() async {
try? await api.logout()
socket.disconnect()
clearCookies()
var fresh = ChatState()
fresh.savedProfile = profileStore.read()
fresh.countries = state.countries
state = fresh
}
func connectSocket(expressSessionId: String? = nil) {
let sid = expressSessionId ?? state.expressSessionId
socket.connect()
if let sid {
socket.setSessionId(sid)
}
}
// MARK: - Chat
func openConversation(userName: String) {
var next = state
next.currentConversation = userName
next.messages = []
state = next
socket.requestConversation(withUserName: userName)
resetTimeout()
}
func closeConversation() {
var next = state
next.currentConversation = nil
next.messages = []
state = next
}
func sendMessage(text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let target = state.currentConversation
let isCommand = trimmed.hasPrefix("/")
if target == nil, !isCommand { return }
socket.sendMessage(toUserName: target, message: trimmed)
if !isCommand {
let msg = ChatMessageDto(
from: state.currentUser?.userName ?? "",
to: target,
message: trimmed,
messageId: nil,
timestamp: ISO8601DateFormatter().string(from: Date()),
read: false,
isImage: false,
imageType: nil,
imageUrl: nil,
imageCode: nil
)
var next = state
next.messages.append(msg)
state = next
}
resetTimeout()
}
func sendImage(toUserName: String, imageCode: String, imageUrl: String) {
let absoluteUrl: String =
imageUrl.hasPrefix("http") ? imageUrl : AppConfig.baseURL + imageUrl
socket.sendImage(toUserName: toUserName, imageCode: imageCode, imageUrl: absoluteUrl)
let msg = ChatMessageDto(
from: state.currentUser?.userName ?? "",
to: toUserName,
message: absoluteUrl,
messageId: nil,
timestamp: ISO8601DateFormatter().string(from: Date()),
read: false,
isImage: true,
imageType: nil,
imageUrl: absoluteUrl,
imageCode: imageCode
)
var next = state
next.messages.append(msg)
state = next
resetTimeout()
}
func setImageUploadState(inProgress: Bool, message: String? = nil) {
var next = state
next.isUploadingImage = inProgress
next.imageUploadMessage = message
state = next
}
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> ImageUploadResponse {
let result = try await api.uploadImage(data: data, fileName: fileName, mimeType: mimeType)
return result.response
}
// MARK: - Feedback / Partner
func loadFeedback() async {
do {
let response = try await api.feedback()
var next = state
next.feedbackItems = response.items
next.feedbackMessage = nil
state = next
} catch {
var next = state
next.feedbackMessage = error.localizedDescription
state = next
}
}
func submitFeedback(comment: String) async {
let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let profile = state.savedProfile
do {
try await api.submitFeedback(
FeedbackRequest(
name: profile.nickname,
age: profile.age,
country: profile.country,
gender: profile.gender,
comment: trimmed
)
)
var next = state
next.feedbackMessage = "Feedback saved"
state = next
await loadFeedback()
} catch {
var next = state
next.feedbackMessage = error.localizedDescription
state = next
}
}
func loadFeedbackAdminStatus() async {
do {
let response = try await api.feedbackAdminStatus()
var next = state
next.feedbackAdminAuthenticated = response.authenticated
next.feedbackAdminUserName = response.username
next.feedbackAdminError = nil
state = next
} catch {
var next = state
next.feedbackAdminAuthenticated = false
next.feedbackAdminUserName = nil
state = next
}
}
func loginFeedbackAdmin(username: String, password: String) async {
do {
let response = try await api.feedbackAdminLogin(FeedbackAdminLoginRequest(username: username, password: password))
var next = state
next.feedbackAdminAuthenticated = true
next.feedbackAdminUserName = response.username
next.feedbackAdminError = nil
state = next
await loadFeedback()
} catch {
var next = state
next.feedbackAdminError = error.localizedDescription
state = next
}
}
func logoutFeedbackAdmin() async {
try? await api.feedbackAdminLogout()
var next = state
next.feedbackAdminAuthenticated = false
next.feedbackAdminUserName = nil
next.feedbackAdminError = nil
state = next
}
func deleteFeedback(id: String) async {
do {
try await api.deleteFeedback(id: id)
await loadFeedback()
} catch {
var next = state
next.feedbackAdminError = error.localizedDescription
state = next
}
}
func loadPartners() async {
do {
let links = try await api.partners()
var next = state
next.partnerLinks = links
next.partnersError = nil
state = next
} catch {
var next = state
next.partnersError = error.localizedDescription
state = next
}
}
// MARK: - Socket actions
func search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
socket.userSearch(nameIncludes: nameIncludes, minAge: minAge, maxAge: maxAge, countries: countries, genders: genders)
resetTimeout()
}
func requestInbox() {
socket.requestOpenConversations()
resetTimeout()
}
func requestHistory() {
socket.requestHistory()
resetTimeout()
}
func blockUser(userName: String) {
socket.blockUser(userName: userName)
resetTimeout()
}
func unblockUser(userName: String) {
socket.unblockUser(userName: userName)
resetTimeout()
}
// MARK: - Private
private func clearCookies() {
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
}
private func startTimeoutTicker() {
guard !timeoutTickerStarted else { return }
timeoutTickerStarted = true
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.runTimeoutTick()
}
.store(in: &cancellables)
}
private func runTimeoutTick() {
guard state.isLoggedIn else { return }
let nextSec = max(0, state.remainingSecondsToTimeout - 1)
var next = state
next.remainingSecondsToTimeout = nextSec
state = next
if nextSec == 0 {
Task { await self.logout() }
}
}
private func resetTimeout() {
guard state.isLoggedIn || state.currentUser != nil else { return }
var next = state
next.remainingSecondsToTimeout = 1800
state = next
}
private func reduce(_ event: SocketEvent) {
state = Self.apply(state: state, event: event, socket: socket)
}
private static func apply(state: ChatState, event: SocketEvent, socket: SocketClient) -> ChatState {
var current = state
switch event {
case .connectionChanged(let connected, _):
current.isConnected = connected
case .connected(let sessionId, let loggedIn, let user):
if let sid = sessionId {
socket.setSessionId(sid)
}
current.expressSessionId = sessionId ?? current.expressSessionId
current.isLoggedIn = loggedIn || current.isLoggedIn
current.currentUser = user ?? current.currentUser
current.errorMessage = nil
if current.isLoggedIn {
current.remainingSecondsToTimeout = 1800
}
case .loginSuccess(let sessionId, let user):
current.expressSessionId = sessionId ?? current.expressSessionId
current.isLoggedIn = true
current.currentUser = user
current.errorMessage = nil
current.remainingSecondsToTimeout = 1800
case .userList(let users):
current.users = users
case .incomingMessage(let message):
let active = current.currentConversation == message.from
if active {
current.messages.append(message)
}
if !active {
current.unreadChatsCount += 1
}
current.remainingSecondsToTimeout = 1800
case .messageSent:
break
case .conversation(let withUserName, let messages):
current.currentConversation = withUserName
current.messages = messages
current.unreadChatsCount = max(0, current.unreadChatsCount - 1)
current.remainingSecondsToTimeout = 1800
case .searchResults(let results):
current.searchResults = results
case .historyResults(let results):
current.historyResults = results
case .inboxResults(let results):
current.inboxResults = results
case .unreadChats(let count):
current.unreadChatsCount = count
case .userBlocked(let userName):
current.errorMessage = "\(userName) blocked"
case .userUnblocked(let userName):
current.errorMessage = "\(userName) unblocked"
case .commandResult(let lines, let kind):
current.commandLines = lines
current.commandKind = kind
current.commandTable = nil
current.awaitingLoginUsername = kind == "loginPromptUsername"
current.awaitingLoginPassword = kind == "loginPromptPassword"
if kind == "info" || kind.hasPrefix("login") {
current.errorMessage = lines.joined(separator: " | ")
}
case .commandTable(let title, let columns, let rows):
current.commandTable = CommandTableState(title: title, columns: columns, rows: rows)
case .error(let message):
current.errorMessage = message
}
return current
}
}

View File

@@ -0,0 +1,105 @@
import Foundation
// MARK: - Session / User (RestApi.kt / Models.kt)
struct UserDto: Codable, Equatable, Sendable {
var sessionId: String?
var userName: String = ""
var gender: String = ""
var age: Int = 0
var country: String = ""
var isoCountryCode: String = ""
}
struct SessionResponse: Codable, Equatable, Sendable {
var loggedIn: Bool = false
var sessionId: String?
var user: UserDto?
}
struct LogoutResponse: Codable, Equatable, Sendable {
var success: Bool = false
}
// MARK: - Chat / Listen (Socket / Models.kt)
struct ChatMessageDto: Codable, Equatable, Sendable {
var from: String = ""
var to: String?
var message: String = ""
var messageId: String?
var timestamp: String = ""
var read: Bool = false
var isImage: Bool = false
var imageType: String?
var imageUrl: String?
var imageCode: String?
}
struct HistoryItemDto: Codable, Equatable, Sendable {
var userName: String = ""
var lastMessage: ChatMessageDto?
}
struct InboxItemDto: Codable, Equatable, Sendable {
var userName: String = ""
var unreadCount: Int = 0
}
// MARK: - REST (Models.kt)
struct CountryOption: Equatable, Sendable {
var englishName: String
var displayName: String
var isoCode: String
}
struct FeedbackItemDto: Codable, Equatable, Sendable {
var id: String = ""
var name: String?
var age: Int?
var country: String?
var gender: String?
var comment: String = ""
var createdAt: String = ""
}
struct FeedbackResponse: Codable, Equatable, Sendable {
var items: [FeedbackItemDto] = []
var admin: Bool = false
}
struct FeedbackAdminStatusResponse: Codable, Equatable, Sendable {
var authenticated: Bool = false
var username: String?
}
struct FeedbackRequest: Codable, Equatable, Sendable {
var name: String = ""
var age: Int?
var country: String = ""
var gender: String = ""
var comment: String = ""
}
struct FeedbackAdminLoginRequest: Codable, Equatable, Sendable {
var username: String = ""
var password: String = ""
}
struct PartnerLinkDto: Codable, Equatable, Sendable {
var pageName: String = ""
var url: String = ""
enum CodingKeys: String, CodingKey {
case pageName = "Page Name"
case url
}
}
struct ImageUploadResponse: Codable, Equatable, Sendable {
var success: Bool = false
var code: String?
var url: String?
var error: String?
}

View File

@@ -0,0 +1,182 @@
import Foundation
enum RestAPIError: Error, LocalizedError {
case invalidURL(String)
case badStatus(Int, String?)
case decoding(Error)
var errorDescription: String? {
switch self {
case .invalidURL(let s): return "Ungültige URL: \(s)"
case .badStatus(let code, let body): return "HTTP \(code): \(body ?? "")"
case .decoding(let e): return "JSON: \(e.localizedDescription)"
}
}
}
/// REST-Schicht analog `RestApi.kt`.
final class RestAPIClient: @unchecked Sendable {
private let baseURLString: String
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(baseURLString: String, session: URLSession) {
self.baseURLString = baseURLString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
self.session = session
self.decoder = JSONDecoder()
self.encoder = JSONEncoder()
}
func sessionStatus() async throws -> SessionResponse {
try await request(path: "api/session", method: "GET", body: nil)
}
func logout() async throws -> LogoutResponse {
let url = try url(for: "api/logout")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw RestAPIError.badStatus(-1, nil)
}
guard (200 ... 299).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8)
throw RestAPIError.badStatus(http.statusCode, text)
}
if data.isEmpty {
return LogoutResponse(success: true)
}
do {
return try decoder.decode(LogoutResponse.self, from: data)
} catch {
throw RestAPIError.decoding(error)
}
}
func countries() async throws -> [String: String] {
try await request(path: "api/countries", method: "GET", body: nil)
}
func feedback() async throws -> FeedbackResponse {
try await request(path: "api/feedback", method: "GET", body: nil)
}
func feedbackAdminStatus() async throws -> FeedbackAdminStatusResponse {
try await request(path: "api/feedback/admin-status", method: "GET", body: nil)
}
func submitFeedback(_ requestBody: FeedbackRequest) async throws {
let data = try encoder.encode(requestBody)
try await requestVoid(path: "api/feedback", method: "POST", body: data)
}
func feedbackAdminLogin(_ requestBody: FeedbackAdminLoginRequest) async throws -> FeedbackAdminStatusResponse {
let data = try encoder.encode(requestBody)
return try await request(path: "api/feedback/admin-login", method: "POST", body: data)
}
func feedbackAdminLogout() async throws {
try await requestVoid(path: "api/feedback/admin-logout", method: "POST", body: Data())
}
func deleteFeedback(id: String) async throws {
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
try await requestVoid(path: "api/feedback/\(encoded)", method: "DELETE", body: nil)
}
func partners() async throws -> [PartnerLinkDto] {
try await request(path: "api/partners", method: "GET", body: nil)
}
/// Multipart-Feld `image` wie OkHttp `MultipartBody.Part`.
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> (response: ImageUploadResponse, httpStatus: Int) {
let url = try url(for: "api/upload-image")
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"image\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!
)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = body
let (respData, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw RestAPIError.badStatus(-1, nil)
}
guard (200 ... 299).contains(http.statusCode) else {
let text = String(data: respData, encoding: .utf8)
throw RestAPIError.badStatus(http.statusCode, text)
}
do {
let decoded = try decoder.decode(ImageUploadResponse.self, from: respData)
return (decoded, http.statusCode)
} catch {
throw RestAPIError.decoding(error)
}
}
// MARK: - Request
private func url(for path: String) throws -> URL {
let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: "\(baseURLString)/\(trimmed)") else {
throw RestAPIError.invalidURL("\(baseURLString)/\(trimmed)")
}
return url
}
private func requestVoid(path: String, method: String, body: Data?) async throws {
let url = try url(for: path)
var req = URLRequest(url: url)
req.httpMethod = method
req.httpBody = body
if let body, !body.isEmpty {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw RestAPIError.badStatus(-1, nil)
}
guard (200 ... 299).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8)
throw RestAPIError.badStatus(http.statusCode, text)
}
}
private func request<T: Decodable>(path: String, method: String, body: Data?) async throws -> T {
let url = try url(for: path)
var req = URLRequest(url: url)
req.httpMethod = method
req.httpBody = body
if let body, !body.isEmpty {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw RestAPIError.badStatus(-1, nil)
}
guard (200 ... 299).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8)
throw RestAPIError.badStatus(http.statusCode, text)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw RestAPIError.decoding(error)
}
}
}

View File

@@ -0,0 +1,348 @@
import Combine
import Foundation
import SocketIO
/// Entspricht `SocketClient.kt`: gleiche Events, `setSessionId`, `login`, Emits.
final class SocketClient: @unchecked Sendable {
private let baseURL: String
private var manager: SocketManager?
private var socket: SocketIOClient?
private var pendingExpressSessionId: String?
private let eventSubject = PassthroughSubject<SocketEvent, Never>()
/// Ereignisse vom Server (analog `SharedFlow<SocketEvent>`).
var eventPublisher: AnyPublisher<SocketEvent, Never> {
eventSubject.eraseToAnyPublisher()
}
var isConnected: Bool {
socket?.status == .connected
}
init(baseURL: String) {
self.baseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
func connect() {
disconnect()
guard let url = URL(string: baseURL) else {
notify(.error("Ungültige Socket-Basis-URL"))
return
}
var config: SocketIOClientConfiguration = [
.log(false),
.compress,
.reconnects(true),
.reconnectAttempts(-1),
.reconnectWait(1),
.reconnectWaitMax(30),
.version(.three),
]
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
config.insert(.cookies(cookies))
}
let manager = SocketManager(socketURL: url, config: config)
self.manager = manager
let socket = manager.defaultSocket
self.socket = socket
socket.on(clientEvent: .connect) { [weak self] _, _ in
self?.handleSocketConnect()
}
socket.on(clientEvent: .disconnect) { [weak self] data, _ in
let reason = data.first.map { String(describing: $0) }
self?.notify(.connectionChanged(connected: false, reason: reason))
}
socket.on(clientEvent: .error) { [weak self] data, _ in
let msg = data.first.map { String(describing: $0) } ?? ""
self?.notify(.error("Socket-Verbindung fehlgeschlagen: \(msg)"))
}
registerServerEvents(socket)
manager.connect()
}
func disconnect() {
socket?.removeAllHandlers()
manager?.disconnect()
socket = nil
manager = nil
}
func setSessionId(_ expressSessionId: String) {
pendingExpressSessionId = expressSessionId
guard isConnected else { return }
socket?.emit("setSessionId", ["expressSessionId": expressSessionId])
}
func login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) {
var payload: [String: Any] = [
"userName": userName,
"gender": gender,
"age": age,
"country": country,
]
if let expressSessionId {
payload["expressSessionId"] = expressSessionId
} else {
payload["expressSessionId"] = NSNull()
}
socket?.emit("login", payload)
}
func sendMessage(toUserName: String?, message: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
var payload: [String: Any] = [
"message": message.trimmingCharacters(in: .whitespacesAndNewlines),
"messageId": messageId,
]
if let toUserName, !toUserName.isEmpty {
payload["toUserName"] = toUserName
}
socket?.emit("message", payload)
}
func sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
socket?.emit(
"message",
[
"toUserName": toUserName,
"message": imageCode,
"messageId": messageId,
"isImage": true,
"imageUrl": imageUrl,
]
)
}
func requestConversation(withUserName: String) {
socket?.emit("requestConversation", ["withUserName": withUserName])
}
func userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
var payload: [String: Any] = [
"countries": countries,
"genders": genders,
]
payload["nameIncludes"] = nameIncludes ?? NSNull()
payload["minAge"] = minAge.map { $0 as Any } ?? NSNull()
payload["maxAge"] = maxAge.map { $0 as Any } ?? NSNull()
socket?.emit("userSearch", payload)
}
func requestHistory() {
socket?.emit("requestHistory")
}
func requestOpenConversations() {
socket?.emit("requestOpenConversations")
}
func blockUser(userName: String) {
socket?.emit("blockUser", ["userName": userName])
}
func unblockUser(userName: String) {
socket?.emit("unblockUser", ["userName": userName])
}
// MARK: - Private
private func handleSocketConnect() {
if let sid = pendingExpressSessionId {
socket?.emit("setSessionId", ["expressSessionId": sid])
}
// Reihenfolge wie `SocketClient.kt` (EVENT_CONNECT): zuerst setSessionId, dann ConnectionChanged.
notify(.connectionChanged(connected: true, reason: nil))
}
private func notify(_ event: SocketEvent) {
eventSubject.send(event)
}
private func registerServerEvents(_ socket: SocketIOClient) {
socket.on("connected") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(
.connected(
sessionId: json.stringOrNil("sessionId"),
loggedIn: json.bool("loggedIn", default: false),
user: json.userObject("user")
)
)
}
socket.on("loginSuccess") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.loginSuccess(sessionId: json.stringOrNil("sessionId"), user: json.userObject("user")))
}
socket.on("userList") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data),
let users = json["users"] as? [[String: Any]]
else { return }
self?.notify(.userList(users: users.map { UserDto(json: $0) }))
}
socket.on("message") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.incomingMessage(ChatMessageDto(json: json)))
}
socket.on("messageSent") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.messageSent(messageId: json.stringOrNil("messageId"), to: json.stringOrNil("to")))
}
socket.on("conversation") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
let withName = json.string("with")
let rawMessages = json["messages"] as? [[String: Any]] ?? []
let messages = rawMessages.map { ChatMessageDto(json: $0) }
self?.notify(.conversation(withUserName: withName, messages: messages))
}
socket.on("searchResults") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data),
let results = json["results"] as? [[String: Any]]
else { return }
self?.notify(.searchResults(results.map { UserDto(json: $0) }))
}
socket.on("historyResults") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data),
let results = json["results"] as? [[String: Any]]
else { return }
self?.notify(.historyResults(results.map { HistoryItemDto(json: $0) }))
}
socket.on("inboxResults") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data),
let results = json["results"] as? [[String: Any]]
else { return }
self?.notify(.inboxResults(results.map { InboxItemDto(json: $0) }))
}
socket.on("unreadChats") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.unreadChats(count: json.int("count", default: 0)))
}
socket.on("userBlocked") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.userBlocked(json.string("userName")))
}
socket.on("userUnblocked") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
self?.notify(.userUnblocked(json.string("userName")))
}
socket.on("commandResult") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
let lines = (json["lines"] as? [Any])?.map { String(describing: $0) } ?? []
let kind = json.string("kind", default: "info")
self?.notify(.commandResult(lines: lines, kind: kind))
}
socket.on("commandTable") { [weak self] data, _ in
guard let json = Self.firstDictionary(from: data) else { return }
let title = json.string("title", default: "Ausgabe")
let columns = (json["columns"] as? [Any])?.map { String(describing: $0) } ?? []
let rows = Self.nestedStringRows(json["rows"])
self?.notify(.commandTable(title: title, columns: columns, rows: rows))
}
socket.on("error") { [weak self] data, _ in
let json = Self.firstDictionary(from: data)
let message =
json?.stringOrNil("message")
?? data.first.map { String(describing: $0) }
?? "Unbekannter Socket-Fehler"
self?.notify(.error(message))
}
}
private static func firstDictionary(from data: [Any]) -> [String: Any]? {
guard let first = data.first else { return nil }
if let d = first as? [String: Any] { return d }
if let n = first as? NSDictionary { return n as? [String: Any] }
return nil
}
private static func nestedStringRows(_ any: Any?) -> [[String]] {
guard let outer = any as? [Any] else { return [] }
return outer.map { row in
(row as? [Any])?.map { String(describing: $0) } ?? []
}
}
}
// MARK: - JSON helpers (JSONObject-Äquivalent)
private extension Dictionary where Key == String, Value == Any {
func stringOrNil(_ key: String) -> String? {
guard let v = self[key], !(v is NSNull) else { return nil }
if let s = v as? String { return s }
return String(describing: v)
}
func string(_ key: String, default def: String = "") -> String {
stringOrNil(key) ?? def
}
func bool(_ key: String, default def: Bool) -> Bool {
guard let v = self[key], !(v is NSNull) else { return def }
if let b = v as? Bool { return b }
if let n = v as? NSNumber { return n.boolValue }
return def
}
func int(_ key: String, default def: Int) -> Int {
guard let v = self[key], !(v is NSNull) else { return def }
if let i = v as? Int { return i }
if let n = v as? NSNumber { return n.intValue }
return def
}
func userObject(_ key: String) -> UserDto? {
guard let nested = self[key] as? [String: Any] else { return nil }
return UserDto(json: nested)
}
}
private extension UserDto {
init(json: [String: Any]) {
self.init(
sessionId: json.stringOrNil("sessionId"),
userName: json.string("userName"),
gender: json.string("gender"),
age: json.int("age", default: 0),
country: json.string("country"),
isoCountryCode: json.string("isoCountryCode")
)
}
}
private extension ChatMessageDto {
init(json: [String: Any]) {
self.init(
from: json.string("from"),
to: json.stringOrNil("to"),
message: json.string("message"),
messageId: json.stringOrNil("messageId"),
timestamp: json.string("timestamp"),
read: json.bool("read", default: false),
isImage: json.bool("isImage", default: false),
imageType: json.stringOrNil("imageType"),
imageUrl: json.stringOrNil("imageUrl"),
imageCode: json.stringOrNil("imageCode")
)
}
}
private extension HistoryItemDto {
init(json: [String: Any]) {
let last: ChatMessageDto? = {
guard let nested = json["lastMessage"] as? [String: Any] else { return nil }
return ChatMessageDto(json: nested)
}()
self.init(userName: json.string("userName"), lastMessage: last)
}
}
private extension InboxItemDto {
init(json: [String: Any]) {
self.init(userName: json.string("userName"), unreadCount: json.int("unreadCount", default: 0))
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
/// Entspricht `SocketEvent.kt` (Android).
enum SocketEvent: Sendable {
case connected(sessionId: String?, loggedIn: Bool, user: UserDto?)
case loginSuccess(sessionId: String?, user: UserDto?)
case userList(users: [UserDto])
case incomingMessage(ChatMessageDto)
case messageSent(messageId: String?, to: String?)
case conversation(withUserName: String, messages: [ChatMessageDto])
case searchResults([UserDto])
case historyResults([HistoryItemDto])
case inboxResults([InboxItemDto])
case unreadChats(count: Int)
case userBlocked(String)
case userUnblocked(String)
case commandResult(lines: [String], kind: String)
case commandTable(title: String, columns: [String], rows: [[String]])
case error(String)
case connectionChanged(connected: Bool, reason: String?)
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.435",
"red" : "0.184"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BASE_URL</key>
<string>$(BASE_URL)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>YPChat</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Bilder aus deiner Mediathek kannst du im Chat versenden.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,99 @@
"app_name" = "YPChat";
"landing_eyebrow" = "SingleChat";
"landing_title" = "Direkt in den Chat";
"landing_copy" = "Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.";
"feature_worldwide_chat" = "Weltweiter Chat";
"feature_image_exchange" = "Bildaustausch";
"feature_compact_controls" = "Kompakte Bedienung";
"profile_title" = "Profil starten";
"profile_copy" = "Wenige Angaben genügen für den Einstieg.";
"label_nick" = "Bitte gib deinen Nicknamen für den Chat ein";
"label_gender" = "Geschlecht";
"label_age" = "Alter";
"label_country" = "Land";
"button_start_chat" = "Chat starten";
"gender_female" = "Weiblich";
"gender_male" = "Männlich";
"gender_pair" = "Paar";
"gender_trans_mf" = "Transgender (M->F)";
"gender_trans_fm" = "Transgender (F->M)";
"socket_connected" = "Socket verbunden";
"socket_connecting" = "Socket wird verbunden...";
"status_online" = "online";
"status_connecting" = "verbindet...";
"tab_online" = "Online";
"tab_search" = "Suche";
"tab_inbox" = "Posteingang";
"tab_history" = "Verlauf";
"tab_console" = "Konsole";
"tab_more" = "Mehr";
"logout" = "Verlassen";
"timeout_in" = "Timeout in %@";
"no_users_online" = "Noch keine anderen Nutzer online.";
"search_username_includes" = "Benutzername enthält";
"search_from_age" = "Von Alter";
"search_to_age" = "Bis Alter";
"search_all" = "Alle";
"search_button" = "Suchen";
"search_no_results" = "Keine Ergebnisse.";
"search_min_age_error" = "Das Mindestalter darf nicht größer als das Höchstalter sein.";
"inbox_empty" = "Keine ungelesenen Chats.";
"inbox_new_count" = "%d neu";
"history_empty" = "Noch kein Verlauf.";
"no_message" = "Keine Nachricht";
"back" = "Zurück";
"block" = "Blockieren";
"unblock" = "Entsperren";
"message_placeholder" = "Nachricht";
"button_image" = "Bild";
"button_send" = "Senden";
"button_smileys" = "Smileys";
"image_message" = "Bildnachricht";
"image_upload_in_progress" = "Bild wird hochgeladen...";
"image_upload_success" = "Bild wurde hochgeladen.";
"image_upload_failed" = "Bild-Upload fehlgeschlagen.";
"image_upload_too_large" = "Das Bild ist größer als 5 MB.";
"image_upload_open_failed" = "Das Bild konnte nicht geöffnet werden.";
"feedback_created_at" = "Eingegangen %@";
"feedback_meta_separator" = " • ";
"countries_load_error" = "Länderliste konnte nicht geladen werden: %@";
"user_blocked" = "%@ wurde blockiert";
"user_unblocked" = "%@ wurde entsperrt";
"feedback_title" = "Feedback";
"feedback_comment" = "Kommentar";
"feedback_send" = "Feedback senden";
"feedback_saved" = "Feedback wurde gespeichert.";
"feedback_empty" = "Noch kein Feedback vorhanden.";
"anonymous" = "Anonym";
"feedback_admin_user" = "Admin-Benutzer";
"feedback_admin_password" = "Passwort";
"feedback_admin_login" = "Admin-Login";
"feedback_admin_logout" = "Admin abmelden";
"feedback_delete" = "Löschen";
"console_title" = "Konsole";
"console_placeholder" = "/Befehl oder Admin-Login-Eingabe senden";
"console_send" = "Senden";
"console_empty" = "Noch keine Konsolen-Ausgabe.";
"more_title" = "Mehr";
"more_feedback" = "Feedback";
"more_partners" = "Partner";
"more_faq" = "FAQ";
"more_rules" = "Regeln";
"more_safety" = "Sicherheit";
"more_imprint" = "Impressum";
"more_back" = "Zur Übersicht";
"partners_intro" = "Empfehlungen und befreundete Projekte für unsere Community.";
"faq_intro" = "Antworten auf häufige Fragen zum Chat.";
"rules_intro" = "Grundregeln für respektvollen Chat.";
"safety_intro" = "Tipps für Privatsphäre und sichere Nutzung.";
"imprint_intro" = "Rechtliche Hinweise und Kontaktdaten.";
"external_link" = "Externer Link";
"faq_title" = "Häufige Fragen";
"rules_title" = "Chat-Regeln";
"safety_title" = "Sicherheit und Privatsphäre";
"imprint_title" = "Impressum";
"partners_title" = "Partner";
"faq_body" = "Wähle einen Nicknamen, gib deine Profildaten an und starte den Chat. Teile keine sensiblen Daten wie Telefonnummern, Adressen, Passwörter oder Zahlungsinformationen. Du kannst Bilder senden, Benutzer blockieren und Feedback für ernste Vorfälle nutzen.";
"rules_body" = "Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer.";
"safety_body" = "Nutze einen Nicknamen, der dich nicht identifiziert. Teile keine privaten Kontakt- oder Zahlungsdaten. Sei vorsichtig mit Links von Unbekannten und beende Gespräche, die sich falsch anfühlen. Nutze Blockieren und Feedback bei schweren Vorfällen.";
"imprint_body" = "Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.";

View File

@@ -0,0 +1,99 @@
"app_name" = "YPChat";
"landing_eyebrow" = "SingleChat";
"landing_title" = "Directly into chat";
"landing_copy" = "Compact, fast and without detours. Create your profile and start a conversation right away.";
"feature_worldwide_chat" = "Worldwide chat";
"feature_image_exchange" = "Image exchange";
"feature_compact_controls" = "Compact controls";
"profile_title" = "Start profile";
"profile_copy" = "A few details are enough to get started.";
"label_nick" = "Please enter your chat nickname";
"label_gender" = "Gender";
"label_age" = "Age";
"label_country" = "Country";
"button_start_chat" = "Start chat";
"gender_female" = "Female";
"gender_male" = "Male";
"gender_pair" = "Couple";
"gender_trans_mf" = "Transgender (M->F)";
"gender_trans_fm" = "Transgender (F->M)";
"socket_connected" = "Socket connected";
"socket_connecting" = "Connecting socket...";
"status_online" = "online";
"status_connecting" = "connecting...";
"tab_online" = "Online";
"tab_search" = "Search";
"tab_inbox" = "Inbox";
"tab_history" = "History";
"tab_console" = "Console";
"tab_more" = "More";
"logout" = "Logout";
"timeout_in" = "Timeout in %@";
"no_users_online" = "No other users online yet.";
"search_username_includes" = "Username contains";
"search_from_age" = "From age";
"search_to_age" = "To age";
"search_all" = "All";
"search_button" = "Search";
"search_no_results" = "No results.";
"search_min_age_error" = "Minimum age must not be greater than maximum age.";
"inbox_empty" = "No unread chats.";
"inbox_new_count" = "%d new";
"history_empty" = "No history yet.";
"no_message" = "No message";
"back" = "Back";
"block" = "Block";
"unblock" = "Unblock";
"message_placeholder" = "Message";
"button_image" = "Image";
"button_send" = "Send";
"button_smileys" = "Smileys";
"image_message" = "Image message";
"image_upload_in_progress" = "Uploading image...";
"image_upload_success" = "Image uploaded.";
"image_upload_failed" = "Image upload failed.";
"image_upload_too_large" = "Image is larger than 5 MB.";
"image_upload_open_failed" = "Image could not be opened.";
"feedback_created_at" = "Received %@";
"feedback_meta_separator" = " • ";
"countries_load_error" = "Country list could not be loaded: %@";
"user_blocked" = "%@ has been blocked";
"user_unblocked" = "%@ has been unblocked";
"feedback_title" = "Feedback";
"feedback_comment" = "Comment";
"feedback_send" = "Send feedback";
"feedback_saved" = "Feedback saved.";
"feedback_empty" = "No feedback yet.";
"anonymous" = "Anonymous";
"feedback_admin_user" = "Admin user";
"feedback_admin_password" = "Password";
"feedback_admin_login" = "Admin login";
"feedback_admin_logout" = "Logout admin";
"feedback_delete" = "Delete";
"console_title" = "Console";
"console_placeholder" = "Enter /command or admin login input";
"console_send" = "Send";
"console_empty" = "No console output yet.";
"more_title" = "More";
"more_feedback" = "Feedback";
"more_partners" = "Partners";
"more_faq" = "FAQ";
"more_rules" = "Rules";
"more_safety" = "Safety";
"more_imprint" = "Imprint";
"more_back" = "Back to overview";
"partners_intro" = "Recommended and friendly projects for our community.";
"faq_intro" = "Answers to common questions about the chat.";
"rules_intro" = "Basic rules for respectful chatting.";
"safety_intro" = "Tips for privacy and safer usage.";
"imprint_intro" = "Legal notice and contact details.";
"external_link" = "External link";
"faq_title" = "Frequently Asked Questions";
"rules_title" = "Chat Rules";
"safety_title" = "Safety and Privacy";
"imprint_title" = "Imprint";
"partners_title" = "Partners";
"faq_body" = "Choose a nickname, enter your profile details and start chatting. Do not share sensitive data like phone numbers, addresses, passwords or payment information. You can send images, block users and use feedback for serious issues.";
"rules_body" = "No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others.";
"safety_body" = "Use a nickname that does not identify you. Do not share private contact or payment data. Be careful with links from strangers and end conversations that feel wrong. Use block and feedback for serious incidents.";
"imprint_body" = "Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.";

View File

@@ -0,0 +1,121 @@
import Foundation
import SwiftUI
/// Schlüssel wie Android `strings.xml` Auflösung über `Localizable.strings` (de/en).
enum L10n {
static func tr(_ key: String) -> String {
String(localized: String.LocalizationValue(key))
}
static func timeoutIn(_ time: String) -> String {
String(format: tr("timeout_in"), time)
}
static func inboxNew(_ count: Int) -> String {
String(format: tr("inbox_new_count"), locale: .current, count)
}
static func feedbackCreatedAt(_ date: String) -> String {
String(format: tr("feedback_created_at"), locale: .current, date)
}
static func countriesLoadError(_ detail: String) -> String {
String(format: tr("countries_load_error"), locale: .current, detail)
}
static func userBlocked(_ name: String) -> String {
String(format: tr("user_blocked"), locale: .current, name)
}
static func userUnblocked(_ name: String) -> String {
String(format: tr("user_unblocked"), locale: .current, name)
}
}
func formatTimeout(totalSeconds: Int) -> String {
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
func localizeRuntimeMessage(_ message: String) -> String {
if message.hasPrefix("Country list could not be loaded:") {
let detail = String(message.dropFirst("Country list could not be loaded:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
return L10n.countriesLoadError(detail)
}
switch message {
case "Image uploaded": return L10n.tr("image_upload_success")
case "Image upload failed": return L10n.tr("image_upload_failed")
case "Image exceeds 5 MB": return L10n.tr("image_upload_too_large")
case "Image could not be opened": return L10n.tr("image_upload_open_failed")
case "Feedback saved": return L10n.tr("feedback_saved")
default:
break
}
if message.hasSuffix(" blocked") {
let name = String(message.dropLast(" blocked".count))
return L10n.userBlocked(name)
}
if message.hasSuffix(" unblocked") {
let name = String(message.dropLast(" unblocked".count))
return L10n.userUnblocked(name)
}
return message
}
func formatFeedbackTimestamp(_ createdAt: String) -> String? {
guard !createdAt.isEmpty else { return nil }
return ISO8601DateFormatter().date(from: createdAt).map {
DateFormatter.localizedString(from: $0, dateStyle: .medium, timeStyle: .short)
}
}
func smileyEmoji(hexCode: String) -> String {
guard let v = UInt32(hexCode, radix: 16), let scalar = UnicodeScalar(v) else { return "" }
return String(Character(scalar))
}
struct SmileyItem: Identifiable {
var id: String { token }
let token: String
let hexCode: String
}
let ypChatSmileys: [SmileyItem] = [
SmileyItem(token: ":)", hexCode: "1F642"),
SmileyItem(token: ":D", hexCode: "1F600"),
SmileyItem(token: ":(", hexCode: "1F641"),
SmileyItem(token: ";)", hexCode: "1F609"),
SmileyItem(token: ":p", hexCode: "1F60B"),
SmileyItem(token: ";p", hexCode: "1F61C"),
SmileyItem(token: "O)", hexCode: "1F607"),
SmileyItem(token: ":*", hexCode: "1F617"),
SmileyItem(token: "(h)", hexCode: "1FA77"),
SmileyItem(token: "xD", hexCode: "1F602"),
SmileyItem(token: ":@", hexCode: "1F635"),
SmileyItem(token: ":O", hexCode: "1F632"),
SmileyItem(token: ":3", hexCode: "1F63A"),
SmileyItem(token: ":|", hexCode: "1F610"),
SmileyItem(token: ":/", hexCode: "1FAE4"),
SmileyItem(token: ":#", hexCode: "1F912"),
SmileyItem(token: "#)", hexCode: "1F973"),
SmileyItem(token: "%)", hexCode: "1F974"),
SmileyItem(token: "(t)", hexCode: "1F44D"),
SmileyItem(token: ":'(", hexCode: "1F622"),
]
struct GenderOptionRow: Identifiable {
var id: String { value }
let value: String
let label: String
}
func displayCountryName(user: UserDto, countries: [CountryOption]) -> String {
if let byEnglish = countries.first(where: { $0.englishName == user.country }) {
return byEnglish.displayName
}
if let byIso = countries.first(where: { $0.isoCode.caseInsensitiveCompare(user.isoCountryCode) == .orderedSame }) {
return byIso.displayName
}
return user.country
}

View File

@@ -0,0 +1,595 @@
import PhotosUI
import SwiftUI
// MARK: - Inbox / History
struct InboxTabView: 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_inbox"))
.font(.title2.weight(.bold))
.padding(.horizontal, 16)
if chat.inboxResults.isEmpty {
Text(L10n.tr("inbox_empty"))
.foregroundStyle(YpChatTheme.textMuted)
.padding(.horizontal, 16)
}
ForEach(chat.inboxResults, id: \.userName) { item in
Button {
repo.openConversation(userName: item.userName)
} label: {
HStack {
Text(item.userName).fontWeight(.semibold)
Spacer()
Text(L10n.inboxNew(item.unreadCount))
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
}
}
.padding(.vertical, 16)
}
}
}
struct HistoryTabView: 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_history"))
.font(.title2.weight(.bold))
.padding(.horizontal, 16)
if chat.historyResults.isEmpty {
Text(L10n.tr("history_empty"))
.foregroundStyle(YpChatTheme.textMuted)
.padding(.horizontal, 16)
}
ForEach(chat.historyResults, id: \.userName) { item in
Button {
repo.openConversation(userName: item.userName)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(item.userName).fontWeight(.semibold)
Text(item.lastMessage?.message ?? L10n.tr("no_message"))
.font(.subheadline)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
}
}
.padding(.vertical, 16)
}
}
}
// MARK: - Console
struct ConsoleTabView: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var input = ""
private var placeholder: String {
if chat.awaitingLoginUsername { return L10n.tr("feedback_admin_user") }
if chat.awaitingLoginPassword { return L10n.tr("feedback_admin_password") }
return L10n.tr("console_placeholder")
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.tr("console_title"))
.font(.title2.weight(.bold))
TextField(placeholder, text: $input)
.textFieldStyle(.roundedBorder)
Button(L10n.tr("console_send")) {
repo.sendMessage(text: input)
input = ""
}
.buttonStyle(.borderedProminent)
.tint(YpChatTheme.primary600)
.disabled(input.isEmpty)
if chat.commandLines.isEmpty, chat.commandTable == nil {
Text(L10n.tr("console_empty"))
.foregroundStyle(YpChatTheme.textMuted)
}
ForEach(Array(chat.commandLines.enumerated()), id: \.offset) { _, line in
consoleLineCard(line: localizeRuntimeMessage(line), kind: chat.commandKind)
}
if let table = chat.commandTable {
commandTableCard(table)
}
}
.padding(16)
}
}
private func consoleLineCard(line: String, kind: String?) -> some View {
let k = kind ?? ""
let bg: Color = {
if k.hasPrefix("login") { return YpChatTheme.surfaceSoftBlue }
if k == "info" { return YpChatTheme.surfaceSoftGreen }
if k == "error" { return YpChatTheme.surfaceSoftRed }
return YpChatTheme.surfaceSubtle
}()
let border: Color = {
if k.hasPrefix("login") { return Color(red: 0.79, green: 0.86, blue: 0.93) }
if k == "info" { return Color(red: 0.81, green: 0.89, blue: 0.83) }
if k == "error" { return Color(red: 0.94, green: 0.79, blue: 0.79) }
return YpChatTheme.border
}()
let fg = k == "error" ? YpChatTheme.danger : YpChatTheme.textStrong
return Text(line)
.font(.body)
.foregroundStyle(fg)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(bg, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(border, lineWidth: 1))
}
private func commandTableCard(_ table: CommandTableState) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(table.title).fontWeight(.bold).foregroundStyle(YpChatTheme.textStrong)
if !table.columns.isEmpty {
HStack {
ForEach(table.columns, id: \.self) { col in
Text(col).fontWeight(.bold).foregroundStyle(YpChatTheme.primary700)
Spacer(minLength: 8)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(YpChatTheme.primary100, in: RoundedRectangle(cornerRadius: 12))
}
ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in
HStack {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell).foregroundStyle(YpChatTheme.textMuted)
Spacer(minLength: 8)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 12))
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(YpChatTheme.border, lineWidth: 1))
}
}
// MARK: - More
struct MoreTabView: View {
@Binding var section: MoreSection
var body: some View {
Group {
switch section {
case .overview:
moreOverview
case .feedback:
FeedbackDetailView { section = .overview }
case .partners:
PartnersDetailView { section = .overview }
case .faq:
staticContent(title: L10n.tr("faq_title"), body: L10n.tr("faq_body")) { section = .overview }
case .rules:
staticContent(title: L10n.tr("rules_title"), body: L10n.tr("rules_body")) { section = .overview }
case .safety:
staticContent(title: L10n.tr("safety_title"), body: L10n.tr("safety_body")) { section = .overview }
case .imprint:
staticContent(title: L10n.tr("imprint_title"), body: L10n.tr("imprint_body")) { section = .overview }
}
}
}
private var moreOverview: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.tr("more_title"))
.font(.title2.weight(.bold))
moreLink(L10n.tr("more_feedback"), L10n.tr("feedback_comment")) { section = .feedback }
moreLink(L10n.tr("more_partners"), L10n.tr("partners_intro")) { section = .partners }
moreLink(L10n.tr("more_faq"), L10n.tr("faq_intro")) { section = .faq }
moreLink(L10n.tr("more_rules"), L10n.tr("rules_intro")) { section = .rules }
moreLink(L10n.tr("more_safety"), L10n.tr("safety_intro")) { section = .safety }
moreLink(L10n.tr("more_imprint"), L10n.tr("imprint_intro")) { section = .imprint }
}
.padding(16)
}
}
private func moreLink(_ title: String, _ subtitle: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 6) {
Text(title).fontWeight(.semibold).foregroundStyle(YpChatTheme.textStrong)
Text(subtitle).font(.subheadline).foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
private func staticContent(title: String, body: String, onBack: @escaping () -> Void) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button(L10n.tr("more_back"), action: onBack)
.foregroundStyle(YpChatTheme.primary700)
Text(title)
.font(.title2.weight(.bold))
}
Text(body).foregroundStyle(YpChatTheme.textStrong)
}
.padding(16)
}
}
}
struct FeedbackDetailView: View {
@EnvironmentObject private var services: AppServices
var onBack: () -> Void
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var comment = ""
@State private var adminUser = ""
@State private var adminPassword = ""
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button(L10n.tr("more_back"), action: onBack)
.foregroundStyle(YpChatTheme.primary700)
Text(L10n.tr("feedback_title"))
.font(.title2.weight(.bold))
}
TextField(L10n.tr("feedback_comment"), text: $comment, axis: .vertical)
.lineLimit(4 ... 8)
.textFieldStyle(.roundedBorder)
Button(L10n.tr("feedback_send")) {
Task {
await repo.submitFeedback(comment: comment)
comment = ""
}
}
.buttonStyle(.borderedProminent)
.tint(YpChatTheme.primary600)
.disabled(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let fm = chat.feedbackMessage {
Text(localizeRuntimeMessage(fm))
.font(.footnote)
.foregroundStyle(YpChatTheme.textMuted)
}
VStack(alignment: .leading, spacing: 8) {
if chat.feedbackAdminAuthenticated {
Text("\(L10n.tr("feedback_admin_user")): \(chat.feedbackAdminUserName ?? "")")
.fontWeight(.semibold)
Button(L10n.tr("feedback_admin_logout")) {
Task { await repo.logoutFeedbackAdmin() }
}
.buttonStyle(.bordered)
.tint(YpChatTheme.primary500)
} else {
TextField(L10n.tr("feedback_admin_user"), text: $adminUser)
.textFieldStyle(.roundedBorder)
SecureField(L10n.tr("feedback_admin_password"), text: $adminPassword)
.textFieldStyle(.roundedBorder)
Button(L10n.tr("feedback_admin_login")) {
Task {
await repo.loginFeedbackAdmin(username: adminUser, password: adminPassword)
}
}
.buttonStyle(.bordered)
.tint(YpChatTheme.primary500)
}
if let err = chat.feedbackAdminError {
Text(localizeRuntimeMessage(err))
.foregroundStyle(YpChatTheme.danger)
.font(.footnote)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 12))
if chat.feedbackItems.isEmpty {
Text(L10n.tr("feedback_empty"))
.foregroundStyle(YpChatTheme.textMuted)
}
ForEach(Array(chat.feedbackItems.enumerated()), id: \.offset) { _, item in
feedbackRow(item)
}
}
.padding(16)
}
}
private func feedbackRow(_ item: FeedbackItemDto) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.name.flatMap { $0.isEmpty ? nil : $0 } ?? L10n.tr("anonymous"))
.fontWeight(.semibold)
Spacer()
if chat.feedbackAdminAuthenticated {
Button(L10n.tr("feedback_delete"), role: .destructive) {
Task { await repo.deleteFeedback(id: item.id) }
}
.font(.caption)
}
}
Text(item.comment).foregroundStyle(YpChatTheme.textStrong)
let meta = [item.country, item.gender, item.age.map { String($0) }]
.compactMap { $0 }
.filter { !$0.isEmpty }
if !meta.isEmpty {
Text(meta.joined(separator: L10n.tr("feedback_meta_separator")))
.font(.caption)
.foregroundStyle(YpChatTheme.textMuted)
}
if let ts = formatFeedbackTimestamp(item.createdAt) {
Text(L10n.feedbackCreatedAt(ts))
.font(.caption2)
.foregroundStyle(YpChatTheme.textMuted)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
struct PartnersDetailView: View {
@EnvironmentObject private var services: AppServices
var onBack: () -> Void
@Environment(\.openURL) private var openURL
private var chat: ChatState { services.repository.state }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button(L10n.tr("more_back"), action: onBack)
.foregroundStyle(YpChatTheme.primary700)
Text(L10n.tr("partners_title"))
.font(.title2.weight(.bold))
}
Text(L10n.tr("partners_intro"))
.foregroundStyle(YpChatTheme.textMuted)
if let err = chat.partnersError {
Text(localizeRuntimeMessage(err))
.foregroundStyle(YpChatTheme.danger)
}
ForEach(Array(chat.partnerLinks.enumerated()), id: \.offset) { _, link in
Button {
if let u = URL(string: link.url) { openURL(u) }
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(link.pageName).fontWeight(.semibold)
Text(link.url).foregroundStyle(YpChatTheme.primary700)
Text(L10n.tr("external_link"))
.font(.caption)
.foregroundStyle(YpChatTheme.textMuted)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(YpChatTheme.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(16)
}
}
}
// MARK: - Chat
struct YpChatConversationView: View {
@EnvironmentObject private var services: AppServices
private var repo: ChatRepository { services.repository }
private var chat: ChatState { repo.state }
@State private var draft = ""
@State private var showSmileys = false
@State private var photoItem: PhotosPickerItem?
private static let maxImageBytes = 5 * 1024 * 1024
var body: some View {
VStack(spacing: 0) {
HStack {
Button(L10n.tr("back")) { repo.closeConversation() }
Text(chat.currentConversation ?? "")
.fontWeight(.bold)
.frame(maxWidth: .infinity)
Button(L10n.tr("block")) {
if let u = chat.currentConversation { repo.blockUser(userName: u) }
}
Button(L10n.tr("unblock")) {
if let u = chat.currentConversation { repo.unblockUser(userName: u) }
}
}
.padding(12)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(chat.messages.enumerated()), id: \.offset) { _, message in
messageBubble(message)
}
}
.padding(12)
}
if chat.isUploadingImage || chat.imageUploadMessage != nil {
uploadBanner
}
HStack(alignment: .bottom) {
TextField(L10n.tr("message_placeholder"), text: $draft, axis: .vertical)
.lineLimit(1 ... 4)
.textFieldStyle(.roundedBorder)
.disabled(chat.isUploadingImage)
Button {
showSmileys.toggle()
} label: {
Image(systemName: "face.smiling")
}
.disabled(chat.isUploadingImage)
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "photo")
}
.disabled(chat.isUploadingImage)
Button(L10n.tr("button_send")) {
repo.sendMessage(text: draft)
draft = ""
}
.buttonStyle(.borderedProminent)
.tint(YpChatTheme.primary600)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || chat.isUploadingImage)
}
.padding(12)
if showSmileys {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(ypChatSmileys) { sm in
Button {
draft += sm.token
showSmileys = false
} label: {
Text(smileyEmoji(hexCode: sm.hexCode))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(YpChatTheme.primary100, in: Capsule())
.foregroundStyle(YpChatTheme.primary700)
.fontWeight(.bold)
}
}
}
.padding(.horizontal, 12)
}
}
if let err = chat.errorMessage {
Text(localizeRuntimeMessage(err))
.font(.footnote)
.foregroundStyle(YpChatTheme.danger)
.padding(.horizontal, 12)
}
}
.onChange(of: photoItem) { _, new in
Task { await handlePhoto(new) }
}
}
private var uploadBanner: some View {
let msg: String = {
if chat.isUploadingImage { return L10n.tr("image_upload_in_progress") }
return localizeRuntimeMessage(chat.imageUploadMessage ?? "")
}()
let ok = chat.imageUploadMessage == "Image uploaded"
let bg: Color = chat.isUploadingImage ? YpChatTheme.surfaceSoftBlue : (ok ? YpChatTheme.surfaceSoftGreen : YpChatTheme.surfaceSoftRed)
let fg: Color = chat.isUploadingImage || ok ? YpChatTheme.primary700 : YpChatTheme.danger
return Text(msg)
.font(.subheadline)
.foregroundStyle(fg)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(bg)
.padding(.horizontal, 12)
}
@ViewBuilder
private func messageBubble(_ message: ChatMessageDto) -> some View {
let selfMsg = message.from == (chat.currentUser?.userName ?? "")
HStack {
if selfMsg { Spacer(minLength: 40) }
VStack(alignment: selfMsg ? .trailing : .leading) {
if message.isImage, let urlStr = message.imageUrl, let url = URL(string: urlStr) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFit().frame(maxHeight: 220)
case .failure:
Text(L10n.tr("image_message")).foregroundStyle(YpChatTheme.textMuted)
case .empty:
ProgressView()
@unknown default:
EmptyView()
}
}
} else {
Text(message.message)
}
}
.padding(12)
.background(selfMsg ? YpChatTheme.bubbleSelf : YpChatTheme.surfaceSubtle, in: RoundedRectangle(cornerRadius: 18))
if !selfMsg { Spacer(minLength: 40) }
}
}
private func handlePhoto(_ item: PhotosPickerItem?) async {
guard let item, let partner = chat.currentConversation else { return }
repo.setImageUploadState(inProgress: true, message: nil)
guard let data = try? await item.loadTransferable(type: Data.self) else {
repo.setImageUploadState(inProgress: false, message: "Image could not be opened")
photoItem = nil
return
}
guard data.count <= Self.maxImageBytes else {
repo.setImageUploadState(inProgress: false, message: "Image exceeds 5 MB")
photoItem = nil
return
}
do {
let response = try await repo.uploadImage(data: data, fileName: "ypchat-image.jpg", mimeType: "image/jpeg")
if response.success, let code = response.code, let url = response.url, !code.isEmpty {
repo.sendImage(toUserName: partner, imageCode: code, imageUrl: url)
repo.setImageUploadState(inProgress: false, message: "Image uploaded")
} else {
repo.setImageUploadState(inProgress: false, message: response.error ?? "Image upload failed")
}
} catch {
repo.setImageUploadState(inProgress: false, message: error.localizedDescription)
}
photoItem = nil
}
}

View File

@@ -0,0 +1,504 @@
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)
}
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
/// Farben wie `YpChatRoot.kt`.
enum YpChatTheme {
static let bgApp = Color(red: 0.96, green: 0.97, blue: 0.96)
static let bgShell = Color(red: 0.93, green: 0.95, blue: 0.93)
static let surface = Color.white
static let surfaceSubtle = Color(red: 0.965, green: 0.976, blue: 0.969)
static let surfaceSoftGreen = Color(red: 0.941, green: 0.969, blue: 0.949)
static let surfaceSoftBlue = Color(red: 0.945, green: 0.961, blue: 0.980)
static let surfaceSoftRed = Color(red: 0.984, green: 0.929, blue: 0.929)
static let border = Color(red: 0.843, green: 0.875, blue: 0.851)
static let textStrong = Color(red: 0.094, green: 0.125, blue: 0.106)
static let textMuted = Color(red: 0.388, green: 0.439, blue: 0.404)
static let primary700 = Color(red: 0.141, green: 0.361, blue: 0.227)
static let primary600 = Color(red: 0.184, green: 0.435, blue: 0.275)
static let primary500 = Color(red: 0.239, green: 0.525, blue: 0.329)
static let primary100 = Color(red: 0.906, green: 0.945, blue: 0.918)
static let danger = Color(red: 0.635, green: 0.251, blue: 0.251)
static let bubbleSelf = Color(red: 0.875, green: 0.941, blue: 0.894)
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct YpChatApp: App {
@StateObject private var services = AppServices()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(services)
}
}
}