mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix login, improve public chat
This commit is contained in:
@@ -20,3 +20,4 @@ local.properties
|
|||||||
|
|
||||||
keys
|
keys
|
||||||
releases
|
releases
|
||||||
|
release
|
||||||
Generated
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="tabSettings">
|
||||||
|
<map>
|
||||||
|
<entry key="Firebase Crashlytics">
|
||||||
|
<value>
|
||||||
|
<InsightsFilterSettings>
|
||||||
|
<option name="connection">
|
||||||
|
<ConnectionSetting>
|
||||||
|
<option name="appId" value="PLACEHOLDER" />
|
||||||
|
<option name="mobileSdkAppId" value="" />
|
||||||
|
<option name="projectId" value="" />
|
||||||
|
<option name="projectNumber" value="" />
|
||||||
|
</ConnectionSetting>
|
||||||
|
</option>
|
||||||
|
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||||
|
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||||
|
<option name="visibilityType" value="ALL" />
|
||||||
|
</InsightsFilterSettings>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+123
@@ -0,0 +1,123 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JetCodeStyleSettings>
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="XML">
|
||||||
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
<arrangement>
|
||||||
|
<rules>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:android</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:id</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>style</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
</rules>
|
||||||
|
</arrangement>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="kotlin">
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@@ -87,6 +87,7 @@ dependencies {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.json)
|
implementation(libs.ktor.serialization.json)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.haze)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ru.fromchat
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.format.char
|
import kotlinx.datetime.format.char
|
||||||
|
|
||||||
const val API_HOST = "http://10.0.2.2:8301"
|
const val API_HOST = "https://fromchat.ru"
|
||||||
const val WS_API_HOST = "ws://10.0.2.2:8301"
|
const val WS_API_HOST = "wss://fromchat.ru"
|
||||||
|
|
||||||
val DATETIME_FORMAT = LocalDateTime.Format {
|
val DATETIME_FORMAT = LocalDateTime.Format {
|
||||||
day()
|
day()
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import io.ktor.client.plugins.logging.Logger
|
|||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
import io.ktor.client.request.bearerAuth
|
import io.ktor.client.request.bearerAuth
|
||||||
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.put
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
@@ -90,4 +92,22 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.failOnError()
|
.failOnError()
|
||||||
.body<SendMessageResponse>()
|
.body<SendMessageResponse>()
|
||||||
|
|
||||||
|
suspend fun editMessage(messageId: Int, content: String) =
|
||||||
|
http
|
||||||
|
.put("$API_HOST/api/edit_message/$messageId") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
bearerAuth(token!!)
|
||||||
|
setBody(EditMessageRequest(content))
|
||||||
|
}
|
||||||
|
.failOnError()
|
||||||
|
.body<SendMessageResponse>()
|
||||||
|
|
||||||
|
suspend fun deleteMessage(messageId: Int) =
|
||||||
|
http
|
||||||
|
.delete("$API_HOST/api/delete_message/$messageId") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
bearerAuth(token!!)
|
||||||
|
}
|
||||||
|
.failOnError()
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ data class LoginRequest(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class RegisterRequest(
|
data class RegisterRequest(
|
||||||
val username: String,
|
val username: String,
|
||||||
|
val display_name: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
val confirm_password: String
|
val confirm_password: String
|
||||||
)
|
)
|
||||||
@@ -48,13 +49,15 @@ data class MessagesResponse(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Message(
|
data class Message(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
val user_id: Int,
|
||||||
val content: String,
|
val content: String,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
val is_read: Boolean,
|
val is_read: Boolean,
|
||||||
val is_edited: Boolean,
|
val is_edited: Boolean,
|
||||||
val username: String,
|
val username: String,
|
||||||
val profile_picture: String?,
|
val profile_picture: String? = null,
|
||||||
val reply_to: Message?
|
val verified: Boolean? = null,
|
||||||
|
val reply_to: Message? = null
|
||||||
) {
|
) {
|
||||||
val utcTimestamp = "${timestamp}Z"
|
val utcTimestamp = "${timestamp}Z"
|
||||||
}
|
}
|
||||||
@@ -65,6 +68,11 @@ data class SendMessageRequest(
|
|||||||
val reply_to_id: Int? = null
|
val reply_to_id: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EditMessageRequest(
|
||||||
|
val content: String
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SendMessageResponse(
|
data class SendMessageResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
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.material3.Icon
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
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(
|
||||||
@@ -18,23 +23,58 @@ fun RowHeader(
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String
|
subtitle: String
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||||
Icon(
|
androidx.compose.material3.Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 8.dp)
|
.padding(bottom = 8.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
)
|
)
|
||||||
Text(
|
androidx.compose.material3.Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
Text(
|
androidx.compose.material3.Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,8 +33,14 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
|||||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.DoneAll
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -42,11 +50,13 @@ 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
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -62,6 +72,12 @@ 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 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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -69,6 +85,7 @@ import kotlinx.datetime.format
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
import ru.fromchat.DATETIME_FORMAT
|
import ru.fromchat.DATETIME_FORMAT
|
||||||
import ru.fromchat.R
|
import ru.fromchat.R
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
@@ -88,16 +105,60 @@ fun PublicChatScreen() {
|
|||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
val messages = remember { mutableStateListOf<Message>() }
|
val messages = remember { mutableStateListOf<Message>() }
|
||||||
|
val typingUsers = remember { mutableStateMapOf<Int, String>() }
|
||||||
var loading by remember { mutableStateOf(true) }
|
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) {
|
suspend fun scrollDown(animated: Boolean = true) {
|
||||||
Log.d("PublicChatScreen", "Scrolling down")
|
if (messages.isNotEmpty()) {
|
||||||
if (animated) {
|
if (animated) {
|
||||||
listState.animateScrollToItem(messages.lastIndex)
|
listState.animateScrollToItem(messages.lastIndex)
|
||||||
} else {
|
} else {
|
||||||
listState.scrollToItem(messages.lastIndex)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +173,62 @@ fun PublicChatScreen() {
|
|||||||
WebSocketManager.connect()
|
WebSocketManager.connect()
|
||||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (msg.type == "newMessage") {
|
when (msg.type) {
|
||||||
try {
|
"newMessage" -> {
|
||||||
val newMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
|
try {
|
||||||
// Check if message already exists to prevent duplicates
|
val newMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
|
||||||
if (messages.none { it.id == newMessage.id }) {
|
if (messages.none { it.id == newMessage.id }) {
|
||||||
messages += newMessage
|
messages += newMessage
|
||||||
scrollDown()
|
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)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to process incoming message:", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,76 +264,140 @@ fun PublicChatScreen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Row(
|
Column(
|
||||||
Modifier
|
modifier = Modifier
|
||||||
|
.hazeChild(state = hazeState)
|
||||||
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.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()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top))
|
.windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top))
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp),
|
.padding(vertical = 8.dp, horizontal = 12.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
val message = rememberTextFieldState()
|
if (replyingToMessage != null) {
|
||||||
|
Card(
|
||||||
BasicTextField(
|
modifier = Modifier
|
||||||
state = message,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.weight(1f),
|
.padding(bottom = 8.dp),
|
||||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
colors = CardDefaults.cardColors(
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f)
|
||||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
|
)
|
||||||
decorator = { innerTextField ->
|
) {
|
||||||
Box {
|
Row(
|
||||||
if (message.text.isEmpty()) {
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.message_placeholder),
|
text = "Replying to ${replyingToMessage!!.username}",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
innerTextField()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
IconButton(
|
Row(
|
||||||
onClick = {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
scope.launch {
|
) {
|
||||||
val messageText = "${message.text.trim()}"
|
val message = rememberTextFieldState()
|
||||||
if (messageText.isEmpty()) return@launch
|
|
||||||
|
|
||||||
try {
|
LaunchedEffect(message.text) {
|
||||||
val response = WebSocketManager.request(
|
if (message.text.isNotEmpty()) {
|
||||||
WebSocketMessage(
|
sendTyping()
|
||||||
type = "sendMessage",
|
}
|
||||||
credentials = WebSocketCredentials(
|
}
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = ApiClient.token!!
|
BasicTextField(
|
||||||
),
|
state = message,
|
||||||
data = ApiClient.json.encodeToJsonElement(
|
modifier = Modifier.weight(1f),
|
||||||
SendMessageRequest(
|
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
content = messageText
|
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) {
|
if (response != null && response.error == null) {
|
||||||
message.setTextAndPlaceCursorAtEnd("")
|
message.setTextAndPlaceCursorAtEnd("")
|
||||||
Log.d("PublicChatScreen", "Message sent successfully")
|
replyingToMessage = null
|
||||||
// Message will be added to the list via WebSocket "newMessage" event
|
typingJob?.cancel()
|
||||||
} else {
|
WebSocketManager.send(
|
||||||
Log.e("PublicChatScreen", "WebSocket error: ${response?.error}")
|
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)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PublicChatScreen", "Failed to send message", e)
|
|
||||||
// Could show a toast or error message to user here
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Send"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
|
||||||
contentDescription = "Send"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,107 +407,415 @@ fun PublicChatScreen() {
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
@Composable
|
items(messages, { it.id }) { message ->
|
||||||
fun Message(
|
MessageBubble(
|
||||||
text: String,
|
message = message,
|
||||||
isAuthor: Boolean,
|
isAuthor = message.user_id == ApiClient.user!!.id,
|
||||||
isRead: Boolean,
|
hazeState = hazeState,
|
||||||
timestamp: String,
|
onTap = { offset ->
|
||||||
username: String
|
menuMessageId = message.id
|
||||||
) {
|
menuOffset = offset
|
||||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
},
|
||||||
val density = LocalDensity.current
|
onEdit = {
|
||||||
|
editingMessage = message
|
||||||
|
showEditDialog = true
|
||||||
|
menuMessageId = null
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
deletingMessageId = message.id
|
||||||
|
showDeleteDialog = true
|
||||||
|
menuMessageId = null
|
||||||
|
},
|
||||||
|
onReply = {
|
||||||
|
replyingToMessage = message
|
||||||
|
menuMessageId = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
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
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged {
|
.padding(bottom = 4.dp),
|
||||||
rowWidth = with(density) {
|
colors = CardDefaults.cardColors(
|
||||||
it.width.toDp()
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
|
||||||
}
|
)
|
||||||
},
|
|
||||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
|
|
||||||
) {
|
) {
|
||||||
val background =
|
|
||||||
if (isAuthor)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
modifier = Modifier.padding(8.dp)
|
||||||
.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(
|
text = message.reply_to.username,
|
||||||
text = username,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.W600,
|
fontWeight = FontWeight.W600,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.primary
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
)
|
Text(
|
||||||
}
|
text = message.reply_to.content,
|
||||||
Text(text)
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Row(
|
maxLines = 2,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
overflow = TextOverflow.Ellipsis
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(messages, { it.id }) {
|
if (!isAuthor) {
|
||||||
Message(
|
Text(
|
||||||
text = it.content,
|
text = message.username,
|
||||||
isAuthor = it.username == ApiClient.user!!.username,
|
fontWeight = FontWeight.W600,
|
||||||
isRead = it.is_read,
|
maxLines = 1,
|
||||||
timestamp = Instant
|
overflow = TextOverflow.Ellipsis
|
||||||
.parse(it.utcTimestamp)
|
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
|
||||||
.format(DATETIME_FORMAT),
|
|
||||||
username = it.username
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
Text(message.content)
|
||||||
|
Row(
|
||||||
AnimatedVisibility(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
visible = loading,
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
enter = EnterTransition.None,
|
modifier = Modifier.fillMaxWidth()
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
LoadingIndicator(Modifier.size(70.dp))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,45 +12,45 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Color(0xFF91CEF4),
|
primary = Color(0xFFDBB9F9),
|
||||||
onPrimary = Color(0xFF00344A),
|
onPrimary = Color(0xFF3E2458),
|
||||||
primaryContainer = Color(0xFF004C6A),
|
primaryContainer = Color(0xFF563B71),
|
||||||
onPrimaryContainer = Color(0xFFC5E7FF),
|
onPrimaryContainer = Color(0xFFF0DBFF),
|
||||||
secondary = Color(0xFFB6C9D8),
|
secondary = Color(0xFFD0C1DA),
|
||||||
onSecondary = Color(0xFF20333E),
|
onSecondary = Color(0xFF362C3F),
|
||||||
secondaryContainer = Color(0xFF374955),
|
secondaryContainer = Color(0xFF4D4356),
|
||||||
onSecondaryContainer = Color(0xFFD2E5F4),
|
onSecondaryContainer = Color(0xFFEDDDF6),
|
||||||
tertiary = Color(0xFFCBC1E9),
|
tertiary = Color(0xFFF3B7BE),
|
||||||
onTertiary = Color(0xFF332C4C),
|
onTertiary = Color(0xFF4B252B),
|
||||||
tertiaryContainer = Color(0xFF494263),
|
tertiaryContainer = Color(0xFF653A40),
|
||||||
onTertiaryContainer = Color(0xFFE7DEFF),
|
onTertiaryContainer = Color(0xFFFFD9DD),
|
||||||
error = Color(0xFFFFB4AB),
|
error = Color(0xFFFFB4AB),
|
||||||
onError = Color(0xFF690005),
|
onError = Color(0xFF690005),
|
||||||
errorContainer = Color(0xFF93000A),
|
errorContainer = Color(0xFF93000A),
|
||||||
onErrorContainer = Color(0xFFFFDAD6),
|
onErrorContainer = Color(0xFFFFDAD6),
|
||||||
background = Color(0xFF0F1417),
|
background = Color(0xFF151218),
|
||||||
onBackground = Color(0xFFDFE3E7),
|
onBackground = Color(0xFFE8E0E8),
|
||||||
surface = Color(0xFF0F1417),
|
surface = Color(0xFF151218),
|
||||||
onSurface = Color(0xFFDFE3E7),
|
onSurface = Color(0xFFE8E0E8),
|
||||||
surfaceVariant = Color(0xFF41484D),
|
surfaceVariant = Color(0xFF4A454E),
|
||||||
onSurfaceVariant = Color(0xFFC1C7CE),
|
onSurfaceVariant = Color(0xFFCCC4CE),
|
||||||
outline = Color(0xFF8B9297),
|
outline = Color(0xFF968E98),
|
||||||
outlineVariant = Color(0xFF41484D),
|
outlineVariant = Color(0xFF4A454E),
|
||||||
scrim = Color(0xFF000000),
|
scrim = Color(0xFF000000),
|
||||||
inverseSurface = Color(0xFFDFE3E7),
|
inverseSurface = Color(0xFFE8E0E8),
|
||||||
inverseOnSurface = Color(0xFF2C3134),
|
inverseOnSurface = Color(0xFF332F35),
|
||||||
inversePrimary = Color(0xFF1E6586),
|
inversePrimary = Color(0xFF6F528A),
|
||||||
surfaceDim = Color(0xFF0F1417),
|
surfaceDim = Color(0xFF151218),
|
||||||
surfaceBright = Color(0xFF353A3D),
|
surfaceBright = Color(0xFF3C383E),
|
||||||
surfaceContainerLowest = Color(0xFF0A0F12),
|
surfaceContainerLowest = Color(0xFF100D12),
|
||||||
surfaceContainerLow = Color(0xFF181C1F),
|
surfaceContainerLow = Color(0xFF1E1A20),
|
||||||
surfaceContainer = Color(0xFF1C2023),
|
surfaceContainer = Color(0xFF221E24),
|
||||||
surfaceContainerHigh = Color(0xFF262B2E),
|
surfaceContainerHigh = Color(0xFF2C292E),
|
||||||
surfaceContainerHighest = Color(0xFF313539),
|
surfaceContainerHighest = Color(0xFF373339),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Color(0xFF1E6586),
|
primary = Color(0xFF1F6586),
|
||||||
onPrimary = Color(0xFFFFFFFF),
|
onPrimary = Color(0xFFFFFFFF),
|
||||||
primaryContainer = Color(0xFFC5E7FF),
|
primaryContainer = Color(0xFFC5E7FF),
|
||||||
onPrimaryContainer = Color(0xFF004C6A),
|
onPrimaryContainer = Color(0xFF004C6A),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.LoginRequest
|
import ru.fromchat.api.LoginRequest
|
||||||
import ru.fromchat.api.apiRequest
|
import ru.fromchat.api.apiRequest
|
||||||
import ru.fromchat.ui.RowHeader
|
import ru.fromchat.ui.RowHeader
|
||||||
|
import ru.fromchat.utils.HashUtils
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
@@ -95,14 +96,17 @@ fun LoginScreen(
|
|||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive auth secret before sending (matches frontend implementation)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val derived = HashUtils.deriveAuthSecret(username.trim(), password.trim())
|
||||||
|
|
||||||
apiRequest(
|
apiRequest(
|
||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
},
|
},
|
||||||
onSuccess = { onLoginSuccess() }
|
onSuccess = { onLoginSuccess() }
|
||||||
) {
|
) {
|
||||||
ApiClient.login(LoginRequest(username.trim(), password.trim()))
|
ApiClient.login(LoginRequest(username.trim(), derived))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import ru.fromchat.api.RegisterRequest
|
|||||||
import ru.fromchat.api.apiRequest
|
import ru.fromchat.api.apiRequest
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.RowHeader
|
import ru.fromchat.ui.RowHeader
|
||||||
|
import ru.fromchat.utils.HashUtils
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RegisterScreen(
|
fun RegisterScreen(
|
||||||
@@ -44,6 +45,7 @@ fun RegisterScreen(
|
|||||||
) {
|
) {
|
||||||
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
|
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf("") }
|
||||||
|
var displayName by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var confirmPassword by remember { mutableStateOf("") }
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
var alert by remember { mutableStateOf<String?>(null) }
|
var alert by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -82,6 +84,13 @@ fun RegisterScreen(
|
|||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayName,
|
||||||
|
onValueChange = { displayName = it },
|
||||||
|
label = { Text("Display Name") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
@@ -116,7 +125,7 @@ fun RegisterScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Checks
|
// Checks
|
||||||
if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
|
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
|
||||||
alert = alert_error_filling
|
alert = alert_error_filling
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
@@ -128,13 +137,19 @@ fun RegisterScreen(
|
|||||||
alert = alert_error_name_little
|
alert = alert_error_name_little
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
if (displayName.isBlank() || displayName.length > 64) {
|
||||||
|
alert = "Display name must be between 1 and 64 characters"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
if (password.length !in 5..50) {
|
if (password.length !in 5..50) {
|
||||||
alert = alert_error_password_little
|
alert = alert_error_password_little
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the register request
|
// Derive auth secret before sending (matches frontend implementation)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val derived = HashUtils.deriveAuthSecret(username.trim(), password)
|
||||||
|
|
||||||
apiRequest(
|
apiRequest(
|
||||||
onError = { message, _ ->
|
onError = { message, _ ->
|
||||||
alert = message
|
alert = message
|
||||||
@@ -143,9 +158,10 @@ fun RegisterScreen(
|
|||||||
) {
|
) {
|
||||||
ApiClient.register(
|
ApiClient.register(
|
||||||
RegisterRequest(
|
RegisterRequest(
|
||||||
username,
|
username.trim(),
|
||||||
password,
|
displayName.trim(),
|
||||||
confirmPassword
|
derived,
|
||||||
|
derived
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ru.fromchat.utils
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object HashUtils {
|
||||||
|
/**
|
||||||
|
* Derive authentication secret using HKDF (HMAC-based Key Derivation Function)
|
||||||
|
* Matches the frontend implementation in deriveAuthSecret
|
||||||
|
*
|
||||||
|
* @param username The username
|
||||||
|
* @param password The plain password
|
||||||
|
* @return Base64-encoded 32-byte derived secret
|
||||||
|
*/
|
||||||
|
fun deriveAuthSecret(username: String, password: String): String {
|
||||||
|
val salt = "fromchat.user:$username".toByteArray(Charsets.UTF_8)
|
||||||
|
val ikm = password.toByteArray(Charsets.UTF_8)
|
||||||
|
val info = "auth-secret".toByteArray(Charsets.UTF_8)
|
||||||
|
val length = 32
|
||||||
|
|
||||||
|
val prk = hkdfExtract(salt, ikm)
|
||||||
|
val okm = hkdfExpand(prk, info, length)
|
||||||
|
|
||||||
|
return Base64.encodeToString(okm, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HKDF Extract step: PRK = HMAC-Hash(salt, IKM)
|
||||||
|
*/
|
||||||
|
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
val keySpec = SecretKeySpec(salt, "HmacSHA256")
|
||||||
|
mac.init(keySpec)
|
||||||
|
return mac.doFinal(ikm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HKDF Expand step: OKM = HKDF-Expand(PRK, info, L)
|
||||||
|
*/
|
||||||
|
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||||
|
val blocks = mutableListOf<ByteArray>()
|
||||||
|
var previous = ByteArray(0)
|
||||||
|
var counter = 1
|
||||||
|
|
||||||
|
while (blocks.sumOf { it.size } < length) {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
val keySpec = SecretKeySpec(prk, "HmacSHA256")
|
||||||
|
mac.init(keySpec)
|
||||||
|
|
||||||
|
val input = previous + info + byteArrayOf(counter.toByte())
|
||||||
|
previous = mac.doFinal(input)
|
||||||
|
blocks.add(previous)
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = ByteArray(length)
|
||||||
|
var offset = 0
|
||||||
|
for (block in blocks) {
|
||||||
|
val toCopy = minOf(block.size, length - offset)
|
||||||
|
System.arraycopy(block, 0, result, offset, toCopy)
|
||||||
|
offset += toCopy
|
||||||
|
if (offset >= length) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ ktor = "3.3.0"
|
|||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
compose = "1.9.2"
|
compose = "1.9.2"
|
||||||
material3 = "1.5.0-alpha04"
|
material3 = "1.5.0-alpha04"
|
||||||
|
haze = "0.7.0"
|
||||||
|
|
||||||
[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" }
|
||||||
@@ -41,6 +42,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
|
|||||||
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user