diff --git a/app/src/main/java/ru/fromchat/api/Http.kt b/app/src/main/java/ru/fromchat/api/ApiClient.kt similarity index 68% rename from app/src/main/java/ru/fromchat/api/Http.kt rename to app/src/main/java/ru/fromchat/api/ApiClient.kt index c1e2da7..ebd215a 100644 --- a/app/src/main/java/ru/fromchat/api/Http.kt +++ b/app/src/main/java/ru/fromchat/api/ApiClient.kt @@ -7,6 +7,8 @@ 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.bearerAuth +import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType @@ -44,6 +46,10 @@ object ApiClient { var token: String? = null private set + @Volatile + var user: User? = null + private set + suspend fun login(request: LoginRequest) = http .post("https://fromchat.ru/api/login") { @@ -52,7 +58,10 @@ object ApiClient { } .failOnError() .body() - .also { token = it.token } + .also { + token = it.token + user = it.user + } suspend fun register(request: RegisterRequest) = http @@ -61,4 +70,22 @@ object ApiClient { setBody(request) } .failOnError() + + suspend fun getMessages() = + http + .get("https://fromchat.ru/api/get_messages") { + contentType(ContentType.Application.Json) + } + .failOnError() + .body() + + suspend fun send(message: String) = + http + .post("https://fromchat.ru/api/send_message") { + contentType(ContentType.Application.Json) + bearerAuth(token!!) + setBody(SendMessageRequest(message)) + } + .failOnError() + .body() } \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/api/Models.kt b/app/src/main/java/ru/fromchat/api/Models.kt index d93ac1d..7e17356 100644 --- a/app/src/main/java/ru/fromchat/api/Models.kt +++ b/app/src/main/java/ru/fromchat/api/Models.kt @@ -1,6 +1,7 @@ package ru.fromchat.api import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class LoginRequest( @@ -38,6 +39,35 @@ data class LoginResponse( val token: String ) +@Serializable +data class MessagesResponse( + val status: String, + val messages: List +) + +@Serializable +data class Message( + val id: Int, + val content: String, + val timestamp: String, + val is_read: Boolean, + val is_edited: Boolean, + val username: String, + val profile_picture: String?, + val reply_to: Message? +) + +@Serializable +data class SendMessageRequest( + val content: String +) + +@Serializable +data class SendMessageResponse( + val status: String, + val message: Message +) + // WebSocket types mirror frontend src/core/types.d.ts @Serializable data class WebSocketCredentials( @@ -55,6 +85,6 @@ data class WebSocketError( data class WebSocketMessage( val type: String, val credentials: WebSocketCredentials? = null, - val data: kotlinx.serialization.json.JsonElement? = null, + val data: JsonElement? = null, val error: WebSocketError? = null ) \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/api/Utils.kt b/app/src/main/java/ru/fromchat/api/Utils.kt index cadbb90..34948c0 100644 --- a/app/src/main/java/ru/fromchat/api/Utils.kt +++ b/app/src/main/java/ru/fromchat/api/Utils.kt @@ -1,11 +1,12 @@ package ru.fromchat.api +import android.util.Log import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException suspend inline fun apiRequest( - onError: (String, Exception) -> Unit, - onSuccess: (Response) -> Unit, + onError: (String, Exception) -> Unit = { _, _ -> }, + onSuccess: (Response) -> Unit = {}, request: suspend () -> Response ): Result { try { @@ -13,16 +14,18 @@ suspend inline fun apiRequest( onSuccess(response) return Result.success(response) } catch (e: ClientRequestException) { - onError( - if (e.response.status.value in arrayOf(401, 403)) { - e.response.body().detail - } else { - "Unexpected error" - }, - e - ) + val message = if (e.response.status.value in arrayOf(401, 403)) { + e.response.body().detail + } else { + "Unexpected error" + } + + Log.e("API", "API request failed: $message, exception:", e) + + onError(message, e) return Result.failure(e) } catch (e: Exception) { + Log.e("API", "API request failed:", e) onError("Unexpected error", e) return Result.failure(e) } diff --git a/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt b/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt index d83e576..efe3ae4 100644 --- a/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt +++ b/app/src/main/java/ru/fromchat/api/WebSocketAPI.kt @@ -1,5 +1,6 @@ package ru.fromchat.api +import android.util.Log import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket import io.ktor.http.HttpMethod @@ -42,6 +43,7 @@ object WebSocketManager { @Volatile private var session: DefaultClientWebSocketSession? = null fun connect() { + Log.d("WebSocketManager", "Connecting to WebSocket") if (connecting) return connecting = true @@ -61,20 +63,25 @@ object WebSocketManager { ) { session = this + Log.d("WebSocketManager", "WebSocket connected") for (frame in incoming) { val text = (frame as? Frame.Text)?.readText() ?: continue + Log.d("WebSocketManager", "Received payload: $text") try { - val msg = json.decodeFromString(WebSocketMessage.serializer(), text) + val msg = json.decodeFromString(text) globalHandler?.invoke(msg) _messages.emit(msg) - } catch (_: Throwable) { + } catch (e: Throwable) { + Log.w("WebSocketManager", "Received malformed payload:", e) // ignore malformed } } } - } catch (_: Throwable) { + } catch (e: Throwable) { + Log.w("WebSocketManager", "An error occurred:", e) delay(3000) } finally { + Log.w("WebSocketManager", "WebSocket disconnected") session = null } } diff --git a/app/src/main/java/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/src/main/java/ru/fromchat/ui/chat/PublicChatScreen.kt index 5163958..48c406d 100644 --- a/app/src/main/java/ru/fromchat/ui/chat/PublicChatScreen.kt +++ b/app/src/main/java/ru/fromchat/ui/chat/PublicChatScreen.kt @@ -1,7 +1,9 @@ package ru.fromchat.ui.chat +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -21,6 +23,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -35,6 +38,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,12 +49,51 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import ru.fromchat.api.ApiClient +import ru.fromchat.api.Message +import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.apiRequest import ru.fromchat.ui.LocalNavController @OptIn(ExperimentalMaterial3Api::class) @Composable fun PublicChatScreen() { val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val messages = remember { mutableStateListOf() } + + LaunchedEffect(Unit) { + val response = apiRequest { + ApiClient.getMessages() + } + + if (response.isSuccess) { + messages.clear() + messages += response.getOrThrow().messages + } + + WebSocketManager.connect() + WebSocketManager.setGlobalMessageHandler { msg -> + scope.launch { + try { + messages += Json.decodeFromJsonElement(msg.data!!) + delay(500) + scrollState.animateScrollTo(scrollState.maxValue) + } catch (e: Exception) { + Log.e("PublicChatScreen", "Failed to process incoming message:", e) + } + } + } + + delay(500) + scrollState.animateScrollTo(scrollState.maxValue) + } Scaffold( topBar = { @@ -93,12 +139,35 @@ fun PublicChatScreen() { modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4) + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + decorator = { innerTextField -> + Box { + if (message.text.isEmpty()) { + Text( + text = "Write a message...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + } ) IconButton( onClick = { - // send + scope.launch { + val response = apiRequest { + ApiClient.send(message.text.toString()) + } + + message.setTextAndPlaceCursorAtEnd("") + + if (response.isSuccess) { + messages += response.getOrThrow().message + delay(500) + scrollState.animateScrollTo(scrollState.maxValue) + } + } } ) { Icon( @@ -112,7 +181,7 @@ fun PublicChatScreen() { Column( Modifier .padding(innerPadding) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) ) { @Composable fun Message( @@ -169,18 +238,12 @@ fun PublicChatScreen() { } } - repeat(100) { + messages.forEach { Message( - text = "test", - isAuthor = true, - isRead = true, - timestamp = "9:41" - ) - Message( - text = "test2", - isAuthor = false, - isRead = true, - timestamp = "9:41" + text = it.content, + isAuthor = it.username == ApiClient.user!!.username, + isRead = it.is_read, + timestamp = it.timestamp ) } }