mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Clean public chat, prepare for new implementation
This commit is contained in:
@@ -27,8 +27,6 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -89,11 +87,5 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
# Uncomment this to preserve the line number information for
|
||||||
# debugging stack traces.
|
# debugging stack traces.
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ fun App() {
|
|||||||
|
|
||||||
composable("chat") { MainScreen() }
|
composable("chat") { MainScreen() }
|
||||||
|
|
||||||
composable("chats/publicChat") { PublicChatScreen() }
|
composable("chats/publicChat") { /* PublicChatScreen() */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.haze
|
|
||||||
import dev.chrisbanes.haze.hazeChild
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RowHeader(
|
fun RowHeader(
|
||||||
@@ -43,38 +35,3 @@ fun RowHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GlassSurface(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
hazeState: HazeState,
|
|
||||||
shape: RoundedCornerShape = RoundedCornerShape(16.dp),
|
|
||||||
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f),
|
|
||||||
borderColor: Color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
|
||||||
borderWidth: androidx.compose.ui.unit.Dp = 1.dp,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
androidx.compose.foundation.layout.Box(
|
|
||||||
modifier = modifier
|
|
||||||
.clip(shape)
|
|
||||||
.hazeChild(state = hazeState)
|
|
||||||
.background(backgroundColor)
|
|
||||||
.border(borderWidth, borderColor, shape)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GlassContainer(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
hazeState: HazeState,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
androidx.compose.foundation.layout.Box(
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,821 +0,0 @@
|
|||||||
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.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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
|
|
||||||
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
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
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.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.DoneAll
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
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
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
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 androidx.compose.ui.window.Popup
|
|
||||||
import androidx.compose.ui.window.PopupProperties
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.haze
|
|
||||||
import dev.chrisbanes.haze.hazeChild
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.format
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import ru.fromchat.DATETIME_FORMAT
|
|
||||||
import ru.fromchat.R
|
|
||||||
import ru.fromchat.api.ApiClient
|
|
||||||
import ru.fromchat.api.Message
|
|
||||||
import ru.fromchat.api.SendMessageRequest
|
|
||||||
import ru.fromchat.api.WebSocketCredentials
|
|
||||||
import ru.fromchat.api.WebSocketManager
|
|
||||||
import ru.fromchat.api.WebSocketMessage
|
|
||||||
import ru.fromchat.api.apiRequest
|
|
||||||
import ru.fromchat.utils.exclude
|
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun PublicChatScreen() {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val hazeState = remember { HazeState() }
|
|
||||||
|
|
||||||
val messages = remember { mutableStateListOf<Message>() }
|
|
||||||
val typingUsers = remember { mutableStateMapOf<Int, String>() }
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
|
||||||
var selectedMessageId by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var showEditDialog by remember { mutableStateOf(false) }
|
|
||||||
var editingMessage by remember { mutableStateOf<Message?>(null) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
var deletingMessageId by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var replyingToMessage by remember { mutableStateOf<Message?>(null) }
|
|
||||||
var menuMessageId by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var menuOffset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
|
||||||
|
|
||||||
var typingJob: Job? = null
|
|
||||||
|
|
||||||
suspend fun scrollDown(animated: Boolean = true) {
|
|
||||||
if (messages.isNotEmpty()) {
|
|
||||||
if (animated) {
|
|
||||||
listState.animateScrollToItem(messages.lastIndex)
|
|
||||||
} else {
|
|
||||||
listState.scrollToItem(messages.lastIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendTyping() {
|
|
||||||
typingJob?.cancel()
|
|
||||||
typingJob = scope.launch {
|
|
||||||
try {
|
|
||||||
WebSocketManager.send(
|
|
||||||
WebSocketMessage(
|
|
||||||
type = "typing",
|
|
||||||
credentials = WebSocketCredentials(
|
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = ApiClient.token!!
|
|
||||||
),
|
|
||||||
data = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
delay(3000)
|
|
||||||
WebSocketManager.send(
|
|
||||||
WebSocketMessage(
|
|
||||||
type = "stopTyping",
|
|
||||||
credentials = WebSocketCredentials(
|
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = ApiClient.token!!
|
|
||||||
),
|
|
||||||
data = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to send typing", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
apiRequest {
|
|
||||||
ApiClient.getMessages()
|
|
||||||
}.onSuccess {
|
|
||||||
messages.clear()
|
|
||||||
messages += it.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
WebSocketManager.connect()
|
|
||||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
|
||||||
scope.launch {
|
|
||||||
when (msg.type) {
|
|
||||||
"newMessage" -> {
|
|
||||||
try {
|
|
||||||
val newMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
|
|
||||||
if (messages.none { it.id == newMessage.id }) {
|
|
||||||
messages += newMessage
|
|
||||||
scrollDown()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process incoming message:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"messageEdited" -> {
|
|
||||||
try {
|
|
||||||
val editedMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
|
|
||||||
val index = messages.indexOfFirst { it.id == editedMessage.id }
|
|
||||||
if (index >= 0) {
|
|
||||||
messages[index] = editedMessage
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process edited message:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"messageDeleted" -> {
|
|
||||||
try {
|
|
||||||
val data = msg.data!!.jsonObject
|
|
||||||
val messageId = data["message_id"]?.toString()?.toIntOrNull()
|
|
||||||
if (messageId != null) {
|
|
||||||
messages.removeAll { it.id == messageId }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process deleted message:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"typing" -> {
|
|
||||||
try {
|
|
||||||
val data = msg.data!!.jsonObject
|
|
||||||
val userId = data["userId"]?.toString()?.toIntOrNull()
|
|
||||||
val username = data["username"]?.toString()
|
|
||||||
if (userId != null && username != null && userId != ApiClient.user?.id) {
|
|
||||||
typingUsers[userId] = username
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process typing:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"stopTyping" -> {
|
|
||||||
try {
|
|
||||||
val data = msg.data!!.jsonObject
|
|
||||||
val userId = data["userId"]?.toString()?.toIntOrNull()
|
|
||||||
if (userId != null) {
|
|
||||||
typingUsers.remove(userId)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process stop typing:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(500)
|
|
||||||
scrollDown(false)
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.public_chat),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.hazeChild(state = hazeState)
|
|
||||||
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f))
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top))
|
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
if (replyingToMessage != null) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Replying to ${replyingToMessage!!.username}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = replyingToMessage!!.content.take(50) + if (replyingToMessage!!.content.length > 50) "..." else "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { replyingToMessage = null },
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Delete,
|
|
||||||
contentDescription = "Cancel reply",
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
val message = rememberTextFieldState()
|
|
||||||
|
|
||||||
LaunchedEffect(message.text) {
|
|
||||||
if (message.text.isNotEmpty()) {
|
|
||||||
sendTyping()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
state = message,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
|
|
||||||
decorator = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (message.text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.message_placeholder),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
val messageText = message.text.trim()
|
|
||||||
if (messageText.isEmpty()) return@launch
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response = WebSocketManager.request(
|
|
||||||
WebSocketMessage(
|
|
||||||
type = "sendMessage",
|
|
||||||
credentials = WebSocketCredentials(
|
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = ApiClient.token!!
|
|
||||||
),
|
|
||||||
data = ApiClient.json.encodeToJsonElement(
|
|
||||||
SendMessageRequest(
|
|
||||||
content = messageText.toString(),
|
|
||||||
reply_to_id = replyingToMessage?.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response != null && response.error == null) {
|
|
||||||
message.setTextAndPlaceCursorAtEnd("")
|
|
||||||
replyingToMessage = null
|
|
||||||
typingJob?.cancel()
|
|
||||||
WebSocketManager.send(
|
|
||||||
WebSocketMessage(
|
|
||||||
type = "stopTyping",
|
|
||||||
credentials = WebSocketCredentials(
|
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = ApiClient.token!!
|
|
||||||
),
|
|
||||||
data = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Log.e("PublicChatScreen", "WebSocket error: ${response?.error}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to send message", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
|
||||||
contentDescription = "Send"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
) {
|
|
||||||
items(messages, { it.id }) { message ->
|
|
||||||
MessageBubble(
|
|
||||||
message = message,
|
|
||||||
isAuthor = message.user_id == ApiClient.user!!.id,
|
|
||||||
hazeState = hazeState,
|
|
||||||
onTap = { offset ->
|
|
||||||
menuMessageId = message.id
|
|
||||||
menuOffset = offset
|
|
||||||
},
|
|
||||||
onEdit = {
|
|
||||||
editingMessage = message
|
|
||||||
showEditDialog = true
|
|
||||||
menuMessageId = null
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
deletingMessageId = message.id
|
|
||||||
showDeleteDialog = true
|
|
||||||
menuMessageId = null
|
|
||||||
},
|
|
||||||
onReply = {
|
|
||||||
replyingToMessage = message
|
|
||||||
menuMessageId = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typingUsers.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
TypingIndicator(
|
|
||||||
users = typingUsers.values.toList(),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menuMessageId != null) {
|
|
||||||
val message = messages.find { it.id == menuMessageId }
|
|
||||||
if (message != null) {
|
|
||||||
MessageMenu(
|
|
||||||
message = message,
|
|
||||||
offset = menuOffset,
|
|
||||||
onDismiss = { menuMessageId = null },
|
|
||||||
onEdit = {
|
|
||||||
editingMessage = message
|
|
||||||
showEditDialog = true
|
|
||||||
menuMessageId = null
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
deletingMessageId = message.id
|
|
||||||
showDeleteDialog = true
|
|
||||||
menuMessageId = null
|
|
||||||
},
|
|
||||||
onReply = {
|
|
||||||
replyingToMessage = message
|
|
||||||
menuMessageId = null
|
|
||||||
},
|
|
||||||
hazeState = hazeState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = loading,
|
|
||||||
enter = EnterTransition.None,
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LoadingIndicator(Modifier.size(70.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEditDialog && editingMessage != null) {
|
|
||||||
EditMessageDialog(
|
|
||||||
message = editingMessage!!,
|
|
||||||
onDismiss = {
|
|
||||||
showEditDialog = false
|
|
||||||
editingMessage = null
|
|
||||||
},
|
|
||||||
onConfirm = { newContent ->
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
apiRequest {
|
|
||||||
ApiClient.editMessage(editingMessage!!.id, newContent)
|
|
||||||
}
|
|
||||||
showEditDialog = false
|
|
||||||
editingMessage = null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to edit message", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteDialog && deletingMessageId != null) {
|
|
||||||
DeleteMessageDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
deletingMessageId = null
|
|
||||||
},
|
|
||||||
onConfirm = {
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
apiRequest {
|
|
||||||
ApiClient.deleteMessage(deletingMessageId!!)
|
|
||||||
}
|
|
||||||
showDeleteDialog = false
|
|
||||||
deletingMessageId = null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to delete message", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
@Composable
|
|
||||||
fun MessageBubble(
|
|
||||||
message: Message,
|
|
||||||
isAuthor: Boolean,
|
|
||||||
hazeState: HazeState,
|
|
||||||
onTap: (androidx.compose.ui.geometry.Offset) -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onReply: () -> Unit
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
val background = if (isAuthor)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = 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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.hazeChild(state = hazeState)
|
|
||||||
.background(background)
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(
|
|
||||||
topStart = if (isAuthor) 16.dp else 5.dp,
|
|
||||||
topEnd = 16.dp,
|
|
||||||
bottomEnd = if (isAuthor) 5.dp else 16.dp,
|
|
||||||
bottomStart = 16.dp
|
|
||||||
))
|
|
||||||
.clickable {
|
|
||||||
onTap(androidx.compose.ui.geometry.Offset(0f, 0f))
|
|
||||||
}
|
|
||||||
.width(IntrinsicSize.Max)
|
|
||||||
.widthIn(max = rowWidth * 0.80f)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(10.dp)
|
|
||||||
) {
|
|
||||||
if (message.reply_to != null) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message.reply_to.username,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = message.reply_to.content,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthor) {
|
|
||||||
Text(
|
|
||||||
text = message.username,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(message.content)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
if (message.is_edited) {
|
|
||||||
Text(
|
|
||||||
text = "(edited)",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = Instant
|
|
||||||
.parse(message.utcTimestamp)
|
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
|
||||||
.format(DATETIME_FORMAT),
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
if (isAuthor && message.is_read) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DoneAll,
|
|
||||||
contentDescription = "Read",
|
|
||||||
modifier = Modifier.size(14.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TypingIndicator(
|
|
||||||
users: List<String>,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (users.size == 1) {
|
|
||||||
"${users[0]} is typing..."
|
|
||||||
} else {
|
|
||||||
"${users.size} people are typing..."
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessageMenu(
|
|
||||||
message: Message,
|
|
||||||
offset: androidx.compose.ui.geometry.Offset,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onReply: () -> Unit,
|
|
||||||
hazeState: HazeState
|
|
||||||
) {
|
|
||||||
val isAuthor = message.user_id == ApiClient.user!!.id
|
|
||||||
|
|
||||||
Popup(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
offset = androidx.compose.ui.unit.IntOffset(offset.x.toInt(), offset.y.toInt()),
|
|
||||||
properties = PopupProperties(focusable = true)
|
|
||||||
) {
|
|
||||||
GlassSurface(
|
|
||||||
hazeState = hazeState,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
MenuItem(
|
|
||||||
icon = Icons.AutoMirrored.Filled.Reply,
|
|
||||||
text = "Reply",
|
|
||||||
onClick = {
|
|
||||||
onReply()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (isAuthor) {
|
|
||||||
MenuItem(
|
|
||||||
icon = Icons.Default.Edit,
|
|
||||||
text = "Edit",
|
|
||||||
onClick = {
|
|
||||||
onEdit()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
MenuItem(
|
|
||||||
icon = Icons.Default.Delete,
|
|
||||||
text = "Delete",
|
|
||||||
onClick = {
|
|
||||||
onDelete()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MenuItem(
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
text: String,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = text,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EditMessageDialog(
|
|
||||||
message: Message,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (String) -> Unit
|
|
||||||
) {
|
|
||||||
var text by remember { mutableStateOf(message.content) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Edit Message") },
|
|
||||||
text = {
|
|
||||||
BasicTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textStyle = LocalTextStyle.current,
|
|
||||||
singleLine = false
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (text.trim().isNotEmpty()) {
|
|
||||||
onConfirm(text.trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DeleteMessageDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: () -> Unit
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Delete Message") },
|
|
||||||
text = { Text("Are you sure you want to delete this message?") },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onConfirm) {
|
|
||||||
Text("Delete")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+10
-19
@@ -1,28 +1,22 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.0"
|
agp = "8.13.1"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.21"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
|
||||||
junitVersion = "1.3.0"
|
|
||||||
espressoCore = "3.7.0"
|
|
||||||
kotlinxDatetime = "0.7.1"
|
kotlinxDatetime = "0.7.1"
|
||||||
lifecycleRuntimeKtx = "2.9.4"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.12.0"
|
||||||
composeBom = "2025.09.01"
|
composeBom = "2025.11.01"
|
||||||
materialIconsExtended = "1.7.8"
|
materialIconsExtended = "1.7.8"
|
||||||
navigationCompose = "2.9.5"
|
navigationCompose = "2.9.6"
|
||||||
ktor = "3.3.0"
|
ktor = "3.3.3"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
compose = "1.9.2"
|
compose = "1.9.5"
|
||||||
material3 = "1.5.0-alpha04"
|
material3 = "1.5.0-alpha09"
|
||||||
haze = "0.7.0"
|
haze = "1.7.1"
|
||||||
|
|
||||||
[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" }
|
||||||
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
|
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
|
||||||
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" }
|
||||||
@@ -30,8 +24,6 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compo
|
|||||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", 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 = { 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-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-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" }
|
||||||
@@ -49,4 +41,3 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
|||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user