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:
BIN
android/Bildschirmfoto_20260512_114053.png
Normal file
BIN
android/Bildschirmfoto_20260512_114053.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -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
|
||||||
|
|||||||
BIN
android/app/release/app-release.aab
Normal file
BIN
android/app/release/app-release.aab
Normal file
Binary file not shown.
@@ -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 {
|
||||||
@@ -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
|
||||||
@@ -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('/')
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package net.ypchat.app.core
|
package de.ypchat.android.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
BIN
android/production
Normal file
Binary file not shown.
275
docs/ios-app-umsetzungsplan.md
Normal file
275
docs/ios-app-umsetzungsplan.md
Normal 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:** 5 MB-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
4
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
DerivedData/
|
||||||
|
.build/
|
||||||
2
ios/Config/Debug.xcconfig
Normal file
2
ios/Config/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Debug: BASE_URL bei Bedarf für Staging oder lokalen Server anpassen.
|
||||||
|
BASE_URL = https://www.ypchat.net
|
||||||
2
ios/Config/Release.xcconfig
Normal file
2
ios/Config/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Release: Produktions-URL
|
||||||
|
BASE_URL = https://www.ypchat.net
|
||||||
42
ios/README.md
Normal file
42
ios/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# YPChat iOS
|
||||||
|
|
||||||
|
Native iOS-App (SwiftUI): **P0–P3** 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.
|
||||||
397
ios/YpChat.xcodeproj/project.pbxproj
Normal file
397
ios/YpChat.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
77
ios/YpChat.xcodeproj/xcshareddata/xcschemes/YpChat.xcscheme
Normal file
77
ios/YpChat.xcodeproj/xcshareddata/xcschemes/YpChat.xcscheme
Normal 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>
|
||||||
12
ios/YpChat/ContentView.swift
Normal file
12
ios/YpChat/ContentView.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
YpChatRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(AppServices())
|
||||||
|
}
|
||||||
11
ios/YpChat/Core/AppConfig.swift
Normal file
11
ios/YpChat/Core/AppConfig.swift
Normal 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: "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ios/YpChat/Core/AppServices.swift
Normal file
38
ios/YpChat/Core/AppServices.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ios/YpChat/Core/ProfileStore.swift
Normal file
43
ios/YpChat/Core/ProfileStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
493
ios/YpChat/Data/ChatRepository.swift
Normal file
493
ios/YpChat/Data/ChatRepository.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
105
ios/YpChat/Data/Models.swift
Normal file
105
ios/YpChat/Data/Models.swift
Normal 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?
|
||||||
|
}
|
||||||
182
ios/YpChat/Data/RestAPIClient.swift
Normal file
182
ios/YpChat/Data/RestAPIClient.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
348
ios/YpChat/Data/SocketClient.swift
Normal file
348
ios/YpChat/Data/SocketClient.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ios/YpChat/Data/SocketEvent.swift
Normal file
21
ios/YpChat/Data/SocketEvent.swift
Normal 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?)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/YpChat/Resources/Assets.xcassets/Contents.json
Normal file
6
ios/YpChat/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
47
ios/YpChat/Resources/Info.plist
Normal file
47
ios/YpChat/Resources/Info.plist
Normal 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>
|
||||||
99
ios/YpChat/Resources/de.lproj/Localizable.strings
Normal file
99
ios/YpChat/Resources/de.lproj/Localizable.strings
Normal 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.";
|
||||||
99
ios/YpChat/Resources/en.lproj/Localizable.strings
Normal file
99
ios/YpChat/Resources/en.lproj/Localizable.strings
Normal 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.";
|
||||||
121
ios/YpChat/UI/YpChatL10n.swift
Normal file
121
ios/YpChat/UI/YpChatL10n.swift
Normal 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
|
||||||
|
}
|
||||||
595
ios/YpChat/UI/YpChatMoreChatViews.swift
Normal file
595
ios/YpChat/UI/YpChatMoreChatViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
504
ios/YpChat/UI/YpChatRoot.swift
Normal file
504
ios/YpChat/UI/YpChatRoot.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ios/YpChat/UI/YpChatTheme.swift
Normal file
21
ios/YpChat/UI/YpChatTheme.swift
Normal 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)
|
||||||
|
}
|
||||||
13
ios/YpChat/YpChatApp.swift
Normal file
13
ios/YpChat/YpChatApp.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct YpChatApp: App {
|
||||||
|
@StateObject private var services = AppServices()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(services)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user