mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement Liquid Glass blur and fix Websocket on physical devices
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
@@ -9,13 +10,13 @@ plugins {
|
||||
kotlin("plugin.serialization") version "1.9.22"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
kotlin {
|
||||
androidTarget()
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
@@ -28,12 +29,19 @@ kotlin {
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
all {
|
||||
languageSettings {
|
||||
optIn("kotlin.RequiresOptIn")
|
||||
}
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(compose.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.adaptive.android)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.slf4j.android)
|
||||
implementation(libs.material)
|
||||
}
|
||||
@@ -48,13 +56,15 @@ kotlin {
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlinx.io.core)
|
||||
|
||||
// Ktor
|
||||
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
@@ -64,6 +74,10 @@ kotlin {
|
||||
// Datetime
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
// Coil for image loading (multiplatform)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
||||
implementation(project(":utils"))
|
||||
}
|
||||
|
||||
|
||||
Vendored
+12
-1
@@ -18,4 +18,15 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Keep Ktor classes
|
||||
-keep class io.ktor.** { *; }
|
||||
-keep class kotlinx.coroutines.** { *; }
|
||||
-dontwarn io.ktor.**
|
||||
|
||||
# Keep HttpTimeout plugin specifically
|
||||
-keep class io.ktor.client.plugins.HttpTimeout { *; }
|
||||
-keep class io.ktor.client.plugins.HttpTimeout$* { *; }
|
||||
-keep class io.ktor.client.plugins.HttpTimeout$Plugin { *; }
|
||||
-keep class io.ktor.client.plugins.HttpTimeout$Config { *; }
|
||||
@@ -3,11 +3,15 @@ package ru.fromchat.api
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.engine.okhttp.OkHttpConfig
|
||||
import okhttp3.Dns
|
||||
|
||||
actual fun createPlatformHttpClient(
|
||||
block: HttpClientConfig<*>.() -> Unit
|
||||
): HttpClient {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HttpClient(OkHttp, block as HttpClientConfig<OkHttpConfig>.() -> Unit)
|
||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
dns(Dns.SYSTEM)
|
||||
}
|
||||
}
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,10 @@
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
<domain-config>
|
||||
<domain includeSubdomains="true">fromchat.ru</domain>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
@@ -117,4 +117,13 @@ fun FromChatTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun isAppInDarkTheme(): Boolean {
|
||||
return when (theme) {
|
||||
Theme.AsSystem -> isSystemInDarkTheme()
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import ru.fromchat.core.config.Config
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
profilePictureUrl: String?,
|
||||
displayName: String,
|
||||
size: Dp = 32.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var imageLoadFailed by remember { mutableStateOf(false) }
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (imageLoadFailed) 0f else 1f,
|
||||
animationSpec = tween(300),
|
||||
label = "avatar_fade"
|
||||
)
|
||||
|
||||
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
||||
val initials = remember(displayName) { getInitials(displayName) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(gradient),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (profilePictureUrl != null && !imageLoadFailed) {
|
||||
val fullUrl = if (profilePictureUrl.startsWith("http")) {
|
||||
profilePictureUrl
|
||||
} else {
|
||||
"${Config.getApiBaseUrl()}$profilePictureUrl"
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = fullUrl,
|
||||
contentDescription = displayName,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = {
|
||||
imageLoadFailed = true
|
||||
},
|
||||
onSuccess = {
|
||||
imageLoadFailed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback initials
|
||||
if (imageLoadFailed || profilePictureUrl == null) {
|
||||
Text(
|
||||
text = initials,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Get gradient brush for own messages
|
||||
*/
|
||||
fun getMessageGradient(isDark: Boolean): Brush {
|
||||
return if (isDark) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF9333EA),
|
||||
Color(0xFF6366F1),
|
||||
Color(0xFF2F68C5)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB794F6),
|
||||
Color(0xFF818CF8),
|
||||
Color(0xFF60A5FA)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background gradient brushes for chat background
|
||||
*/
|
||||
fun getBackgroundGradients(isDark: Boolean): List<Brush> {
|
||||
return if (isDark) {
|
||||
listOf(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x26DBA1F9),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x26F3B7BE),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x1AD0C1DA),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
|
||||
radius = 500f
|
||||
)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x15B794F6),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x15F3B7BE),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x0DD0C1DA),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
|
||||
radius = 500f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent gradient from a name for avatar fallback
|
||||
*/
|
||||
fun generateGradientFromName(name: String): Brush {
|
||||
val hash = name.hashCode()
|
||||
val r = abs(hash % 256)
|
||||
val g = abs((hash / 256) % 256)
|
||||
val b = abs((hash / 65536) % 256)
|
||||
|
||||
// Create two colors based on hash for gradient
|
||||
val color1 = Color(
|
||||
red = (r + 100).coerceIn(0, 255) / 255f,
|
||||
green = (g + 100).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 100).coerceIn(0, 255) / 255f
|
||||
)
|
||||
val color2 = Color(
|
||||
red = (r + 50).coerceIn(0, 255) / 255f,
|
||||
green = (g + 50).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 50).coerceIn(0, 255) / 255f
|
||||
)
|
||||
|
||||
return Brush.linearGradient(
|
||||
colors = listOf(color1, color2),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(100f, 100f)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from display name (first 2 words, first letter of each)
|
||||
*/
|
||||
fun getInitials(displayName: String): String {
|
||||
val words = displayName.trim().split("\\s+".toRegex())
|
||||
return when {
|
||||
words.isEmpty() -> "?"
|
||||
words.size == 1 -> {
|
||||
val word = words[0]
|
||||
if (word.length >= 2) {
|
||||
word.take(2).uppercase()
|
||||
} else {
|
||||
word.uppercase() + "?"
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
words.take(2).joinToString("") { it.firstOrNull()?.uppercase() ?: "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,15 +37,23 @@ 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.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.Message
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.message_placeholder
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
text: String,
|
||||
@@ -47,7 +64,7 @@ fun ChatInput(
|
||||
editingMessage: Message? = null,
|
||||
onClearReply: () -> Unit,
|
||||
onClearEdit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
hazeState: HazeState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
@@ -67,12 +84,21 @@ fun ChatInput(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.ime.add(WindowInsets.navigationBars)
|
||||
)
|
||||
) {
|
||||
// Reply preview
|
||||
replyTo?.let { reply ->
|
||||
ReplyPreviewBar(
|
||||
replyTo = reply,
|
||||
onClose = onClearReply
|
||||
onClose = onClearReply,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.hazeEffect(hazeState, style = HazeMaterials.thick())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,50 +106,88 @@ fun ChatInput(
|
||||
editingMessage?.let { edit ->
|
||||
EditPreviewBar(
|
||||
message = edit,
|
||||
onClose = onClearEdit
|
||||
onClose = onClearEdit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.hazeEffect(hazeState, style = HazeMaterials.thick())
|
||||
)
|
||||
}
|
||||
|
||||
// Input field
|
||||
Row(
|
||||
// Input field with blur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(Res.string.message_placeholder),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
maxLines = 5,
|
||||
singleLine = false
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (text.isNotBlank()) {
|
||||
onSend(text.trim())
|
||||
onTextChange("")
|
||||
typingHandler.stopTyping()
|
||||
}
|
||||
},
|
||||
enabled = text.isNotBlank()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (text.isNotBlank()) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.border(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
shape
|
||||
)
|
||||
.clip(shape)
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(Res.string.message_placeholder),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
shape = shape,
|
||||
maxLines = 5,
|
||||
singleLine = false,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
errorContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent
|
||||
),
|
||||
trailingIcon = {
|
||||
if (text.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSend(text.trim())
|
||||
onTextChange("")
|
||||
typingHandler.stopTyping()
|
||||
},
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -138,11 +202,9 @@ private fun ReplyPreviewBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -184,11 +246,9 @@ private fun EditPreviewBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -222,4 +282,3 @@ private fun EditPreviewBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -33,20 +38,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.TypingData
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
panel: ChatPanel,
|
||||
@@ -72,9 +88,11 @@ fun ChatScreen(
|
||||
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
// UI state
|
||||
var inputText by rememberSaveable { mutableStateOf("") }
|
||||
@@ -186,21 +204,42 @@ fun ChatScreen(
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = panelState.title,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
if (typingUsers.isNotEmpty()) {
|
||||
TypingIndicator(
|
||||
typingUsers = typingUsers.values.toList(),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = typingUsers.isNotEmpty(),
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "typing_status"
|
||||
) { hasTyping ->
|
||||
if (hasTyping) {
|
||||
TypingIndicator(
|
||||
typingUsers = typingUsers.values.toList(),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
} else {
|
||||
// Empty space to maintain height
|
||||
Box(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (panel.showCallButton()) {
|
||||
IconButton(onClick = { /* TODO: Handle call */ }) {
|
||||
@@ -211,14 +250,61 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
),
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Column( // New Column to hold ChatInput below the LazyColumn
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 0f,
|
||||
endIntensity = 1f
|
||||
)
|
||||
}
|
||||
) {
|
||||
ChatInput(
|
||||
text = inputText,
|
||||
onTextChange = { inputText = it },
|
||||
onSend = { text ->
|
||||
if (editingMessage != null) {
|
||||
scope.launch {
|
||||
panel.handleEditMessage(editingMessage!!.id, text)
|
||||
editingMessage = null
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
panel.sendMessageWithImmediateDisplay(text, replyTo?.id)
|
||||
replyTo = null
|
||||
}
|
||||
}
|
||||
inputText = ""
|
||||
},
|
||||
typingHandler = panel.getTypingHandler(),
|
||||
replyTo = replyTo,
|
||||
editingMessage = editingMessage,
|
||||
onClearReply = { replyTo = null },
|
||||
onClearEdit = { editingMessage = null },
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
// Close context menu on outside tap
|
||||
@@ -236,23 +322,23 @@ fun ChatScreen(
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
|
||||
reverseLayout = true // Display messages from bottom to top
|
||||
) {
|
||||
// Message list
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
reverseLayout = false
|
||||
) {
|
||||
items(
|
||||
items = panelState.messages,
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
val isAuthor = message.user_id == currentUserId
|
||||
var tapPosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
|
||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||
items(
|
||||
items = panelState.messages,
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
val isAuthor = message.user_id == currentUserId
|
||||
var tapPosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.hazeSource(hazeState)
|
||||
) {
|
||||
MessageItem(
|
||||
message = message,
|
||||
isAuthor = isAuthor,
|
||||
@@ -269,32 +355,7 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chat input
|
||||
ChatInput(
|
||||
text = inputText,
|
||||
onTextChange = { inputText = it },
|
||||
onSend = { text ->
|
||||
if (editingMessage != null) {
|
||||
scope.launch {
|
||||
panel.handleEditMessage(editingMessage!!.id, text)
|
||||
editingMessage = null
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
panel.sendMessageWithImmediateDisplay(text, replyTo?.id)
|
||||
replyTo = null
|
||||
}
|
||||
}
|
||||
inputText = ""
|
||||
},
|
||||
typingHandler = panel.getTypingHandler(),
|
||||
replyTo = replyTo,
|
||||
editingMessage = editingMessage,
|
||||
onClearReply = { replyTo = null },
|
||||
onClearEdit = { editingMessage = null },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.ime)
|
||||
)
|
||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,9 +377,9 @@ fun ChatScreen(
|
||||
scope.launch {
|
||||
panel.handleDeleteMessage(message.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,16 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import ru.fromchat.api.Message
|
||||
|
||||
data class ContextMenuState(
|
||||
@@ -41,6 +46,7 @@ data class ContextMenuState(
|
||||
val position: IntOffset = IntOffset(0, 0)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun MessageContextMenu(
|
||||
state: ContextMenuState,
|
||||
@@ -49,12 +55,13 @@ fun MessageContextMenu(
|
||||
onReply: (Message) -> Unit,
|
||||
onEdit: (Message) -> Unit,
|
||||
onDelete: (Message) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
hazeState: HazeState
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isOpen,
|
||||
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.9f, animationSpec = tween(200)),
|
||||
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150))
|
||||
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(250)),
|
||||
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.8f, animationSpec = tween(150))
|
||||
) {
|
||||
if (state.isOpen && state.message != null) {
|
||||
Popup(
|
||||
@@ -69,9 +76,13 @@ fun MessageContextMenu(
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(160.dp)
|
||||
.shadow(8.dp, RoundedCornerShape(8.dp)),
|
||||
.shadow(8.dp, RoundedCornerShape(8.dp))
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick()
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column {
|
||||
// Reply button (always shown)
|
||||
@@ -160,4 +171,3 @@ private fun ContextMenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,10 +16,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,8 +25,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -37,6 +39,7 @@ import ru.fromchat.api.Message
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun MessageItem(
|
||||
message: Message,
|
||||
@@ -74,24 +77,10 @@ fun MessageItem(
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
|
||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (!isAuthor) {
|
||||
// Profile picture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// TODO: Load profile picture
|
||||
Text(
|
||||
text = message.username.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
@@ -101,17 +90,6 @@ fun MessageItem(
|
||||
.widthIn(max = 280.dp),
|
||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||
) {
|
||||
if (!isAuthor) {
|
||||
// Username
|
||||
Text(
|
||||
text = message.username,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Reply preview
|
||||
message.reply_to?.let { replyTo ->
|
||||
ReplyPreview(
|
||||
@@ -121,6 +99,7 @@ fun MessageItem(
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val isDark = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
@@ -131,22 +110,54 @@ fun MessageItem(
|
||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||
)
|
||||
)
|
||||
.background(
|
||||
.then(
|
||||
if (isAuthor) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
Modifier.shadow(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = 20.dp,
|
||||
bottomEnd = 8.dp
|
||||
),
|
||||
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
||||
)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.background(
|
||||
brush = if (isAuthor) {
|
||||
getMessageGradient(isDark)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
// Username inside bubble (for received messages)
|
||||
if (!isAuthor) {
|
||||
Text(
|
||||
text = message.username,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message content
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isAuthor) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Color.White
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
@@ -163,7 +174,7 @@ fun MessageItem(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 11.sp,
|
||||
color = if (isAuthor) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
Color.White.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
}
|
||||
@@ -175,7 +186,7 @@ fun MessageItem(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 11.sp,
|
||||
color = if (isAuthor) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
Color.White.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
}
|
||||
@@ -186,7 +197,15 @@ fun MessageItem(
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthor) {
|
||||
if (!isAuthor) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// Avatar at bottom
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
size = 32.dp
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -223,18 +242,31 @@ private fun ReplyPreview(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@ExperimentalTime
|
||||
private fun formatTime(timestamp: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(timestamp)
|
||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
String.format(
|
||||
"%02d:%02d",
|
||||
localDateTime.hour,
|
||||
localDateTime.minute
|
||||
)
|
||||
val hour = localDateTime.hour.toString().padStart(2, '0')
|
||||
val minute = localDateTime.minute.toString().padStart(2, '0')
|
||||
"$hour:$minute"
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
// Fallback: try parsing without timezone if it fails
|
||||
try {
|
||||
val parts = timestamp.split("T")
|
||||
if (parts.size == 2) {
|
||||
val timePart = parts[1].split(".")[0]
|
||||
if (timePart.length >= 5) {
|
||||
timePart.take(5) // Return HH:mm
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,3 +54,4 @@ class PublicChatTypingHandler(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.LoggingConfig
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
@@ -58,7 +59,7 @@ inline fun HttpClientConfig<*>.defaultRequest(
|
||||
* @param settings Lambda to configure the [Logging.Config].
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.logging(
|
||||
crossinline settings: Logging.Config.() -> Unit
|
||||
crossinline settings: LoggingConfig.() -> Unit
|
||||
) = install(Logging) {
|
||||
settings()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user