From 07af5a2d2488d0d42f3423b968e31bdfe8953441 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 16 Dec 2025 17:00:47 +0300 Subject: [PATCH] Implement Liquid Glass blur and fix Websocket on physical devices --- composeApp/build.gradle.kts | 20 +- composeApp/proguard-rules.pro | 13 +- .../ru/fromchat/api/ApiClient.android.kt | 16 +- .../res/xml/network_security_config.xml | 6 + .../composeResources/values-ru/strings.xml | 1 + .../commonMain/kotlin/ru/fromchat/ui/Theme.kt | 9 + .../kotlin/ru/fromchat/ui/chat/Avatar.kt | 87 +++++++++ .../ru/fromchat/ui/chat/ChatGradients.kt | 145 ++++++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 163 +++++++++++----- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 181 ++++++++++++------ .../ru/fromchat/ui/chat/MessageContextMenu.kt | 22 ++- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 120 +++++++----- .../ru/fromchat/ui/chat/TypingHandler.kt | 1 + .../kotlin/ru/fromchat/utils/Ktor.kt | 3 +- gradle/libs.versions.toml | 12 +- 15 files changed, 623 insertions(+), 176 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1fed5bd..407e0ce 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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")) } diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index cdc313f..e0d9994 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -18,4 +18,15 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-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 { *; } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt index dbc758c..47700df 100644 --- a/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt +++ b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt @@ -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.() -> Unit) +actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient { + return HttpClient(OkHttp) { + engine { + config { + dns(Dns.SYSTEM) + } + } + block() + } } diff --git a/composeApp/src/androidMain/res/xml/network_security_config.xml b/composeApp/src/androidMain/res/xml/network_security_config.xml index 2681a9e..8c8d4eb 100644 --- a/composeApp/src/androidMain/res/xml/network_security_config.xml +++ b/composeApp/src/androidMain/res/xml/network_security_config.xml @@ -11,4 +11,10 @@ 127.0.0.1 10.0.2.2 + + fromchat.ru + + + + diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 810b791..699e502 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -5,3 +5,4 @@ %1$s, %2$s и еще %3$d печатают… + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt index 600a91b..b4a113e 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt @@ -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 + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt new file mode 100644 index 0000000..4923de0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt @@ -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 + ) + } + } +} + + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt new file mode 100644 index 0000000..371a747 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt @@ -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 { + 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() ?: "" } + } + } +} + + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index ae37a19..e14c9fe 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -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(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( } } } - diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 55c3b15..c123d41 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 ) } } } - diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 03c3f40..1551a7c 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -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( } } } - diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 6a586d6..f003b91 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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) { + "" + } } } diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt index 721a8ab..d3aba55 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt @@ -54,3 +54,4 @@ class PublicChatTypingHandler( } } + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt index 01fbd5d..05c5149 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt @@ -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() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e23a8a..31cc2f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }