android version
This commit is contained in:
118
android/app/build.gradle.kts
Normal file
118
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,118 @@
|
||||
import java.util.Properties
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use(::load)
|
||||
}
|
||||
}
|
||||
|
||||
val keyProperties = Properties().apply {
|
||||
val file = rootProject.file("key.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use(::load)
|
||||
}
|
||||
}
|
||||
|
||||
val releaseStoreFile = keyProperties.getProperty("storeFile")?.let { rootProject.file(it) }
|
||||
|
||||
val defaultBaseUrl = "https://www.ypchat.net"
|
||||
val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl)
|
||||
val hasReleaseSigning = releaseStoreFile?.exists() == true
|
||||
|
||||
android {
|
||||
namespace = "net.ypchat.app"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.ypchat.app"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = keyProperties.getProperty("storePassword")
|
||||
keyAlias = keyProperties.getProperty("keyAlias")
|
||||
keyPassword = keyProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = true
|
||||
}
|
||||
release {
|
||||
buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
manifestPlaceholders["usesCleartextTraffic"] = false
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.03.01")
|
||||
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("com.squareup.retrofit2:retrofit:3.0.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
|
||||
implementation("io.socket:socket.io-client:2.1.2") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
6
android/app/proguard-rules.pro
vendored
Normal file
6
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Keep Socket.IO and Engine.IO callback classes reachable in release builds.
|
||||
-keep class io.socket.** { *; }
|
||||
-keep class io.socket.engineio.** { *; }
|
||||
-keep class okhttp3.** { *; }
|
||||
-dontwarn io.socket.**
|
||||
-dontwarn okhttp3.**
|
||||
23
android/app/src/main/AndroidManifest.xml
Normal file
23
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".YpChatApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.YpChat">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
20
android/app/src/main/java/net/ypchat/app/MainActivity.kt
Normal file
20
android/app/src/main/java/net/ypchat/app/MainActivity.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package net.ypchat.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import net.ypchat.app.ui.ChatViewModel
|
||||
import net.ypchat.app.ui.ChatViewModelFactory
|
||||
import net.ypchat.app.ui.YpChatRoot
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: ChatViewModel by viewModels {
|
||||
ChatViewModelFactory((application as YpChatApp).container.chatRepository)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { YpChatRoot(viewModel) }
|
||||
}
|
||||
}
|
||||
14
android/app/src/main/java/net/ypchat/app/YpChatApp.kt
Normal file
14
android/app/src/main/java/net/ypchat/app/YpChatApp.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package net.ypchat.app
|
||||
|
||||
import android.app.Application
|
||||
import net.ypchat.app.core.AppContainer
|
||||
|
||||
class YpChatApp : Application() {
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.ypchat.app.core
|
||||
|
||||
import net.ypchat.app.BuildConfig
|
||||
|
||||
object AppConfig {
|
||||
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.ypchat.app.core
|
||||
|
||||
import android.content.Context
|
||||
import net.ypchat.app.data.api.RestApi
|
||||
import net.ypchat.app.data.api.SocketClient
|
||||
import net.ypchat.app.data.repository.ChatRepository
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
val cookieJar = SessionCookieJar(context)
|
||||
val profileStore = ProfileStore(context)
|
||||
|
||||
val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val retrofit: Retrofit = Retrofit.Builder()
|
||||
.baseUrl(AppConfig.baseUrl + "/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val restApi: RestApi = retrofit.create(RestApi::class.java)
|
||||
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
|
||||
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package net.ypchat.app.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
data class SavedProfile(
|
||||
val nickname: String = "",
|
||||
val gender: String = "",
|
||||
val age: Int = 18,
|
||||
val country: String = "Germany"
|
||||
)
|
||||
|
||||
class ProfileStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences("ypchat_profile", Context.MODE_PRIVATE)
|
||||
|
||||
fun read(): SavedProfile = SavedProfile(
|
||||
nickname = preferences.getString(KEY_NICKNAME, "").orEmpty(),
|
||||
gender = preferences.getString(KEY_GENDER, "").orEmpty(),
|
||||
age = preferences.getInt(KEY_AGE, 18),
|
||||
country = preferences.getString(KEY_COUNTRY, "Germany").orEmpty()
|
||||
)
|
||||
|
||||
fun write(profile: SavedProfile) {
|
||||
preferences.edit()
|
||||
.putString(KEY_NICKNAME, profile.nickname.trim())
|
||||
.putString(KEY_GENDER, profile.gender)
|
||||
.putInt(KEY_AGE, profile.age)
|
||||
.putString(KEY_COUNTRY, profile.country)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_NICKNAME = "nickname"
|
||||
const val KEY_GENDER = "gender"
|
||||
const val KEY_AGE = "age"
|
||||
const val KEY_COUNTRY = "country"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.ypchat.app.core
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class SessionCookieJar(context: Context) : CookieJar {
|
||||
private val prefs = context.getSharedPreferences("ypchat_cookies", Context.MODE_PRIVATE)
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val editor = prefs.edit()
|
||||
cookies.forEach { cookie ->
|
||||
editor.putString(cookie.name, cookie.toString())
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return prefs.all.values
|
||||
.mapNotNull { it as? String }
|
||||
.mapNotNull { Cookie.parse(url, it) }
|
||||
.filter { cookie -> cookie.expiresAt > System.currentTimeMillis() }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
54
android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt
Normal file
54
android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package net.ypchat.app.data.api
|
||||
|
||||
import net.ypchat.app.data.model.CountriesResponse
|
||||
import net.ypchat.app.data.model.FeedbackAdminLoginRequest
|
||||
import net.ypchat.app.data.model.FeedbackAdminStatusResponse
|
||||
import net.ypchat.app.data.model.FeedbackRequest
|
||||
import net.ypchat.app.data.model.FeedbackResponse
|
||||
import net.ypchat.app.data.model.ImageUploadResponse
|
||||
import net.ypchat.app.data.model.LogoutResponse
|
||||
import net.ypchat.app.data.model.PartnerLinkDto
|
||||
import net.ypchat.app.data.model.SessionResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
interface RestApi {
|
||||
@GET("api/session")
|
||||
suspend fun session(): SessionResponse
|
||||
|
||||
@POST("api/logout")
|
||||
suspend fun logout(): LogoutResponse
|
||||
|
||||
@GET("api/countries")
|
||||
suspend fun countries(): CountriesResponse
|
||||
|
||||
@GET("api/feedback")
|
||||
suspend fun feedback(): FeedbackResponse
|
||||
|
||||
@GET("api/feedback/admin-status")
|
||||
suspend fun feedbackAdminStatus(): FeedbackAdminStatusResponse
|
||||
|
||||
@POST("api/feedback")
|
||||
suspend fun submitFeedback(@Body request: FeedbackRequest)
|
||||
|
||||
@POST("api/feedback/admin-login")
|
||||
suspend fun feedbackAdminLogin(@Body request: FeedbackAdminLoginRequest): FeedbackAdminStatusResponse
|
||||
|
||||
@POST("api/feedback/admin-logout")
|
||||
suspend fun feedbackAdminLogout()
|
||||
|
||||
@retrofit2.http.DELETE("api/feedback/{id}")
|
||||
suspend fun deleteFeedback(@retrofit2.http.Path("id") id: String)
|
||||
|
||||
@GET("api/partners")
|
||||
suspend fun partners(): List<PartnerLinkDto>
|
||||
|
||||
@Multipart
|
||||
@POST("api/upload-image")
|
||||
suspend fun uploadImage(@Part image: MultipartBody.Part): Response<ImageUploadResponse>
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package net.ypchat.app.data.api
|
||||
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
import io.socket.engineio.client.transports.Polling
|
||||
import io.socket.engineio.client.transports.WebSocket
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.data.model.ChatMessageDto
|
||||
import net.ypchat.app.data.model.HistoryItemDto
|
||||
import net.ypchat.app.data.model.InboxItemDto
|
||||
import net.ypchat.app.data.model.SocketEvent
|
||||
import net.ypchat.app.data.model.UserDto
|
||||
import okhttp3.OkHttpClient
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class SocketClient(
|
||||
private val baseUrl: String,
|
||||
private val okHttpClient: OkHttpClient
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<SocketEvent> = _events
|
||||
|
||||
private var socket: Socket? = null
|
||||
private var pendingExpressSessionId: String? = null
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = socket?.connected() == true
|
||||
|
||||
fun connect() {
|
||||
disconnect()
|
||||
|
||||
val options = IO.Options().apply {
|
||||
transports = arrayOf(WebSocket.NAME, Polling.NAME)
|
||||
reconnection = true
|
||||
reconnectionAttempts = Int.MAX_VALUE
|
||||
reconnectionDelay = 1_000
|
||||
timeout = 10_000
|
||||
callFactory = okHttpClient
|
||||
webSocketFactory = okHttpClient
|
||||
}
|
||||
|
||||
socket = IO.socket(baseUrl, options).also { s ->
|
||||
s.on(Socket.EVENT_CONNECT) {
|
||||
pendingExpressSessionId?.let { sessionId ->
|
||||
s.emit("setSessionId", JSONObject().put("expressSessionId", sessionId))
|
||||
}
|
||||
emit(SocketEvent.ConnectionChanged(true))
|
||||
}
|
||||
s.on(Socket.EVENT_DISCONNECT) { args ->
|
||||
emit(SocketEvent.ConnectionChanged(false, args.firstOrNull()?.toString()))
|
||||
}
|
||||
s.on(Socket.EVENT_CONNECT_ERROR) { args ->
|
||||
emit(SocketEvent.Error("Socket-Verbindung fehlgeschlagen: ${args.firstOrNull()?.toString().orEmpty()}"))
|
||||
}
|
||||
|
||||
s.on("connected") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(
|
||||
SocketEvent.Connected(
|
||||
sessionId = json.optStringOrNull("sessionId"),
|
||||
loggedIn = json.optBoolean("loggedIn", false),
|
||||
user = json.optJSONObject("user")?.toUserDto()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
s.on("loginSuccess") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(SocketEvent.LoginSuccess(json.optStringOrNull("sessionId"), json.optJSONObject("user")?.toUserDto()))
|
||||
}
|
||||
}
|
||||
s.on("userList") { args ->
|
||||
args.firstJson()?.optJSONArray("users")?.let { users ->
|
||||
emit(SocketEvent.UserList(users.toObjectList { it.toUserDto() }))
|
||||
}
|
||||
}
|
||||
s.on("message") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.IncomingMessage(it.toMessageDto())) }
|
||||
}
|
||||
s.on("messageSent") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(SocketEvent.MessageSent(json.optStringOrNull("messageId"), json.optStringOrNull("to")))
|
||||
}
|
||||
}
|
||||
s.on("conversation") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
val messages = json.optJSONArray("messages")?.toObjectList { it.toMessageDto() }.orEmpty()
|
||||
emit(SocketEvent.Conversation(json.optString("with"), messages))
|
||||
}
|
||||
}
|
||||
s.on("searchResults") { args ->
|
||||
args.firstJson()?.optJSONArray("results")?.let { results ->
|
||||
emit(SocketEvent.SearchResults(results.toObjectList { it.toUserDto() }))
|
||||
}
|
||||
}
|
||||
s.on("historyResults") { args ->
|
||||
args.firstJson()?.optJSONArray("results")?.let { results ->
|
||||
emit(SocketEvent.HistoryResults(results.toObjectList { it.toHistoryItemDto() }))
|
||||
}
|
||||
}
|
||||
s.on("inboxResults") { args ->
|
||||
args.firstJson()?.optJSONArray("results")?.let { results ->
|
||||
emit(SocketEvent.InboxResults(results.toObjectList { it.toInboxItemDto() }))
|
||||
}
|
||||
}
|
||||
s.on("unreadChats") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) }
|
||||
}
|
||||
s.on("userBlocked") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
|
||||
}
|
||||
s.on("userUnblocked") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.UserUnblocked(it.optString("userName"))) }
|
||||
}
|
||||
s.on("commandResult") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(SocketEvent.CommandResult(json.optJSONArray("lines").toStringList(), json.optString("kind", "info")))
|
||||
}
|
||||
}
|
||||
s.on("commandTable") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(
|
||||
SocketEvent.CommandTable(
|
||||
title = json.optString("title", "Ausgabe"),
|
||||
columns = json.optJSONArray("columns").toStringList(),
|
||||
rows = json.optJSONArray("rows").toNestedStringList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
s.on("error") { args ->
|
||||
val message = args.firstJson()?.optString("message") ?: args.firstOrNull()?.toString() ?: "Unbekannter Socket-Fehler"
|
||||
emit(SocketEvent.Error(message))
|
||||
}
|
||||
s.connect()
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
socket?.disconnect()
|
||||
socket?.off()
|
||||
socket = null
|
||||
}
|
||||
|
||||
fun setSessionId(expressSessionId: String) {
|
||||
pendingExpressSessionId = expressSessionId
|
||||
socket?.takeIf { it.connected() }?.emit("setSessionId", JSONObject().put("expressSessionId", expressSessionId))
|
||||
}
|
||||
|
||||
fun login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) {
|
||||
socket?.emit(
|
||||
"login",
|
||||
JSONObject()
|
||||
.put("userName", userName)
|
||||
.put("gender", gender)
|
||||
.put("age", age)
|
||||
.put("country", country)
|
||||
.put("expressSessionId", expressSessionId)
|
||||
)
|
||||
}
|
||||
|
||||
fun sendMessage(toUserName: String?, message: String, messageId: String = System.currentTimeMillis().toString()) {
|
||||
val payload = JSONObject()
|
||||
.put("message", message.trim())
|
||||
.put("messageId", messageId)
|
||||
|
||||
if (!toUserName.isNullOrBlank()) {
|
||||
payload.put("toUserName", toUserName)
|
||||
}
|
||||
|
||||
socket?.emit("message", payload)
|
||||
}
|
||||
|
||||
fun sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = System.currentTimeMillis().toString()) {
|
||||
socket?.emit(
|
||||
"message",
|
||||
JSONObject()
|
||||
.put("toUserName", toUserName)
|
||||
.put("message", imageCode)
|
||||
.put("messageId", messageId)
|
||||
.put("isImage", true)
|
||||
.put("imageUrl", imageUrl)
|
||||
)
|
||||
}
|
||||
|
||||
fun requestConversation(withUserName: String) {
|
||||
socket?.emit("requestConversation", JSONObject().put("withUserName", withUserName))
|
||||
}
|
||||
|
||||
fun userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
|
||||
socket?.emit(
|
||||
"userSearch",
|
||||
JSONObject()
|
||||
.put("nameIncludes", nameIncludes)
|
||||
.put("minAge", minAge)
|
||||
.put("maxAge", maxAge)
|
||||
.put("countries", JSONArray(countries))
|
||||
.put("genders", JSONArray(genders))
|
||||
)
|
||||
}
|
||||
|
||||
fun requestHistory() = socket?.emit("requestHistory")
|
||||
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
|
||||
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
|
||||
fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName))
|
||||
|
||||
private fun emit(event: SocketEvent) {
|
||||
scope.launch { _events.emit(event) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Array<out Any>.firstJson(): JSONObject? = firstOrNull() as? JSONObject
|
||||
|
||||
private fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) optString(name) else null
|
||||
|
||||
private fun JSONObject.toUserDto(): UserDto = UserDto(
|
||||
sessionId = optStringOrNull("sessionId"),
|
||||
userName = optString("userName"),
|
||||
gender = optString("gender"),
|
||||
age = optInt("age", 0),
|
||||
country = optString("country"),
|
||||
isoCountryCode = optString("isoCountryCode")
|
||||
)
|
||||
|
||||
private fun JSONObject.toMessageDto(): ChatMessageDto = ChatMessageDto(
|
||||
from = optString("from"),
|
||||
to = optStringOrNull("to"),
|
||||
message = optString("message"),
|
||||
messageId = optStringOrNull("messageId"),
|
||||
timestamp = optString("timestamp"),
|
||||
read = optBoolean("read", false),
|
||||
isImage = optBoolean("isImage", false),
|
||||
imageType = optStringOrNull("imageType"),
|
||||
imageUrl = optStringOrNull("imageUrl"),
|
||||
imageCode = optStringOrNull("imageCode")
|
||||
)
|
||||
|
||||
private fun JSONObject.toHistoryItemDto(): HistoryItemDto = HistoryItemDto(
|
||||
userName = optString("userName"),
|
||||
lastMessage = optJSONObject("lastMessage")?.toMessageDto()
|
||||
)
|
||||
|
||||
private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
|
||||
userName = optString("userName"),
|
||||
unreadCount = optInt("unreadCount", 0)
|
||||
)
|
||||
|
||||
private fun JSONArray?.toStringList(): List<String> {
|
||||
if (this == null) return emptyList()
|
||||
return List(length()) { index -> opt(index)?.toString().orEmpty() }
|
||||
}
|
||||
|
||||
private fun JSONArray?.toNestedStringList(): List<List<String>> {
|
||||
if (this == null) return emptyList()
|
||||
return List(length()) { index -> optJSONArray(index).toStringList() }
|
||||
}
|
||||
|
||||
private fun <T> JSONArray.toObjectList(mapper: (JSONObject) -> T): List<T> = buildList {
|
||||
for (index in 0 until length()) {
|
||||
optJSONObject(index)?.let { add(mapper(it)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package net.ypchat.app.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class UserDto(
|
||||
val sessionId: String? = null,
|
||||
val userName: String = "",
|
||||
val gender: String = "",
|
||||
val age: Int = 0,
|
||||
val country: String = "",
|
||||
val isoCountryCode: String = ""
|
||||
)
|
||||
|
||||
data class ChatMessageDto(
|
||||
val from: String = "",
|
||||
val to: String? = null,
|
||||
val message: String = "",
|
||||
val messageId: String? = null,
|
||||
val timestamp: String = "",
|
||||
val read: Boolean = false,
|
||||
val isImage: Boolean = false,
|
||||
val imageType: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val imageCode: String? = null
|
||||
)
|
||||
|
||||
data class HistoryItemDto(
|
||||
val userName: String = "",
|
||||
val lastMessage: ChatMessageDto? = null
|
||||
)
|
||||
|
||||
data class InboxItemDto(
|
||||
val userName: String = "",
|
||||
val unreadCount: Int = 0
|
||||
)
|
||||
|
||||
data class CountryOption(
|
||||
val englishName: String,
|
||||
val displayName: String,
|
||||
val isoCode: String
|
||||
)
|
||||
|
||||
data class SessionResponse(
|
||||
val loggedIn: Boolean = false,
|
||||
val sessionId: String? = null,
|
||||
val user: UserDto? = null
|
||||
)
|
||||
|
||||
data class LogoutResponse(
|
||||
val success: Boolean = false
|
||||
)
|
||||
|
||||
data class ImageUploadResponse(
|
||||
val success: Boolean = false,
|
||||
val code: String? = null,
|
||||
val url: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class FeedbackItemDto(
|
||||
val id: String = "",
|
||||
val name: String? = null,
|
||||
val age: Int? = null,
|
||||
val country: String? = null,
|
||||
val gender: String? = null,
|
||||
val comment: String = "",
|
||||
val createdAt: String = ""
|
||||
)
|
||||
|
||||
data class FeedbackResponse(
|
||||
val items: List<FeedbackItemDto> = emptyList(),
|
||||
val admin: Boolean = false
|
||||
)
|
||||
|
||||
data class FeedbackAdminStatusResponse(
|
||||
val authenticated: Boolean = false,
|
||||
val username: String? = null
|
||||
)
|
||||
|
||||
data class FeedbackRequest(
|
||||
val name: String = "",
|
||||
val age: Int? = null,
|
||||
val country: String = "",
|
||||
val gender: String = "",
|
||||
val comment: String = ""
|
||||
)
|
||||
|
||||
data class FeedbackAdminLoginRequest(
|
||||
val username: String = "",
|
||||
val password: String = ""
|
||||
)
|
||||
|
||||
data class PartnerLinkDto(
|
||||
@SerializedName("Page Name")
|
||||
val pageName: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
class CountriesResponse : LinkedHashMap<String, String>()
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.ypchat.app.data.model
|
||||
|
||||
sealed interface SocketEvent {
|
||||
data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent
|
||||
data class LoginSuccess(val sessionId: String?, val user: UserDto?) : SocketEvent
|
||||
data class UserList(val users: List<UserDto>) : SocketEvent
|
||||
data class IncomingMessage(val message: ChatMessageDto) : SocketEvent
|
||||
data class MessageSent(val messageId: String?, val to: String?) : SocketEvent
|
||||
data class Conversation(val withUserName: String, val messages: List<ChatMessageDto>) : SocketEvent
|
||||
data class SearchResults(val results: List<UserDto>) : SocketEvent
|
||||
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
|
||||
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
|
||||
data class UnreadChats(val count: Int) : SocketEvent
|
||||
data class UserBlocked(val userName: String) : SocketEvent
|
||||
data class UserUnblocked(val userName: String) : SocketEvent
|
||||
data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent
|
||||
data class CommandTable(val title: String, val columns: List<String>, val rows: List<List<String>>) : SocketEvent
|
||||
data class Error(val message: String) : SocketEvent
|
||||
data class ConnectionChanged(val connected: Boolean, val reason: String? = null) : SocketEvent
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package net.ypchat.app.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.core.AppConfig
|
||||
import net.ypchat.app.core.ProfileStore
|
||||
import net.ypchat.app.core.SavedProfile
|
||||
import net.ypchat.app.core.SessionCookieJar
|
||||
import net.ypchat.app.data.api.RestApi
|
||||
import net.ypchat.app.data.api.SocketClient
|
||||
import net.ypchat.app.data.model.ChatMessageDto
|
||||
import net.ypchat.app.data.model.CountryOption
|
||||
import net.ypchat.app.data.model.FeedbackAdminLoginRequest
|
||||
import net.ypchat.app.data.model.FeedbackItemDto
|
||||
import net.ypchat.app.data.model.FeedbackRequest
|
||||
import net.ypchat.app.data.model.HistoryItemDto
|
||||
import net.ypchat.app.data.model.InboxItemDto
|
||||
import net.ypchat.app.data.model.PartnerLinkDto
|
||||
import net.ypchat.app.data.model.SocketEvent
|
||||
import net.ypchat.app.data.model.UserDto
|
||||
import okhttp3.MultipartBody
|
||||
import java.util.Locale
|
||||
|
||||
class ChatRepository(
|
||||
private val restApi: RestApi,
|
||||
private val socketClient: SocketClient,
|
||||
private val cookieJar: SessionCookieJar,
|
||||
private val profileStore: ProfileStore
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state: StateFlow<ChatState> = _state.asStateFlow()
|
||||
private var timeoutTickerStarted = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
socketClient.events.collect { event -> reduce(event) }
|
||||
}
|
||||
startTimeoutTicker()
|
||||
}
|
||||
|
||||
suspend fun restoreSession() {
|
||||
_state.value = _state.value.copy(savedProfile = profileStore.read())
|
||||
loadCountries()
|
||||
loadFeedbackAdminStatus()
|
||||
runCatching { restApi.session() }
|
||||
.onSuccess { session ->
|
||||
_state.value = _state.value.copy(
|
||||
expressSessionId = session.sessionId,
|
||||
isLoggedIn = session.loggedIn && session.user != null,
|
||||
currentUser = session.user,
|
||||
errorMessage = null
|
||||
)
|
||||
if (session.loggedIn && session.user != null) {
|
||||
resetTimeout()
|
||||
}
|
||||
connectSocket(session.sessionId)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(errorMessage = error.message) }
|
||||
}
|
||||
|
||||
suspend fun loadCountries() {
|
||||
runCatching { restApi.countries() }
|
||||
.onSuccess { countries ->
|
||||
val locale = Locale.getDefault()
|
||||
val options = countries.map { (englishName, code) ->
|
||||
val normalizedCode = code.uppercase(Locale.US)
|
||||
val localizedName = Locale.Builder().setRegion(normalizedCode).build().getDisplayCountry(locale)
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: englishName
|
||||
CountryOption(
|
||||
englishName = englishName,
|
||||
displayName = localizedName,
|
||||
isoCode = code
|
||||
)
|
||||
}.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
|
||||
|
||||
_state.value = _state.value.copy(countries = options)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(errorMessage = "Country list could not be loaded: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(userName: String, gender: String, age: Int, country: String) {
|
||||
profileStore.write(SavedProfile(userName, gender, age, country))
|
||||
val session = runCatching { restApi.session() }.getOrNull()
|
||||
val sessionId = session?.sessionId ?: _state.value.expressSessionId
|
||||
_state.value = _state.value.copy(expressSessionId = sessionId)
|
||||
if (!socketClient.isConnected) {
|
||||
connectSocket(sessionId)
|
||||
}
|
||||
socketClient.login(userName, gender, age, country, sessionId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
runCatching { restApi.logout() }
|
||||
socketClient.disconnect()
|
||||
cookieJar.clear()
|
||||
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
|
||||
}
|
||||
|
||||
fun connectSocket(expressSessionId: String? = _state.value.expressSessionId) {
|
||||
socketClient.connect()
|
||||
expressSessionId?.let { socketClient.setSessionId(it) }
|
||||
}
|
||||
|
||||
fun openConversation(userName: String) {
|
||||
_state.value = _state.value.copy(currentConversation = userName, messages = emptyList())
|
||||
socketClient.requestConversation(userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun closeConversation() {
|
||||
_state.value = _state.value.copy(currentConversation = null, messages = emptyList())
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
val target = _state.value.currentConversation
|
||||
val isCommand = trimmed.startsWith("/")
|
||||
if (target == null && !isCommand) return
|
||||
|
||||
socketClient.sendMessage(target, trimmed)
|
||||
if (!isCommand) {
|
||||
_state.value = _state.value.copy(
|
||||
messages = _state.value.messages + ChatMessageDto(
|
||||
from = _state.value.currentUser?.userName.orEmpty(),
|
||||
to = target,
|
||||
message = trimmed,
|
||||
timestamp = java.time.Instant.now().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun sendImage(toUserName: String, imageCode: String, imageUrl: String) {
|
||||
val absoluteUrl = if (imageUrl.startsWith("http")) imageUrl else AppConfig.baseUrl + imageUrl
|
||||
socketClient.sendImage(toUserName, imageCode, absoluteUrl)
|
||||
_state.value = _state.value.copy(
|
||||
messages = _state.value.messages + ChatMessageDto(
|
||||
from = _state.value.currentUser?.userName.orEmpty(),
|
||||
to = toUserName,
|
||||
message = absoluteUrl,
|
||||
timestamp = java.time.Instant.now().toString(),
|
||||
isImage = true,
|
||||
imageUrl = absoluteUrl,
|
||||
imageCode = imageCode
|
||||
)
|
||||
)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun setImageUploadState(inProgress: Boolean, message: String? = null) {
|
||||
_state.value = _state.value.copy(
|
||||
isUploadingImage = inProgress,
|
||||
imageUploadMessage = message
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(part: MultipartBody.Part) = restApi.uploadImage(part)
|
||||
|
||||
suspend fun loadFeedback() {
|
||||
runCatching { restApi.feedback() }
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(feedbackItems = response.items, feedbackMessage = null)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(feedbackMessage = error.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun submitFeedback(comment: String) {
|
||||
val trimmed = comment.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
|
||||
val profile = _state.value.savedProfile
|
||||
runCatching {
|
||||
restApi.submitFeedback(
|
||||
FeedbackRequest(
|
||||
name = profile.nickname,
|
||||
age = profile.age,
|
||||
country = profile.country,
|
||||
gender = profile.gender,
|
||||
comment = trimmed
|
||||
)
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(feedbackMessage = "Feedback saved")
|
||||
loadFeedback()
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(feedbackMessage = error.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadFeedbackAdminStatus() {
|
||||
runCatching { restApi.feedbackAdminStatus() }
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
feedbackAdminAuthenticated = response.authenticated,
|
||||
feedbackAdminUserName = response.username,
|
||||
feedbackAdminError = null
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
feedbackAdminAuthenticated = false,
|
||||
feedbackAdminUserName = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loginFeedbackAdmin(username: String, password: String) {
|
||||
runCatching { restApi.feedbackAdminLogin(FeedbackAdminLoginRequest(username, password)) }
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
feedbackAdminAuthenticated = true,
|
||||
feedbackAdminUserName = response.username,
|
||||
feedbackAdminError = null
|
||||
)
|
||||
loadFeedback()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(feedbackAdminError = error.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logoutFeedbackAdmin() {
|
||||
runCatching { restApi.feedbackAdminLogout() }
|
||||
_state.value = _state.value.copy(
|
||||
feedbackAdminAuthenticated = false,
|
||||
feedbackAdminUserName = null,
|
||||
feedbackAdminError = null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteFeedback(id: String) {
|
||||
runCatching { restApi.deleteFeedback(id) }
|
||||
.onSuccess { loadFeedback() }
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(feedbackAdminError = error.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPartners() {
|
||||
runCatching { restApi.partners() }
|
||||
.onSuccess { links ->
|
||||
_state.value = _state.value.copy(partnerLinks = links, partnersError = null)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(partnersError = error.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
|
||||
socketClient.userSearch(nameIncludes, minAge, maxAge, countries, genders)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun requestInbox() {
|
||||
socketClient.requestOpenConversations()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun requestHistory() {
|
||||
socketClient.requestHistory()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun blockUser(userName: String) {
|
||||
socketClient.blockUser(userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun unblockUser(userName: String) {
|
||||
socketClient.unblockUser(userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
private fun startTimeoutTicker() {
|
||||
if (timeoutTickerStarted) return
|
||||
timeoutTickerStarted = true
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(1_000)
|
||||
val current = _state.value
|
||||
if (!current.isLoggedIn) continue
|
||||
val next = (current.remainingSecondsToTimeout - 1).coerceAtLeast(0)
|
||||
_state.value = current.copy(remainingSecondsToTimeout = next)
|
||||
if (next == 0) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTimeout() {
|
||||
if (_state.value.isLoggedIn || _state.value.currentUser != null) {
|
||||
_state.value = _state.value.copy(remainingSecondsToTimeout = 1800)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduce(event: SocketEvent) {
|
||||
val current = _state.value
|
||||
_state.value = when (event) {
|
||||
is SocketEvent.ConnectionChanged -> current.copy(isConnected = event.connected)
|
||||
is SocketEvent.Connected -> {
|
||||
event.sessionId?.let { socketClient.setSessionId(it) }
|
||||
current.copy(
|
||||
expressSessionId = event.sessionId ?: current.expressSessionId,
|
||||
isLoggedIn = event.loggedIn || current.isLoggedIn,
|
||||
currentUser = event.user ?: current.currentUser,
|
||||
errorMessage = null,
|
||||
remainingSecondsToTimeout = if (event.loggedIn || current.isLoggedIn) 1800 else current.remainingSecondsToTimeout
|
||||
)
|
||||
}
|
||||
is SocketEvent.LoginSuccess -> current.copy(
|
||||
expressSessionId = event.sessionId ?: current.expressSessionId,
|
||||
isLoggedIn = true,
|
||||
currentUser = event.user,
|
||||
errorMessage = null,
|
||||
remainingSecondsToTimeout = 1800
|
||||
)
|
||||
is SocketEvent.UserList -> current.copy(users = event.users)
|
||||
is SocketEvent.IncomingMessage -> {
|
||||
val active = current.currentConversation == event.message.from
|
||||
current.copy(
|
||||
messages = if (active) current.messages + event.message else current.messages,
|
||||
unreadChatsCount = if (active) current.unreadChatsCount else current.unreadChatsCount + 1,
|
||||
remainingSecondsToTimeout = 1800
|
||||
)
|
||||
}
|
||||
is SocketEvent.MessageSent -> current
|
||||
is SocketEvent.Conversation -> current.copy(
|
||||
currentConversation = event.withUserName,
|
||||
messages = event.messages,
|
||||
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
|
||||
remainingSecondsToTimeout = 1800
|
||||
)
|
||||
is SocketEvent.SearchResults -> current.copy(searchResults = event.results)
|
||||
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
|
||||
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
|
||||
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count)
|
||||
is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
|
||||
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
|
||||
is SocketEvent.CommandResult -> current.copy(
|
||||
commandLines = event.lines,
|
||||
commandKind = event.kind,
|
||||
commandTable = null,
|
||||
awaitingLoginUsername = event.kind == "loginPromptUsername",
|
||||
awaitingLoginPassword = event.kind == "loginPromptPassword",
|
||||
errorMessage = if (event.kind == "info" || event.kind.startsWith("login")) event.lines.joinToString(" | ") else current.errorMessage
|
||||
)
|
||||
is SocketEvent.CommandTable -> current.copy(
|
||||
commandTable = CommandTableState(event.title, event.columns, event.rows)
|
||||
)
|
||||
is SocketEvent.Error -> current.copy(errorMessage = event.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CommandTableState(
|
||||
val title: String,
|
||||
val columns: List<String>,
|
||||
val rows: List<List<String>>
|
||||
)
|
||||
|
||||
data class ChatState(
|
||||
val isConnected: Boolean = false,
|
||||
val isLoggedIn: Boolean = false,
|
||||
val expressSessionId: String? = null,
|
||||
val currentUser: UserDto? = null,
|
||||
val users: List<UserDto> = emptyList(),
|
||||
val currentConversation: String? = null,
|
||||
val messages: List<ChatMessageDto> = emptyList(),
|
||||
val searchResults: List<UserDto> = emptyList(),
|
||||
val inboxResults: List<InboxItemDto> = emptyList(),
|
||||
val historyResults: List<HistoryItemDto> = emptyList(),
|
||||
val countries: List<CountryOption> = emptyList(),
|
||||
val feedbackItems: List<FeedbackItemDto> = emptyList(),
|
||||
val feedbackMessage: String? = null,
|
||||
val feedbackAdminAuthenticated: Boolean = false,
|
||||
val feedbackAdminUserName: String? = null,
|
||||
val feedbackAdminError: String? = null,
|
||||
val partnerLinks: List<PartnerLinkDto> = emptyList(),
|
||||
val partnersError: String? = null,
|
||||
val savedProfile: SavedProfile = SavedProfile(),
|
||||
val commandLines: List<String> = emptyList(),
|
||||
val commandKind: String? = null,
|
||||
val commandTable: CommandTableState? = null,
|
||||
val awaitingLoginUsername: Boolean = false,
|
||||
val awaitingLoginPassword: Boolean = false,
|
||||
val remainingSecondsToTimeout: Int = 1800,
|
||||
val isUploadingImage: Boolean = false,
|
||||
val imageUploadMessage: String? = null,
|
||||
val unreadChatsCount: Int = 0,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
124
android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt
Normal file
124
android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package net.ypchat.app.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.data.repository.ChatRepository
|
||||
import net.ypchat.app.data.repository.ChatState
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
||||
val state: StateFlow<ChatState> = repository.state
|
||||
|
||||
init {
|
||||
viewModelScope.launch { repository.restoreSession() }
|
||||
}
|
||||
|
||||
fun login(userName: String, gender: String, age: Int, country: String) {
|
||||
viewModelScope.launch { repository.login(userName, gender, age, country) }
|
||||
}
|
||||
|
||||
fun loadCountries() {
|
||||
viewModelScope.launch { repository.loadCountries() }
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { repository.logout() }
|
||||
}
|
||||
|
||||
fun openConversation(userName: String) = repository.openConversation(userName)
|
||||
fun closeConversation() = repository.closeConversation()
|
||||
fun sendMessage(text: String) = repository.sendMessage(text)
|
||||
fun sendImage(context: Context, uri: Uri) {
|
||||
val target = state.value.currentConversation ?: return
|
||||
viewModelScope.launch {
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
||||
repository.setImageUploadState(true)
|
||||
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
if (bytes == null) {
|
||||
repository.setImageUploadState(false, "Image could not be opened")
|
||||
return@launch
|
||||
}
|
||||
if (bytes.size > MAX_IMAGE_BYTES) {
|
||||
repository.setImageUploadState(false, "Image exceeds 5 MB")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val fileName = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (cursor.moveToFirst() && nameIndex >= 0) cursor.getString(nameIndex) else null
|
||||
} ?: "ypchat-image"
|
||||
|
||||
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("image", fileName, body)
|
||||
runCatching { repository.uploadImage(part) }
|
||||
.onSuccess { response ->
|
||||
val payload = response.body()
|
||||
if (response.isSuccessful && payload?.success == true && !payload.code.isNullOrBlank() && !payload.url.isNullOrBlank()) {
|
||||
repository.sendImage(target, payload.code, payload.url)
|
||||
repository.setImageUploadState(false, "Image uploaded")
|
||||
} else {
|
||||
repository.setImageUploadState(false, payload?.error ?: "Image upload failed")
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
repository.setImageUploadState(false, error.message ?: "Image upload failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(name: String, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
|
||||
repository.search(name.takeIf { it.isNotBlank() }, minAge, maxAge, countries, genders)
|
||||
}
|
||||
fun requestInbox() = repository.requestInbox()
|
||||
fun requestHistory() = repository.requestHistory()
|
||||
fun loadFeedback() {
|
||||
viewModelScope.launch { repository.loadFeedback() }
|
||||
}
|
||||
|
||||
fun submitFeedback(comment: String) {
|
||||
viewModelScope.launch { repository.submitFeedback(comment) }
|
||||
}
|
||||
|
||||
fun loadFeedbackAdminStatus() {
|
||||
viewModelScope.launch { repository.loadFeedbackAdminStatus() }
|
||||
}
|
||||
|
||||
fun loginFeedbackAdmin(username: String, password: String) {
|
||||
viewModelScope.launch { repository.loginFeedbackAdmin(username, password) }
|
||||
}
|
||||
|
||||
fun logoutFeedbackAdmin() {
|
||||
viewModelScope.launch { repository.logoutFeedbackAdmin() }
|
||||
}
|
||||
|
||||
fun deleteFeedback(id: String) {
|
||||
viewModelScope.launch { repository.deleteFeedback(id) }
|
||||
}
|
||||
|
||||
fun loadPartners() {
|
||||
viewModelScope.launch { repository.loadPartners() }
|
||||
}
|
||||
|
||||
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
|
||||
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser)
|
||||
|
||||
private companion object {
|
||||
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
class ChatViewModelFactory(private val repository: ChatRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ChatViewModel(repository) as T
|
||||
}
|
||||
}
|
||||
1039
android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt
Normal file
1039
android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
android/app/src/main/res/drawable-nodpi/app_icon_asset.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/app_icon_asset.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 985 KiB |
BIN
android/app/src/main/res/drawable-nodpi/image_button.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/image_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
android/app/src/main/res/drawable-nodpi/smileys_button.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/smileys_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
3
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
3
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:gravity="center"
|
||||
android:src="@drawable/app_icon_asset" />
|
||||
103
android/app/src/main/res/values-de/strings.xml
Normal file
103
android/app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,103 @@
|
||||
<resources>
|
||||
<string name="app_name">YPChat</string>
|
||||
<string name="landing_eyebrow">SingleChat</string>
|
||||
<string name="landing_title">Direkt in den Chat</string>
|
||||
<string name="landing_copy">Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.</string>
|
||||
<string name="feature_worldwide_chat">Weltweiter Chat</string>
|
||||
<string name="feature_image_exchange">Bildaustausch</string>
|
||||
<string name="feature_compact_controls">Kompakte Bedienung</string>
|
||||
<string name="profile_title">Profil starten</string>
|
||||
<string name="profile_copy">Wenige Angaben genügen für den Einstieg.</string>
|
||||
<string name="label_nick">Bitte gib deinen Nicknamen für den Chat ein</string>
|
||||
<string name="label_gender">Geschlecht</string>
|
||||
<string name="label_age">Alter</string>
|
||||
<string name="label_country">Land</string>
|
||||
<string name="button_start_chat">Chat starten</string>
|
||||
<string name="gender_female">Weiblich</string>
|
||||
<string name="gender_male">Männlich</string>
|
||||
<string name="gender_pair">Paar</string>
|
||||
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||
<string name="socket_connected">Socket verbunden</string>
|
||||
<string name="socket_connecting">Socket wird verbunden...</string>
|
||||
<string name="status_online">online</string>
|
||||
<string name="status_connecting">verbindet...</string>
|
||||
<string name="tab_online">Online</string>
|
||||
<string name="tab_search">Suche</string>
|
||||
<string name="tab_inbox">Posteingang</string>
|
||||
<string name="tab_history">Verlauf</string>
|
||||
<string name="tab_console">Konsole</string>
|
||||
<string name="tab_more">Mehr</string>
|
||||
<string name="logout">Verlassen</string>
|
||||
<string name="timeout_in">Timeout in %1$s</string>
|
||||
<string name="no_users_online">Noch keine anderen Nutzer online.</string>
|
||||
<string name="search_username_includes">Benutzername enthält</string>
|
||||
<string name="search_from_age">Von Alter</string>
|
||||
<string name="search_to_age">Bis Alter</string>
|
||||
<string name="search_country">Land</string>
|
||||
<string name="search_genders">Geschlechter</string>
|
||||
<string name="search_all">Alle</string>
|
||||
<string name="search_button">Suchen</string>
|
||||
<string name="search_no_results">Keine Ergebnisse.</string>
|
||||
<string name="search_min_age_error">Das Mindestalter darf nicht größer als das Höchstalter sein.</string>
|
||||
<string name="inbox_empty">Keine ungelesenen Chats.</string>
|
||||
<string name="inbox_new_count">%1$d neu</string>
|
||||
<string name="history_empty">Noch kein Verlauf.</string>
|
||||
<string name="no_message">Keine Nachricht</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="block">Blockieren</string>
|
||||
<string name="unblock">Entsperren</string>
|
||||
<string name="message_placeholder">Nachricht</string>
|
||||
<string name="button_image">Bild</string>
|
||||
<string name="button_send">Senden</string>
|
||||
<string name="button_smileys">Smileys</string>
|
||||
<string name="image_message">Bildnachricht</string>
|
||||
<string name="image_upload_in_progress">Bild wird hochgeladen...</string>
|
||||
<string name="image_upload_success">Bild wurde hochgeladen.</string>
|
||||
<string name="image_upload_failed">Bild-Upload fehlgeschlagen.</string>
|
||||
<string name="image_upload_too_large">Das Bild ist größer als 5 MB.</string>
|
||||
<string name="image_upload_open_failed">Das Bild konnte nicht geöffnet werden.</string>
|
||||
<string name="feedback_created_at">Eingegangen %1$s</string>
|
||||
<string name="feedback_meta_separator"> • </string>
|
||||
<string name="countries_load_error">Länderliste konnte nicht geladen werden: %1$s</string>
|
||||
<string name="user_blocked">%1$s wurde blockiert</string>
|
||||
<string name="user_unblocked">%1$s wurde entsperrt</string>
|
||||
<string name="feedback_title">Feedback</string>
|
||||
<string name="feedback_comment">Kommentar</string>
|
||||
<string name="feedback_send">Feedback senden</string>
|
||||
<string name="feedback_saved">Feedback wurde gespeichert.</string>
|
||||
<string name="feedback_empty">Noch kein Feedback vorhanden.</string>
|
||||
<string name="anonymous">Anonym</string>
|
||||
<string name="feedback_admin_user">Admin-Benutzer</string>
|
||||
<string name="feedback_admin_password">Passwort</string>
|
||||
<string name="feedback_admin_login">Admin-Login</string>
|
||||
<string name="feedback_admin_logout">Admin abmelden</string>
|
||||
<string name="feedback_delete">Löschen</string>
|
||||
<string name="console_title">Konsole</string>
|
||||
<string name="console_placeholder">/Befehl oder Admin-Login-Eingabe senden</string>
|
||||
<string name="console_send">Senden</string>
|
||||
<string name="console_empty">Noch keine Konsolen-Ausgabe.</string>
|
||||
<string name="more_title">Mehr</string>
|
||||
<string name="more_feedback">Feedback</string>
|
||||
<string name="more_partners">Partner</string>
|
||||
<string name="more_faq">FAQ</string>
|
||||
<string name="more_rules">Regeln</string>
|
||||
<string name="more_safety">Sicherheit</string>
|
||||
<string name="more_imprint">Impressum</string>
|
||||
<string name="more_back">Zur Übersicht</string>
|
||||
<string name="partners_intro">Empfehlungen und befreundete Projekte für unsere Community.</string>
|
||||
<string name="faq_intro">Antworten auf häufige Fragen zum Chat.</string>
|
||||
<string name="rules_intro">Grundregeln für respektvollen Chat.</string>
|
||||
<string name="safety_intro">Tipps für Privatsphäre und sichere Nutzung.</string>
|
||||
<string name="imprint_intro">Rechtliche Hinweise und Kontaktdaten.</string>
|
||||
<string name="external_link">Externer Link</string>
|
||||
<string name="faq_title">Häufige Fragen</string>
|
||||
<string name="rules_title">Chat-Regeln</string>
|
||||
<string name="safety_title">Sicherheit und Privatsphäre</string>
|
||||
<string name="imprint_title">Impressum</string>
|
||||
<string name="partners_title">Partner</string>
|
||||
<string name="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.</string>
|
||||
<string name="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.</string>
|
||||
<string name="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.</string>
|
||||
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-es/strings.xml
Normal file
41
android/app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">Entrar al chat</string>
|
||||
<string name="landing_copy">Compacto, rápido y sin rodeos. Crea tu perfil y empieza una conversación al instante.</string>
|
||||
<string name="feature_worldwide_chat">Chat mundial</string>
|
||||
<string name="feature_image_exchange">Intercambio de imágenes</string>
|
||||
<string name="feature_compact_controls">Uso compacto</string>
|
||||
<string name="profile_title">Iniciar perfil</string>
|
||||
<string name="profile_copy">Solo hacen falta unos pocos datos para empezar.</string>
|
||||
<string name="label_nick">Introduce tu apodo para el chat</string>
|
||||
<string name="label_gender">Género</string>
|
||||
<string name="label_age">Edad</string>
|
||||
<string name="label_country">País</string>
|
||||
<string name="button_start_chat">Iniciar chat</string>
|
||||
<string name="gender_female">Mujer</string>
|
||||
<string name="gender_male">Hombre</string>
|
||||
<string name="gender_pair">Pareja</string>
|
||||
<string name="gender_trans_mf">Transgénero (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgénero (F->M)</string>
|
||||
<string name="tab_search">Buscar</string>
|
||||
<string name="tab_inbox">Bandeja</string>
|
||||
<string name="tab_history">Historial</string>
|
||||
<string name="logout">Salir</string>
|
||||
<string name="status_online">en línea</string>
|
||||
<string name="status_connecting">conectando...</string>
|
||||
<string name="search_username_includes">El nombre de usuario contiene</string>
|
||||
<string name="search_from_age">Desde la edad</string>
|
||||
<string name="search_to_age">Hasta la edad</string>
|
||||
<string name="search_country">País</string>
|
||||
<string name="search_genders">Géneros</string>
|
||||
<string name="search_all">Todos</string>
|
||||
<string name="search_button">Buscar</string>
|
||||
<string name="search_no_results">Sin resultados.</string>
|
||||
<string name="search_min_age_error">La edad mínima no debe ser mayor que la edad máxima.</string>
|
||||
<string name="inbox_empty">No hay chats sin leer.</string>
|
||||
<string name="inbox_new_count">%1$d nuevos</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="block">Bloquear</string>
|
||||
<string name="unblock">Desbloquear</string>
|
||||
<string name="button_image">Imagen</string>
|
||||
<string name="button_send">Enviar</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-fr/strings.xml
Normal file
41
android/app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">Entrer dans le chat</string>
|
||||
<string name="landing_copy">Compact, rapide et sans détour. Crée ton profil et commence tout de suite une conversation.</string>
|
||||
<string name="feature_worldwide_chat">Chat mondial</string>
|
||||
<string name="feature_image_exchange">Échange d’images</string>
|
||||
<string name="feature_compact_controls">Utilisation compacte</string>
|
||||
<string name="profile_title">Démarrer le profil</string>
|
||||
<string name="profile_copy">Quelques informations suffisent pour commencer.</string>
|
||||
<string name="label_nick">Indique ton pseudo pour le chat</string>
|
||||
<string name="label_gender">Genre</string>
|
||||
<string name="label_age">Âge</string>
|
||||
<string name="label_country">Pays</string>
|
||||
<string name="button_start_chat">Démarrer le chat</string>
|
||||
<string name="gender_female">Femme</string>
|
||||
<string name="gender_male">Homme</string>
|
||||
<string name="gender_pair">Couple</string>
|
||||
<string name="gender_trans_mf">Transgenre (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgenre (F->M)</string>
|
||||
<string name="tab_search">Recherche</string>
|
||||
<string name="tab_inbox">Boîte</string>
|
||||
<string name="tab_history">Historique</string>
|
||||
<string name="logout">Quitter</string>
|
||||
<string name="status_online">en ligne</string>
|
||||
<string name="status_connecting">connexion...</string>
|
||||
<string name="search_username_includes">Le nom d’utilisateur contient</string>
|
||||
<string name="search_from_age">À partir de l’âge</string>
|
||||
<string name="search_to_age">Jusqu’à l’âge</string>
|
||||
<string name="search_country">Pays</string>
|
||||
<string name="search_genders">Genres</string>
|
||||
<string name="search_all">Tous</string>
|
||||
<string name="search_button">Rechercher</string>
|
||||
<string name="search_no_results">Aucun résultat.</string>
|
||||
<string name="search_min_age_error">L’âge minimum ne doit pas être supérieur à l’âge maximum.</string>
|
||||
<string name="inbox_empty">Aucun chat non lu.</string>
|
||||
<string name="inbox_new_count">%1$d nouveaux</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="block">Bloquer</string>
|
||||
<string name="unblock">Débloquer</string>
|
||||
<string name="button_image">Image</string>
|
||||
<string name="button_send">Envoyer</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-it/strings.xml
Normal file
41
android/app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">Vai direttamente alla chat</string>
|
||||
<string name="landing_copy">Compatto, veloce e senza passaggi inutili. Crea il tuo profilo e inizia subito una conversazione.</string>
|
||||
<string name="feature_worldwide_chat">Chat mondiale</string>
|
||||
<string name="feature_image_exchange">Scambio immagini</string>
|
||||
<string name="feature_compact_controls">Comandi compatti</string>
|
||||
<string name="profile_title">Avvia profilo</string>
|
||||
<string name="profile_copy">Bastano pochi dati per iniziare.</string>
|
||||
<string name="label_nick">Inserisci il tuo nickname per la chat</string>
|
||||
<string name="label_gender">Genere</string>
|
||||
<string name="label_age">Età</string>
|
||||
<string name="label_country">Paese</string>
|
||||
<string name="button_start_chat">Avvia chat</string>
|
||||
<string name="gender_female">Donna</string>
|
||||
<string name="gender_male">Uomo</string>
|
||||
<string name="gender_pair">Coppia</string>
|
||||
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||
<string name="tab_search">Cerca</string>
|
||||
<string name="tab_inbox">Posta</string>
|
||||
<string name="tab_history">Cronologia</string>
|
||||
<string name="logout">Esci</string>
|
||||
<string name="status_online">online</string>
|
||||
<string name="status_connecting">connessione...</string>
|
||||
<string name="search_username_includes">Il nome utente contiene</string>
|
||||
<string name="search_from_age">Dall’età</string>
|
||||
<string name="search_to_age">Fino all’età</string>
|
||||
<string name="search_country">Paese</string>
|
||||
<string name="search_genders">Generi</string>
|
||||
<string name="search_all">Tutti</string>
|
||||
<string name="search_button">Cerca</string>
|
||||
<string name="search_no_results">Nessun risultato.</string>
|
||||
<string name="search_min_age_error">L’età minima non deve essere maggiore dell’età massima.</string>
|
||||
<string name="inbox_empty">Nessuna chat non letta.</string>
|
||||
<string name="inbox_new_count">%1$d nuovi</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="block">Blocca</string>
|
||||
<string name="unblock">Sblocca</string>
|
||||
<string name="button_image">Immagine</string>
|
||||
<string name="button_send">Invia</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-ja/strings.xml
Normal file
41
android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">チャットへ直接</string>
|
||||
<string name="landing_copy">手早く簡単にプロフィールを作成して、すぐに会話を始められます。</string>
|
||||
<string name="feature_worldwide_chat">世界中のチャット</string>
|
||||
<string name="feature_image_exchange">画像交換</string>
|
||||
<string name="feature_compact_controls">シンプル操作</string>
|
||||
<string name="profile_title">プロフィールを開始</string>
|
||||
<string name="profile_copy">開始には少しの情報だけで十分です。</string>
|
||||
<string name="label_nick">チャット用ニックネーム</string>
|
||||
<string name="label_gender">性別</string>
|
||||
<string name="label_age">年齢</string>
|
||||
<string name="label_country">国</string>
|
||||
<string name="button_start_chat">チャット開始</string>
|
||||
<string name="gender_female">女性</string>
|
||||
<string name="gender_male">男性</string>
|
||||
<string name="gender_pair">カップル</string>
|
||||
<string name="gender_trans_mf">トランスジェンダー (M->F)</string>
|
||||
<string name="gender_trans_fm">トランスジェンダー (F->M)</string>
|
||||
<string name="tab_search">検索</string>
|
||||
<string name="tab_inbox">受信箱</string>
|
||||
<string name="tab_history">履歴</string>
|
||||
<string name="logout">退出</string>
|
||||
<string name="status_online">オンライン</string>
|
||||
<string name="status_connecting">接続中...</string>
|
||||
<string name="search_username_includes">ユーザー名に含まれる</string>
|
||||
<string name="search_from_age">年齢から</string>
|
||||
<string name="search_to_age">年齢まで</string>
|
||||
<string name="search_country">国</string>
|
||||
<string name="search_genders">性別</string>
|
||||
<string name="search_all">すべて</string>
|
||||
<string name="search_button">検索</string>
|
||||
<string name="search_no_results">結果がありません。</string>
|
||||
<string name="search_min_age_error">最小年齢は最大年齢以下でなければなりません。</string>
|
||||
<string name="inbox_empty">未読チャットはありません。</string>
|
||||
<string name="inbox_new_count">%1$d 件の新着</string>
|
||||
<string name="back">戻る</string>
|
||||
<string name="block">ブロック</string>
|
||||
<string name="unblock">ブロック解除</string>
|
||||
<string name="button_image">画像</string>
|
||||
<string name="button_send">送信</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-th/strings.xml
Normal file
41
android/app/src/main/res/values-th/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">เข้าสู่แชททันที</string>
|
||||
<string name="landing_copy">รวดเร็ว กระชับ และไม่ซับซ้อน สร้างโปรไฟล์แล้วเริ่มคุยได้ทันที</string>
|
||||
<string name="feature_worldwide_chat">แชททั่วโลก</string>
|
||||
<string name="feature_image_exchange">แลกเปลี่ยนรูปภาพ</string>
|
||||
<string name="feature_compact_controls">ใช้งานง่าย</string>
|
||||
<string name="profile_title">เริ่มโปรไฟล์</string>
|
||||
<string name="profile_copy">กรอกข้อมูลเพียงไม่กี่อย่างก็เริ่มได้</string>
|
||||
<string name="label_nick">ชื่อเล่นสำหรับแชท</string>
|
||||
<string name="label_gender">เพศ</string>
|
||||
<string name="label_age">อายุ</string>
|
||||
<string name="label_country">ประเทศ</string>
|
||||
<string name="button_start_chat">เริ่มแชท</string>
|
||||
<string name="gender_female">หญิง</string>
|
||||
<string name="gender_male">ชาย</string>
|
||||
<string name="gender_pair">คู่</string>
|
||||
<string name="gender_trans_mf">ข้ามเพศ (ชาย->หญิง)</string>
|
||||
<string name="gender_trans_fm">ข้ามเพศ (หญิง->ชาย)</string>
|
||||
<string name="tab_search">ค้นหา</string>
|
||||
<string name="tab_inbox">กล่องข้อความ</string>
|
||||
<string name="tab_history">ประวัติ</string>
|
||||
<string name="logout">ออก</string>
|
||||
<string name="status_online">ออนไลน์</string>
|
||||
<string name="status_connecting">กำลังเชื่อมต่อ...</string>
|
||||
<string name="search_username_includes">ชื่อผู้ใช้มีคำว่า</string>
|
||||
<string name="search_from_age">จากอายุ</string>
|
||||
<string name="search_to_age">ถึงอายุ</string>
|
||||
<string name="search_country">ประเทศ</string>
|
||||
<string name="search_genders">เพศ</string>
|
||||
<string name="search_all">ทั้งหมด</string>
|
||||
<string name="search_button">ค้นหา</string>
|
||||
<string name="search_no_results">ไม่มีผลลัพธ์</string>
|
||||
<string name="search_min_age_error">อายุขั้นต่ำต้องไม่มากกว่าอายุสูงสุด</string>
|
||||
<string name="inbox_empty">ไม่มีแชทที่ยังไม่ได้อ่าน</string>
|
||||
<string name="inbox_new_count">ใหม่ %1$d</string>
|
||||
<string name="back">กลับ</string>
|
||||
<string name="block">บล็อก</string>
|
||||
<string name="unblock">เลิกบล็อก</string>
|
||||
<string name="button_image">รูปภาพ</string>
|
||||
<string name="button_send">ส่ง</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-tl/strings.xml
Normal file
41
android/app/src/main/res/values-tl/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">Diretso sa chat</string>
|
||||
<string name="landing_copy">Mabilis, simple at walang paligoy-ligoy. Gumawa ng profile at magsimula agad ng usapan.</string>
|
||||
<string name="feature_worldwide_chat">Pandaigdigang chat</string>
|
||||
<string name="feature_image_exchange">Palitan ng larawan</string>
|
||||
<string name="feature_compact_controls">Madaling gamitin</string>
|
||||
<string name="profile_title">Simulan ang profile</string>
|
||||
<string name="profile_copy">Ilang detalye lang ang kailangan para makapagsimula.</string>
|
||||
<string name="label_nick">Ilagay ang iyong palayaw sa chat</string>
|
||||
<string name="label_gender">Kasarian</string>
|
||||
<string name="label_age">Edad</string>
|
||||
<string name="label_country">Bansa</string>
|
||||
<string name="button_start_chat">Simulan ang chat</string>
|
||||
<string name="gender_female">Babae</string>
|
||||
<string name="gender_male">Lalaki</string>
|
||||
<string name="gender_pair">Magkapareha</string>
|
||||
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||
<string name="tab_search">Maghanap</string>
|
||||
<string name="tab_inbox">Inbox</string>
|
||||
<string name="tab_history">Kasaysayan</string>
|
||||
<string name="logout">Umalis</string>
|
||||
<string name="status_online">online</string>
|
||||
<string name="status_connecting">kumokonekta...</string>
|
||||
<string name="search_username_includes">Kasama sa username</string>
|
||||
<string name="search_from_age">Mula sa edad</string>
|
||||
<string name="search_to_age">Hanggang edad</string>
|
||||
<string name="search_country">Bansa</string>
|
||||
<string name="search_genders">Kasarian</string>
|
||||
<string name="search_all">Lahat</string>
|
||||
<string name="search_button">Maghanap</string>
|
||||
<string name="search_no_results">Walang resulta.</string>
|
||||
<string name="search_min_age_error">Ang minimum na edad ay hindi dapat mas mataas kaysa maximum na edad.</string>
|
||||
<string name="inbox_empty">Walang hindi pa nababasang chat.</string>
|
||||
<string name="inbox_new_count">%1$d bago</string>
|
||||
<string name="back">Bumalik</string>
|
||||
<string name="block">I-block</string>
|
||||
<string name="unblock">I-unblock</string>
|
||||
<string name="button_image">Larawan</string>
|
||||
<string name="button_send">Ipadala</string>
|
||||
</resources>
|
||||
41
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
41
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<resources>
|
||||
<string name="landing_title">直接进入聊天</string>
|
||||
<string name="landing_copy">简洁、快速、无需复杂步骤。创建个人资料并立即开始聊天。</string>
|
||||
<string name="feature_worldwide_chat">全球聊天</string>
|
||||
<string name="feature_image_exchange">图片交换</string>
|
||||
<string name="feature_compact_controls">简洁操作</string>
|
||||
<string name="profile_title">开始个人资料</string>
|
||||
<string name="profile_copy">只需少量信息即可开始。</string>
|
||||
<string name="label_nick">请输入聊天昵称</string>
|
||||
<string name="label_gender">性别</string>
|
||||
<string name="label_age">年龄</string>
|
||||
<string name="label_country">国家</string>
|
||||
<string name="button_start_chat">开始聊天</string>
|
||||
<string name="gender_female">女性</string>
|
||||
<string name="gender_male">男性</string>
|
||||
<string name="gender_pair">情侣</string>
|
||||
<string name="gender_trans_mf">跨性别 (男->女)</string>
|
||||
<string name="gender_trans_fm">跨性别 (女->男)</string>
|
||||
<string name="tab_search">搜索</string>
|
||||
<string name="tab_inbox">收件箱</string>
|
||||
<string name="tab_history">历史</string>
|
||||
<string name="logout">离开</string>
|
||||
<string name="status_online">在线</string>
|
||||
<string name="status_connecting">正在连接...</string>
|
||||
<string name="search_username_includes">用户名包含</string>
|
||||
<string name="search_from_age">从年龄</string>
|
||||
<string name="search_to_age">到年龄</string>
|
||||
<string name="search_country">国家</string>
|
||||
<string name="search_genders">性别</string>
|
||||
<string name="search_all">全部</string>
|
||||
<string name="search_button">搜索</string>
|
||||
<string name="search_no_results">没有结果。</string>
|
||||
<string name="search_min_age_error">最小年龄不能大于最大年龄。</string>
|
||||
<string name="inbox_empty">没有未读聊天。</string>
|
||||
<string name="inbox_new_count">%1$d 条新消息</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="block">屏蔽</string>
|
||||
<string name="unblock">取消屏蔽</string>
|
||||
<string name="button_image">图片</string>
|
||||
<string name="button_send">发送</string>
|
||||
</resources>
|
||||
103
android/app/src/main/res/values/strings.xml
Normal file
103
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,103 @@
|
||||
<resources>
|
||||
<string name="app_name">YPChat</string>
|
||||
<string name="landing_eyebrow">SingleChat</string>
|
||||
<string name="landing_title">Directly into chat</string>
|
||||
<string name="landing_copy">Compact, fast and without detours. Create your profile and start a conversation right away.</string>
|
||||
<string name="feature_worldwide_chat">Worldwide chat</string>
|
||||
<string name="feature_image_exchange">Image exchange</string>
|
||||
<string name="feature_compact_controls">Compact controls</string>
|
||||
<string name="profile_title">Start profile</string>
|
||||
<string name="profile_copy">A few details are enough to get started.</string>
|
||||
<string name="label_nick">Please enter your chat nickname</string>
|
||||
<string name="label_gender">Gender</string>
|
||||
<string name="label_age">Age</string>
|
||||
<string name="label_country">Country</string>
|
||||
<string name="button_start_chat">Start chat</string>
|
||||
<string name="gender_female">Female</string>
|
||||
<string name="gender_male">Male</string>
|
||||
<string name="gender_pair">Couple</string>
|
||||
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||
<string name="socket_connected">Socket connected</string>
|
||||
<string name="socket_connecting">Connecting socket...</string>
|
||||
<string name="status_online">online</string>
|
||||
<string name="status_connecting">connecting...</string>
|
||||
<string name="tab_online">Online</string>
|
||||
<string name="tab_search">Search</string>
|
||||
<string name="tab_inbox">Inbox</string>
|
||||
<string name="tab_history">History</string>
|
||||
<string name="tab_console">Console</string>
|
||||
<string name="tab_more">More</string>
|
||||
<string name="logout">Logout</string>
|
||||
<string name="timeout_in">Timeout in %1$s</string>
|
||||
<string name="no_users_online">No other users online yet.</string>
|
||||
<string name="search_username_includes">Username contains</string>
|
||||
<string name="search_from_age">From age</string>
|
||||
<string name="search_to_age">To age</string>
|
||||
<string name="search_country">Country</string>
|
||||
<string name="search_genders">Genders</string>
|
||||
<string name="search_all">All</string>
|
||||
<string name="search_button">Search</string>
|
||||
<string name="search_no_results">No results.</string>
|
||||
<string name="search_min_age_error">Minimum age must not be greater than maximum age.</string>
|
||||
<string name="inbox_empty">No unread chats.</string>
|
||||
<string name="inbox_new_count">%1$d new</string>
|
||||
<string name="history_empty">No history yet.</string>
|
||||
<string name="no_message">No message</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="block">Block</string>
|
||||
<string name="unblock">Unblock</string>
|
||||
<string name="message_placeholder">Message</string>
|
||||
<string name="button_image">Image</string>
|
||||
<string name="button_send">Send</string>
|
||||
<string name="button_smileys">Smileys</string>
|
||||
<string name="image_message">Image message</string>
|
||||
<string name="image_upload_in_progress">Uploading image...</string>
|
||||
<string name="image_upload_success">Image uploaded.</string>
|
||||
<string name="image_upload_failed">Image upload failed.</string>
|
||||
<string name="image_upload_too_large">Image is larger than 5 MB.</string>
|
||||
<string name="image_upload_open_failed">Image could not be opened.</string>
|
||||
<string name="feedback_created_at">Received %1$s</string>
|
||||
<string name="feedback_meta_separator"> • </string>
|
||||
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
|
||||
<string name="user_blocked">%1$s has been blocked</string>
|
||||
<string name="user_unblocked">%1$s has been unblocked</string>
|
||||
<string name="feedback_title">Feedback</string>
|
||||
<string name="feedback_comment">Comment</string>
|
||||
<string name="feedback_send">Send feedback</string>
|
||||
<string name="feedback_saved">Feedback saved.</string>
|
||||
<string name="feedback_empty">No feedback yet.</string>
|
||||
<string name="anonymous">Anonymous</string>
|
||||
<string name="feedback_admin_user">Admin user</string>
|
||||
<string name="feedback_admin_password">Password</string>
|
||||
<string name="feedback_admin_login">Admin login</string>
|
||||
<string name="feedback_admin_logout">Logout admin</string>
|
||||
<string name="feedback_delete">Delete</string>
|
||||
<string name="console_title">Console</string>
|
||||
<string name="console_placeholder">Enter /command or admin login input</string>
|
||||
<string name="console_send">Send</string>
|
||||
<string name="console_empty">No console output yet.</string>
|
||||
<string name="more_title">More</string>
|
||||
<string name="more_feedback">Feedback</string>
|
||||
<string name="more_partners">Partners</string>
|
||||
<string name="more_faq">FAQ</string>
|
||||
<string name="more_rules">Rules</string>
|
||||
<string name="more_safety">Safety</string>
|
||||
<string name="more_imprint">Imprint</string>
|
||||
<string name="more_back">Back to overview</string>
|
||||
<string name="partners_intro">Recommended and friendly projects for our community.</string>
|
||||
<string name="faq_intro">Answers to common questions about the chat.</string>
|
||||
<string name="rules_intro">Basic rules for respectful chatting.</string>
|
||||
<string name="safety_intro">Tips for privacy and safer usage.</string>
|
||||
<string name="imprint_intro">Legal notice and contact details.</string>
|
||||
<string name="external_link">External link</string>
|
||||
<string name="faq_title">Frequently Asked Questions</string>
|
||||
<string name="rules_title">Chat Rules</string>
|
||||
<string name="safety_title">Safety and Privacy</string>
|
||||
<string name="imprint_title">Imprint</string>
|
||||
<string name="partners_title">Partners</string>
|
||||
<string name="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.</string>
|
||||
<string name="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.</string>
|
||||
<string name="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.</string>
|
||||
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.</string>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/styles.xml
Normal file
7
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="Theme.YpChat" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:fontFamily">sans</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user