Implement Liquid Glass blur and fix Websocket on physical devices

This commit is contained in:
2025-12-16 17:00:47 +03:00
Unverified
parent 3d7bb91b2c
commit 07af5a2d24
15 changed files with 623 additions and 176 deletions
+16 -2
View File
@@ -1,3 +1,4 @@
import java.io.FileInputStream
import java.util.Properties
@@ -9,7 +10,7 @@ plugins {
kotlin("plugin.serialization") version "1.9.22"
}
kotlin {
kotlin {
androidTarget()
compilerOptions {
@@ -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"))
}
+11
View File
@@ -19,3 +19,14 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-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>
@@ -118,3 +118,12 @@ fun FromChatTheme(
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)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val shape = RoundedCornerShape(24.dp)
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f),
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 = RoundedCornerShape(24.dp),
shape = shape,
maxLines = 5,
singleLine = false
)
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 = {
if (text.isNotBlank()) {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
}
},
enabled = text.isNotBlank()
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Send,
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (text.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
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,20 +204,41 @@ 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()) {
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()) {
@@ -211,66 +250,31 @@ fun ChatScreen(
}
}
},
scrollBehavior = scrollBehavior
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier.fillMaxSize()
) {
// 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)) }
MessageItem(
message = message,
isAuthor = isAuthor,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPosition
)
},
onTapPosition = { position ->
tapPosition = position
}
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
)
}
}
// Chat input
) {
ChatInput(
text = inputText,
onTextChange = { inputText = it },
@@ -293,10 +297,67 @@ fun ChatScreen(
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = { editingMessage = null },
modifier = Modifier.windowInsetsPadding(WindowInsets.ime)
hazeState = hazeState
)
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
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
) {
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,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPosition
)
},
onTapPosition = { position ->
tapPosition = position
}
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
}
}
// Context menu
MessageContextMenu(
@@ -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 {
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()
}
+9 -3
View File
@@ -1,12 +1,14 @@
[versions]
agp = "8.13.1"
androidx-activityCompose = "1.12.0"
androidx-activityCompose = "1.12.1"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0"
androidx-lifecycle = "2.9.6"
coilCompose = "3.3.0"
compose-multiplatform = "1.9.3"
constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0"
haze = "1.7.1"
kotlin = "2.2.21"
adaptiveAndroid = "1.2.0"
kotlinStdlib = "2.2.21"
@@ -17,10 +19,10 @@ kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0"
material = "1.13.0"
activityKtx = "1.12.0"
activityKtx = "1.12.1"
navigationCompose = "2.9.1"
datastore = "1.2.0"
ktor = "2.3.12"
ktor = "3.3.3"
slf4j = "1.7.36"
kotlinxDatetime = "0.7.1"
@@ -31,7 +33,11 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
jetbrains-kotlinx-io-bytestring = { module = "org.jetbrains.kotlinx:kotlinx-io-bytestring", version.ref = "kotlinxIoBytestring" }
androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-android", version.ref = "adaptiveAndroid" }