diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index a4f8d4a..7d11553 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -29,6 +29,7 @@
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9ab125b..d9514e4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,7 +13,7 @@ plugins {
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
- freeCompilerArgs.add("-Xsuppress-warning=NOTHING_TO_INLINE")
+ freeCompilerArgs.add("-Xwarning-level=NOTHING_TO_INLINE:disabled")
}
}
diff --git a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt b/app/src/main/java/ru/fromchat/api/WebSocketManager.kt
index da62b33..bee5653 100644
--- a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt
+++ b/app/src/main/java/ru/fromchat/api/WebSocketManager.kt
@@ -31,8 +31,7 @@ object WebSocketManager {
private val _messages = MutableSharedFlow(replay = 0, extraBufferCapacity = 64)
val messages = _messages.asSharedFlow()
- @Volatile
- private var globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
+ private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers += handler
diff --git a/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt b/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt
index 3c14de8..41ee46b 100644
--- a/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt
+++ b/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt
@@ -1,6 +1,9 @@
package ru.fromchat.ui
import android.util.Log
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -29,8 +33,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -51,11 +57,15 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.toLocalDateTime
@@ -76,7 +86,7 @@ import ru.fromchat.utils.exclude
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PublicChatScreen() {
val navController = LocalNavController.current
@@ -84,32 +94,40 @@ fun PublicChatScreen() {
val scrollState = rememberScrollState()
val messages = remember { mutableStateListOf() }
+ var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
- apiRequest {
- ApiClient.getMessages()
- }.onSuccess {
- messages.clear()
- messages += it.messages
- }
+ val coroutineContext = coroutineContext
- WebSocketManager.connect()
- WebSocketManager.addGlobalMessageHandler { msg ->
- scope.launch {
- if (msg.type == "newMessage") {
- try {
- messages += Json.decodeFromJsonElement(msg.data!!)
- delay(500)
- scrollState.animateScrollTo(scrollState.maxValue)
- } catch (e: Exception) {
- Log.e("PublicChatScreen", "Failed to process incoming message:", e)
+ CoroutineScope(Dispatchers.IO).launch {
+ apiRequest {
+ ApiClient.getMessages()
+ }.onSuccess {
+ messages.clear()
+ messages += it.messages
+ }
+
+ WebSocketManager.connect()
+ WebSocketManager.addGlobalMessageHandler { msg ->
+ scope.launch {
+ if (msg.type == "newMessage") {
+ 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)
+ delay(500)
+ withContext(coroutineContext) {
+ scrollState.scrollTo(scrollState.maxValue)
+ }
+ loading = false
+ }.join()
}
Scaffold(
@@ -205,85 +223,112 @@ fun PublicChatScreen() {
}
}
) { innerPadding ->
- Column(
- Modifier
- .padding(innerPadding)
- .verticalScroll(scrollState)
- ) {
- @Composable
- fun Message(
- text: String,
- isAuthor: Boolean,
- isRead: Boolean,
- timestamp: String
+ Box(Modifier.fillMaxSize()) {
+ Column(
+ Modifier
+ .padding(innerPadding)
+ .verticalScroll(scrollState)
) {
- var rowWidth by remember { mutableStateOf(0.dp) }
- val density = LocalDensity.current
-
- Row(
- modifier = Modifier
- .padding(horizontal = 16.dp, vertical = 4.dp)
- .fillMaxWidth()
- .onSizeChanged {
- rowWidth = with (density) {
- it.width.toDp()
- }
- },
- horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
+ @Composable
+ fun Message(
+ text: String,
+ isAuthor: Boolean,
+ isRead: Boolean,
+ timestamp: String,
+ username: String
) {
- val background =
- if (isAuthor)
- MaterialTheme.colorScheme.primaryContainer
- else MaterialTheme.colorScheme.surfaceContainer
+ var rowWidth by remember { mutableStateOf(0.dp) }
+ val density = LocalDensity.current
- Column(
- Modifier
- .clip(
- RoundedCornerShape(
- topStart = if (isAuthor) 16.dp else 5.dp,
- topEnd = 16.dp,
- bottomEnd = if (isAuthor) 5.dp else 16.dp,
- bottomStart = 16.dp
- )
- )
- .background(background)
- .padding(10.dp)
- .width(IntrinsicSize.Max)
- .widthIn(max = rowWidth * 0.80f)
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .fillMaxWidth()
+ .onSizeChanged {
+ rowWidth = with(density) {
+ it.width.toDp()
+ }
+ },
+ horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
) {
- Text(text)
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(0.dp, Alignment.End),
- modifier = Modifier.fillMaxWidth()
- ) {
- Spacer(Modifier.weight(1f))
- Text(
- text = timestamp,
- fontSize = 14.sp
- )
- if (isRead) {
- Icon(
- imageVector = Icons.Filled.DoneAll,
- contentDescription = "Read",
- modifier = Modifier.size(17.dp)
+ val background =
+ if (isAuthor)
+ MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.surfaceContainer
+
+ Column(
+ Modifier
+ .clip(
+ RoundedCornerShape(
+ topStart = if (isAuthor) 16.dp else 5.dp,
+ topEnd = 16.dp,
+ bottomEnd = if (isAuthor) 5.dp else 16.dp,
+ bottomStart = 16.dp
+ )
)
+ .background(background)
+ .padding(10.dp)
+ .width(IntrinsicSize.Max)
+ .widthIn(max = rowWidth * 0.80f)
+ ) {
+ if (!isAuthor) {
+ Text(
+ text = username,
+ fontWeight = FontWeight.W600,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Text(text)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(0.dp, Alignment.End),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = timestamp,
+ fontSize = 14.sp
+ )
+ if (isRead) {
+ Icon(
+ imageVector = Icons.Filled.DoneAll,
+ contentDescription = "Read",
+ modifier = Modifier.size(17.dp)
+ )
+ }
}
}
}
}
+
+ messages.forEach {
+ Message(
+ text = it.content,
+ isAuthor = it.username == ApiClient.user!!.username,
+ isRead = it.is_read,
+ timestamp = Instant
+ .parse(it.utcTimestamp)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .format(DATETIME_FORMAT),
+ username = it.username
+ )
+ }
}
- messages.forEach {
- Message(
- text = it.content,
- isAuthor = it.username == ApiClient.user!!.username,
- isRead = it.is_read,
- timestamp = Instant
- .parse(it.utcTimestamp)
- .toLocalDateTime(TimeZone.currentSystemDefault())
- .format(DATETIME_FORMAT)
- )
+ AnimatedVisibility(
+ visible = loading,
+ enter = EnterTransition.None,
+ exit = fadeOut()
+ ) {
+ Box(
+ Modifier
+ .background(MaterialTheme.colorScheme.surface)
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator(Modifier.size(70.dp))
+ }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 669865e..7e86e40 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
[versions]
agp = "8.12.3"
-kotlin = "2.2.10"
+kotlin = "2.2.20"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -11,8 +11,10 @@ activityCompose = "1.11.0"
composeBom = "2025.09.00"
materialIconsExtended = "1.7.8"
navigationCompose = "2.9.4"
-ktor = "3.2.3"
+ktor = "3.3.0"
kotlinxSerializationJson = "1.9.0"
+compose = "1.9.1"
+material3 = "1.5.0-alpha04"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -23,13 +25,13 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
-androidx-ui = { group = "androidx.compose.ui", name = "ui" }
-androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
-androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
-androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
-androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
-androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }