mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
+1
@@ -29,6 +29,7 @@
|
|||||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ plugins {
|
|||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
freeCompilerArgs.add("-Xsuppress-warning=NOTHING_TO_INLINE")
|
freeCompilerArgs.add("-Xwarning-level=NOTHING_TO_INLINE:disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ object WebSocketManager {
|
|||||||
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
|
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
|
||||||
val messages = _messages.asSharedFlow()
|
val messages = _messages.asSharedFlow()
|
||||||
|
|
||||||
@Volatile
|
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
|
||||||
private var globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
|
|
||||||
|
|
||||||
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||||
globalHandlers += handler
|
globalHandlers += handler
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
import android.util.Log
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
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.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.DoneAll
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
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.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.format
|
import kotlinx.datetime.format
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
@@ -76,7 +86,7 @@ import ru.fromchat.utils.exclude
|
|||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PublicChatScreen() {
|
fun PublicChatScreen() {
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
@@ -84,32 +94,40 @@ fun PublicChatScreen() {
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
val messages = remember { mutableStateListOf<Message>() }
|
val messages = remember { mutableStateListOf<Message>() }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
apiRequest {
|
val coroutineContext = coroutineContext
|
||||||
ApiClient.getMessages()
|
|
||||||
}.onSuccess {
|
|
||||||
messages.clear()
|
|
||||||
messages += it.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
WebSocketManager.connect()
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
apiRequest {
|
||||||
scope.launch {
|
ApiClient.getMessages()
|
||||||
if (msg.type == "newMessage") {
|
}.onSuccess {
|
||||||
try {
|
messages.clear()
|
||||||
messages += Json.decodeFromJsonElement<Message>(msg.data!!)
|
messages += it.messages
|
||||||
delay(500)
|
}
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
|
||||||
} catch (e: Exception) {
|
WebSocketManager.connect()
|
||||||
Log.e("PublicChatScreen", "Failed to process incoming message:", e)
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
|
scope.launch {
|
||||||
|
if (msg.type == "newMessage") {
|
||||||
|
try {
|
||||||
|
messages += Json.decodeFromJsonElement<Message>(msg.data!!)
|
||||||
|
delay(500)
|
||||||
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("PublicChatScreen", "Failed to process incoming message:", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
delay(500)
|
delay(500)
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
withContext(coroutineContext) {
|
||||||
|
scrollState.scrollTo(scrollState.maxValue)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -205,85 +223,112 @@ fun PublicChatScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Box(Modifier.fillMaxSize()) {
|
||||||
Modifier
|
Column(
|
||||||
.padding(innerPadding)
|
Modifier
|
||||||
.verticalScroll(scrollState)
|
.padding(innerPadding)
|
||||||
) {
|
.verticalScroll(scrollState)
|
||||||
@Composable
|
|
||||||
fun Message(
|
|
||||||
text: String,
|
|
||||||
isAuthor: Boolean,
|
|
||||||
isRead: Boolean,
|
|
||||||
timestamp: String
|
|
||||||
) {
|
) {
|
||||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
@Composable
|
||||||
val density = LocalDensity.current
|
fun Message(
|
||||||
|
text: String,
|
||||||
Row(
|
isAuthor: Boolean,
|
||||||
modifier = Modifier
|
isRead: Boolean,
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
timestamp: String,
|
||||||
.fillMaxWidth()
|
username: String
|
||||||
.onSizeChanged {
|
|
||||||
rowWidth = with (density) {
|
|
||||||
it.width.toDp()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
|
|
||||||
) {
|
) {
|
||||||
val background =
|
var rowWidth by remember { mutableStateOf(0.dp) }
|
||||||
if (isAuthor)
|
val density = LocalDensity.current
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
|
|
||||||
Column(
|
Row(
|
||||||
Modifier
|
modifier = Modifier
|
||||||
.clip(
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
RoundedCornerShape(
|
.fillMaxWidth()
|
||||||
topStart = if (isAuthor) 16.dp else 5.dp,
|
.onSizeChanged {
|
||||||
topEnd = 16.dp,
|
rowWidth = with(density) {
|
||||||
bottomEnd = if (isAuthor) 5.dp else 16.dp,
|
it.width.toDp()
|
||||||
bottomStart = 16.dp
|
}
|
||||||
)
|
},
|
||||||
)
|
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
|
||||||
.background(background)
|
|
||||||
.padding(10.dp)
|
|
||||||
.width(IntrinsicSize.Max)
|
|
||||||
.widthIn(max = rowWidth * 0.80f)
|
|
||||||
) {
|
) {
|
||||||
Text(text)
|
val background =
|
||||||
Row(
|
if (isAuthor)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
horizontalArrangement = Arrangement.spacedBy(0.dp, Alignment.End),
|
else MaterialTheme.colorScheme.surfaceContainer
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
Column(
|
||||||
Spacer(Modifier.weight(1f))
|
Modifier
|
||||||
Text(
|
.clip(
|
||||||
text = timestamp,
|
RoundedCornerShape(
|
||||||
fontSize = 14.sp
|
topStart = if (isAuthor) 16.dp else 5.dp,
|
||||||
)
|
topEnd = 16.dp,
|
||||||
if (isRead) {
|
bottomEnd = if (isAuthor) 5.dp else 16.dp,
|
||||||
Icon(
|
bottomStart = 16.dp
|
||||||
imageVector = Icons.Filled.DoneAll,
|
)
|
||||||
contentDescription = "Read",
|
|
||||||
modifier = Modifier.size(17.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 {
|
AnimatedVisibility(
|
||||||
Message(
|
visible = loading,
|
||||||
text = it.content,
|
enter = EnterTransition.None,
|
||||||
isAuthor = it.username == ApiClient.user!!.username,
|
exit = fadeOut()
|
||||||
isRead = it.is_read,
|
) {
|
||||||
timestamp = Instant
|
Box(
|
||||||
.parse(it.utcTimestamp)
|
Modifier
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.format(DATETIME_FORMAT)
|
.fillMaxSize(),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator(Modifier.size(70.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.12.3"
|
agp = "8.12.3"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.20"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
@@ -11,8 +11,10 @@ activityCompose = "1.11.0"
|
|||||||
composeBom = "2025.09.00"
|
composeBom = "2025.09.00"
|
||||||
materialIconsExtended = "1.7.8"
|
materialIconsExtended = "1.7.8"
|
||||||
navigationCompose = "2.9.4"
|
navigationCompose = "2.9.4"
|
||||||
ktor = "3.2.3"
|
ktor = "3.3.0"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
|
compose = "1.9.1"
|
||||||
|
material3 = "1.5.0-alpha04"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
|
||||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" }
|
||||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
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" }
|
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" }
|
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" }
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||||
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||||
|
|||||||
Reference in New Issue
Block a user