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.io.FileInputStream
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ plugins {
|
|||||||
kotlin("plugin.serialization") version "1.9.22"
|
kotlin("plugin.serialization") version "1.9.22"
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget()
|
androidTarget()
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -28,12 +29,19 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
all {
|
||||||
|
languageSettings {
|
||||||
|
optIn("kotlin.RequiresOptIn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(compose.preview)
|
implementation(compose.preview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.adaptive.android)
|
implementation(libs.androidx.adaptive.android)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.slf4j.android)
|
implementation(libs.slf4j.android)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
}
|
}
|
||||||
@@ -48,13 +56,15 @@ kotlin {
|
|||||||
implementation(libs.constraintlayout)
|
implementation(libs.constraintlayout)
|
||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation.compose)
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation(libs.haze)
|
||||||
|
implementation(libs.haze.materials)
|
||||||
|
|
||||||
// Serialization
|
// Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(libs.kotlinx.io.core)
|
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.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
@@ -64,6 +74,10 @@ kotlin {
|
|||||||
// Datetime
|
// Datetime
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
|
// Coil for image loading (multiplatform)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.ktor3)
|
||||||
|
|
||||||
implementation(project(":utils"))
|
implementation(project(":utils"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+11
@@ -19,3 +19,14 @@
|
|||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-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.HttpClient
|
||||||
import io.ktor.client.HttpClientConfig
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
import io.ktor.client.engine.okhttp.OkHttpConfig
|
import okhttp3.Dns
|
||||||
|
|
||||||
actual fun createPlatformHttpClient(
|
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient {
|
||||||
block: HttpClientConfig<*>.() -> Unit
|
return HttpClient(OkHttp) {
|
||||||
): HttpClient {
|
engine {
|
||||||
@Suppress("UNCHECKED_CAST")
|
config {
|
||||||
return HttpClient(OkHttp, block as HttpClientConfig<OkHttpConfig>.() -> Unit)
|
dns(Dns.SYSTEM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,10 @@
|
|||||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
|
<domain-config>
|
||||||
|
<domain includeSubdomains="true">fromchat.ru</domain>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</domain-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
|
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,3 +118,12 @@ fun FromChatTheme(
|
|||||||
content = content
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.Send
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -28,15 +37,23 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.Message
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.message_placeholder
|
import ru.fromchat.message_placeholder
|
||||||
|
|
||||||
|
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInput(
|
fun ChatInput(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -47,7 +64,7 @@ fun ChatInput(
|
|||||||
editingMessage: Message? = null,
|
editingMessage: Message? = null,
|
||||||
onClearReply: () -> Unit,
|
onClearReply: () -> Unit,
|
||||||
onClearEdit: () -> Unit,
|
onClearEdit: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
hazeState: HazeState
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
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
|
// Reply preview
|
||||||
replyTo?.let { reply ->
|
replyTo?.let { reply ->
|
||||||
ReplyPreviewBar(
|
ReplyPreviewBar(
|
||||||
replyTo = reply,
|
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 ->
|
editingMessage?.let { edit ->
|
||||||
EditPreviewBar(
|
EditPreviewBar(
|
||||||
message = edit,
|
message = edit,
|
||||||
onClose = onClearEdit
|
onClose = onClearEdit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
.hazeEffect(hazeState, style = HazeMaterials.thick())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input field
|
// Input field with blur
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.background(Color.Transparent)
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = text,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = onTextChange,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.weight(1f),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
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()
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
val shape = RoundedCornerShape(24.dp)
|
||||||
imageVector = Icons.Default.Send,
|
|
||||||
contentDescription = "Send",
|
OutlinedTextField(
|
||||||
tint = if (text.isNotBlank()) {
|
value = text,
|
||||||
MaterialTheme.colorScheme.primary
|
onValueChange = onTextChange,
|
||||||
} else {
|
modifier = Modifier
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
.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
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
color = Color.Transparent
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -184,11 +246,9 @@ private fun EditPreviewBar(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
color = Color.Transparent
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -222,4 +282,3 @@ private fun EditPreviewBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
package ru.fromchat.ui.chat
|
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.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -33,20 +38,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
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.coroutines.launch
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.TypingData
|
import ru.fromchat.api.TypingData
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
|
import ru.fromchat.back
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
panel: ChatPanel,
|
panel: ChatPanel,
|
||||||
@@ -72,9 +88,11 @@ fun ChatScreen(
|
|||||||
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
@@ -186,21 +204,42 @@ fun ChatScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Column {
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = panelState.title,
|
text = panelState.title,
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
if (typingUsers.isNotEmpty()) {
|
AnimatedContent(
|
||||||
TypingIndicator(
|
targetState = typingUsers.isNotEmpty(),
|
||||||
typingUsers = typingUsers.values.toList(),
|
transitionSpec = {
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
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 = {
|
actions = {
|
||||||
if (panel.showCallButton()) {
|
if (panel.showCallButton()) {
|
||||||
IconButton(onClick = { /* TODO: Handle call */ }) {
|
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 ->
|
) { innerPadding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures {
|
detectTapGestures {
|
||||||
// Close context menu on outside tap
|
// Close context menu on outside tap
|
||||||
@@ -236,23 +322,23 @@ fun ChatScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize()
|
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
|
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||||
LazyColumn(
|
items(
|
||||||
state = listState,
|
items = panelState.messages,
|
||||||
modifier = Modifier.weight(1f),
|
key = { it.id }
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
) { message ->
|
||||||
reverseLayout = false
|
val isAuthor = message.user_id == currentUserId
|
||||||
) {
|
var tapPosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||||
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(
|
MessageItem(
|
||||||
message = message,
|
message = message,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
@@ -269,32 +355,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,9 +377,9 @@ fun ChatScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
panel.handleDeleteMessage(message.id)
|
panel.handleDeleteMessage(message.id)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,16 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
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
|
import ru.fromchat.api.Message
|
||||||
|
|
||||||
data class ContextMenuState(
|
data class ContextMenuState(
|
||||||
@@ -41,6 +46,7 @@ data class ContextMenuState(
|
|||||||
val position: IntOffset = IntOffset(0, 0)
|
val position: IntOffset = IntOffset(0, 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageContextMenu(
|
fun MessageContextMenu(
|
||||||
state: ContextMenuState,
|
state: ContextMenuState,
|
||||||
@@ -49,12 +55,13 @@ fun MessageContextMenu(
|
|||||||
onReply: (Message) -> Unit,
|
onReply: (Message) -> Unit,
|
||||||
onEdit: (Message) -> Unit,
|
onEdit: (Message) -> Unit,
|
||||||
onDelete: (Message) -> Unit,
|
onDelete: (Message) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
hazeState: HazeState
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isOpen,
|
visible = state.isOpen,
|
||||||
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.9f, animationSpec = tween(200)),
|
enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(250)),
|
||||||
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150))
|
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.8f, animationSpec = tween(150))
|
||||||
) {
|
) {
|
||||||
if (state.isOpen && state.message != null) {
|
if (state.isOpen && state.message != null) {
|
||||||
Popup(
|
Popup(
|
||||||
@@ -69,9 +76,13 @@ fun MessageContextMenu(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(160.dp)
|
.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),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
color = Color.Transparent
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Reply button (always shown)
|
// 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.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -15,10 +16,8 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -26,8 +25,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.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.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -37,6 +39,7 @@ import ru.fromchat.api.Message
|
|||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageItem(
|
fun MessageItem(
|
||||||
message: Message,
|
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) {
|
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))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,17 +90,6 @@ fun MessageItem(
|
|||||||
.widthIn(max = 280.dp),
|
.widthIn(max = 280.dp),
|
||||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
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
|
// Reply preview
|
||||||
message.reply_to?.let { replyTo ->
|
message.reply_to?.let { replyTo ->
|
||||||
ReplyPreview(
|
ReplyPreview(
|
||||||
@@ -121,6 +99,7 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Message bubble
|
// Message bubble
|
||||||
|
val isDark = isSystemInDarkTheme()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(
|
.clip(
|
||||||
@@ -131,22 +110,54 @@ fun MessageItem(
|
|||||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.background(
|
.then(
|
||||||
if (isAuthor) {
|
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 {
|
} 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)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
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
|
// Message content
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (isAuthor) {
|
color = if (isAuthor) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
Color.White
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
}
|
}
|
||||||
@@ -163,7 +174,7 @@ fun MessageItem(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = if (isAuthor) {
|
color = if (isAuthor) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
Color.White.copy(alpha = 0.7f)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
}
|
}
|
||||||
@@ -175,7 +186,7 @@ fun MessageItem(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = if (isAuthor) {
|
color = if (isAuthor) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
Color.White.copy(alpha = 0.7f)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
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))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,18 +242,31 @@ private fun ReplyPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
@ExperimentalTime
|
||||||
private fun formatTime(timestamp: String): String {
|
private fun formatTime(timestamp: String): String {
|
||||||
return try {
|
return try {
|
||||||
val instant = Instant.parse(timestamp)
|
val instant = Instant.parse(timestamp)
|
||||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
String.format(
|
val hour = localDateTime.hour.toString().padStart(2, '0')
|
||||||
"%02d:%02d",
|
val minute = localDateTime.minute.toString().padStart(2, '0')
|
||||||
localDateTime.hour,
|
"$hour:$minute"
|
||||||
localDateTime.minute
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} 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.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.client.plugins.logging.LoggingConfig
|
||||||
import io.ktor.client.plugins.logging.SIMPLE
|
import io.ktor.client.plugins.logging.SIMPLE
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
@@ -58,7 +59,7 @@ inline fun HttpClientConfig<*>.defaultRequest(
|
|||||||
* @param settings Lambda to configure the [Logging.Config].
|
* @param settings Lambda to configure the [Logging.Config].
|
||||||
*/
|
*/
|
||||||
inline fun HttpClientConfig<*>.logging(
|
inline fun HttpClientConfig<*>.logging(
|
||||||
crossinline settings: Logging.Config.() -> Unit
|
crossinline settings: LoggingConfig.() -> Unit
|
||||||
) = install(Logging) {
|
) = install(Logging) {
|
||||||
settings()
|
settings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.1"
|
agp = "8.13.1"
|
||||||
androidx-activityCompose = "1.12.0"
|
androidx-activityCompose = "1.12.1"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.17.0"
|
androidx-core-ktx = "1.17.0"
|
||||||
androidx-lifecycle = "2.9.6"
|
androidx-lifecycle = "2.9.6"
|
||||||
|
coilCompose = "3.3.0"
|
||||||
compose-multiplatform = "1.9.3"
|
compose-multiplatform = "1.9.3"
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.6.1-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
|
haze = "1.7.1"
|
||||||
kotlin = "2.2.21"
|
kotlin = "2.2.21"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
kotlinStdlib = "2.2.21"
|
kotlinStdlib = "2.2.21"
|
||||||
@@ -17,10 +19,10 @@ kotlinxCoroutinesCore = "1.10.2"
|
|||||||
kotlinxIoCore = "0.8.2"
|
kotlinxIoCore = "0.8.2"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
activityKtx = "1.12.0"
|
activityKtx = "1.12.1"
|
||||||
navigationCompose = "2.9.1"
|
navigationCompose = "2.9.1"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.0"
|
||||||
ktor = "2.3.12"
|
ktor = "3.3.3"
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
kotlinxDatetime = "0.7.1"
|
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-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-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" }
|
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" }
|
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-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" }
|
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" }
|
androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-android", version.ref = "adaptiveAndroid" }
|
||||||
|
|||||||
Reference in New Issue
Block a user