mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Make the base
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FromChat">
|
android:theme="@style/Theme.FromChat"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logger
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.URLBuilder
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.API_HOST
|
||||||
|
|
||||||
|
object ApiClient {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val http = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(Logging) {
|
||||||
|
level = LogLevel.INFO
|
||||||
|
logger = object : Logger {
|
||||||
|
override fun log(message: String) {
|
||||||
|
// Replace with Logcat if needed
|
||||||
|
println(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install(WebSockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var token: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private fun apiUrlBuilder(): URLBuilder {
|
||||||
|
return URLBuilder().apply {
|
||||||
|
protocol = io.ktor.http.URLProtocol.HTTPS
|
||||||
|
host = API_HOST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(request: LoginRequest): LoginResponse {
|
||||||
|
// Endpoint exists in web: POST /login with {username,password}
|
||||||
|
val response = http.post("https://fromchat.ru/api/login") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}.body<LoginResponse>()
|
||||||
|
token = response.token
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(request: RegisterRequest) {
|
||||||
|
// Endpoint exists in web: POST /register with {username,password,confirm_password}
|
||||||
|
http.post("https://fromchat.ru/api/register") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val confirm_password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorResponse(
|
||||||
|
val message: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class User(
|
||||||
|
val id: Int,
|
||||||
|
val created_at: String,
|
||||||
|
val last_seen: String,
|
||||||
|
val online: Boolean,
|
||||||
|
val username: String,
|
||||||
|
val admin: Boolean? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val profile_picture: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponse(
|
||||||
|
val user: User,
|
||||||
|
val token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocket types mirror frontend src/core/types.d.ts
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketCredentials(
|
||||||
|
val scheme: String,
|
||||||
|
val credentials: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketError(
|
||||||
|
val code: Int,
|
||||||
|
val detail: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketMessage(
|
||||||
|
val type: String,
|
||||||
|
val credentials: WebSocketCredentials? = null,
|
||||||
|
val data: kotlinx.serialization.json.JsonElement? = null,
|
||||||
|
val error: WebSocketError? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||||
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
|
import io.ktor.http.HttpMethod
|
||||||
|
import io.ktor.http.URLProtocol
|
||||||
|
import io.ktor.http.encodedPath
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.API_HOST
|
||||||
|
|
||||||
|
object WebSocketManager {
|
||||||
|
// Config
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
|
||||||
|
val messages = _messages.asSharedFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var globalHandler: ((WebSocketMessage) -> Unit)? = null
|
||||||
|
|
||||||
|
fun setGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)?) {
|
||||||
|
globalHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
@Volatile private var connecting: Boolean = false
|
||||||
|
@Volatile private var session: DefaultClientWebSocketSession? = null
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (connecting) return
|
||||||
|
connecting = true
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
ApiClient.http.webSocket(
|
||||||
|
method = HttpMethod.Get,
|
||||||
|
host = API_HOST,
|
||||||
|
request = {
|
||||||
|
url {
|
||||||
|
protocol = URLProtocol.WSS
|
||||||
|
host = "fromchat.ru"
|
||||||
|
encodedPath = "/api/chat/ws"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
session = this
|
||||||
|
|
||||||
|
for (frame in incoming) {
|
||||||
|
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||||
|
try {
|
||||||
|
val msg = json.decodeFromString(WebSocketMessage.serializer(), text)
|
||||||
|
globalHandler?.invoke(msg)
|
||||||
|
_messages.emit(msg)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
delay(3000)
|
||||||
|
} finally {
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send(message: WebSocketMessage) {
|
||||||
|
val payload = json.encodeToString(WebSocketMessage.serializer(), message)
|
||||||
|
ApiClient.http.webSocket(
|
||||||
|
method = HttpMethod.Get,
|
||||||
|
host = API_HOST,
|
||||||
|
request = {
|
||||||
|
url {
|
||||||
|
protocol = URLProtocol.WSS
|
||||||
|
host = "fromchat.ru"
|
||||||
|
encodedPath = "/api/chat/ws"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
send(Frame.Text(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? {
|
||||||
|
val ch = Channel<WebSocketMessage>(capacity = 1)
|
||||||
|
val handler: (WebSocketMessage) -> Unit = {
|
||||||
|
ch.trySend(it)
|
||||||
|
}
|
||||||
|
setGlobalMessageHandler(handler)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
send(message)
|
||||||
|
withTimeout(timeoutMs) { ch.receive() }
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
setGlobalMessageHandler(null)
|
||||||
|
ch.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import ru.fromchat.ui.auth.LoginScreen
|
||||||
|
import ru.fromchat.ui.auth.RegisterScreen
|
||||||
|
import ru.fromchat.ui.chat.ChatScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
FromChatTheme {
|
FromChatTheme {
|
||||||
|
val (route, setRoute) = remember { mutableStateOf("login") }
|
||||||
|
when (route) {
|
||||||
|
"login" -> LoginScreen(
|
||||||
|
onLoginSuccess = { setRoute("chat") },
|
||||||
|
onNavigateToRegister = { setRoute("register") }
|
||||||
|
)
|
||||||
|
"register" -> RegisterScreen(onRegistered = { setRoute("login") })
|
||||||
|
"chat" -> ChatScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Login
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.LoginRequest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
onNavigateToRegister: () -> Unit
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var alert by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
RowHeader()
|
||||||
|
|
||||||
|
if (alert != null) {
|
||||||
|
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Имя пользователя") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
alert = "Пожалуйста, заполните все поля"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
ApiClient.login(LoginRequest(username, password))
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
alert = "Неверное имя пользователя или пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text("Войти") }
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = onNavigateToRegister) { Text("Зарегистрируйтесь") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowHeader() {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null)
|
||||||
|
Text("Добро пожаловать!")
|
||||||
|
Text("Войдите в свой аккаунт", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.RegisterRequest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
onRegistered: () -> Unit
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var alert by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.PersonAdd, contentDescription = null)
|
||||||
|
Text("Регистрация")
|
||||||
|
Text("Создайте новый аккаунт", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert != null) {
|
||||||
|
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Имя пользователя") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text("Подтвердите пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
|
||||||
|
alert = "Пожалуйста, заполните все поля"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
if (password != confirmPassword) {
|
||||||
|
alert = "Пароли не совпадают"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
if (username.length !in 3..20) {
|
||||||
|
alert = "Имя пользователя должно быть от 3 до 20 символов"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
if (password.length !in 5..50) {
|
||||||
|
alert = "Пароль должен быть от 5 до 50 символов"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
ApiClient.register(RegisterRequest(username, password, confirmPassword))
|
||||||
|
onRegistered()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
alert = "Ошибка при регистрации"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text("Зарегистрироваться") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import ru.fromchat.api.WebSocketManager
|
||||||
|
import ru.fromchat.api.WebSocketMessage
|
||||||
|
|
||||||
|
data class ChatMessage(val author: String, val content: String)
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BaseChat(
|
||||||
|
chatTitle: String
|
||||||
|
) {
|
||||||
|
val messages = remember { mutableStateListOf<ChatMessage>() }
|
||||||
|
var composing by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
WebSocketManager.connect()
|
||||||
|
WebSocketManager.setGlobalMessageHandler { msg ->
|
||||||
|
// Placeholder: append raw data content if present
|
||||||
|
val text = msg.data?.toString() ?: return@setGlobalMessageHandler
|
||||||
|
messages.add(ChatMessage(author = "Server", content = text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||||
|
Text(chatTitle)
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
|
items(messages) { m ->
|
||||||
|
Text("${m.author}: ${m.content}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = composing,
|
||||||
|
onValueChange = { composing = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = { Text("Сообщение") }
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
if (composing.isNotBlank()) {
|
||||||
|
// Note: server schema for public chat messages is not specified in the repo.
|
||||||
|
// We send a generic WebSocketMessage to match frontend type contract.
|
||||||
|
val payload = WebSocketMessage(
|
||||||
|
type = "public_chat_message",
|
||||||
|
data = buildJsonObject {
|
||||||
|
put("content", JsonPrimitive(composing))
|
||||||
|
put("chatName", JsonPrimitive(chatTitle))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Fire and forget
|
||||||
|
GlobalScope.launch { WebSocketManager.send(payload) }
|
||||||
|
messages.add(ChatMessage(author = "Вы", content = composing))
|
||||||
|
composing = ""
|
||||||
|
}
|
||||||
|
}) { Text("Отправить") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
|
import androidx.compose.material.icons.filled.Mail
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class ChatTab { CHATS, CONTACTS, DMS }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen() {
|
||||||
|
var selectedTab by remember { mutableStateOf(ChatTab.CHATS) }
|
||||||
|
var activeChat by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == ChatTab.CHATS,
|
||||||
|
onClick = { selectedTab = ChatTab.CHATS },
|
||||||
|
label = { Text("Чаты") },
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == ChatTab.CONTACTS,
|
||||||
|
onClick = { selectedTab = ChatTab.CONTACTS },
|
||||||
|
label = { Text("Контакты") },
|
||||||
|
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == ChatTab.DMS,
|
||||||
|
onClick = { selectedTab = ChatTab.DMS },
|
||||||
|
label = { Text("ЛС") },
|
||||||
|
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(paddingValues).padding(12.dp)) {
|
||||||
|
when (selectedTab) {
|
||||||
|
ChatTab.CHATS -> {
|
||||||
|
if (activeChat == null) {
|
||||||
|
Text("Общий чат", modifier = Modifier.clickable { activeChat = "Общий чат" }.padding(8.dp))
|
||||||
|
Text("Общий чат 2", modifier = Modifier.clickable { activeChat = "Общий чат 2" }.padding(8.dp))
|
||||||
|
} else {
|
||||||
|
BaseChat(chatTitle = activeChat!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatTab.CONTACTS -> {
|
||||||
|
Text("Скоро будет...")
|
||||||
|
}
|
||||||
|
ChatTab.DMS -> {
|
||||||
|
Text("Скоро будет...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user