restructure
This commit is contained in:
@@ -35,7 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.repository.ErrorRepository
|
import su.reya.coop.repository.ErrorRepository
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.humanReadable
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScreenerCard(room: Room) {
|
||||||
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isContact by remember { mutableStateOf(false) }
|
||||||
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) {
|
||||||
|
scope.launch {
|
||||||
|
// Check contact
|
||||||
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
|
// Get mutual contacts
|
||||||
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
|
// Get the last activity
|
||||||
|
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = profile?.picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "No name",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pubkey.short(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isContact) "Contact" else "Not a contact",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check_circle),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateSeparator(date: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_add_circle
|
||||||
|
import coop.composeapp.generated.resources.ic_audio
|
||||||
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInput(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onUpload: () -> Unit,
|
||||||
|
onMicClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = { Text("Message") },
|
||||||
|
leadingIcon = {
|
||||||
|
IconButton(onClick = onUpload) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_add_circle),
|
||||||
|
contentDescription = "Upload",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = value.isNotEmpty(),
|
||||||
|
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||||
|
label = "send_mic_transition"
|
||||||
|
) { isNotEmpty ->
|
||||||
|
if (isNotEmpty) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_send),
|
||||||
|
contentDescription = "Send"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onMicClick,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_audio),
|
||||||
|
contentDescription = "Speech to Text"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
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.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.URL_REGEX
|
||||||
|
import su.reya.coop.formatAsTime
|
||||||
|
import su.reya.coop.isImageUrl
|
||||||
|
import su.reya.coop.removeImageUrls
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MessageUiModel(
|
||||||
|
val id: String,
|
||||||
|
val annotatedContent: AnnotatedString,
|
||||||
|
val images: List<String>,
|
||||||
|
val timestamp: String,
|
||||||
|
val isMine: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMessageUiModel(
|
||||||
|
event: UnsignedEvent,
|
||||||
|
currentUserPublicKey: PublicKey?,
|
||||||
|
contentColor: Color
|
||||||
|
): MessageUiModel {
|
||||||
|
return remember(event, currentUserPublicKey, contentColor) {
|
||||||
|
val content = event.content()
|
||||||
|
val images = URL_REGEX.findAll(content)
|
||||||
|
.map { it.value }
|
||||||
|
.filter { it.isImageUrl() }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val cleanedContent = content.removeImageUrls()
|
||||||
|
|
||||||
|
val annotatedString = buildAnnotatedString {
|
||||||
|
var lastIndex = 0
|
||||||
|
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||||
|
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||||
|
val url = matchResult.value
|
||||||
|
pushLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = url,
|
||||||
|
styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = contentColor,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append(url)
|
||||||
|
pop()
|
||||||
|
lastIndex = matchResult.range.last + 1
|
||||||
|
}
|
||||||
|
append(cleanedContent.substring(lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUiModel(
|
||||||
|
id = event.id()?.toHex() ?: event.hashCode().toString(),
|
||||||
|
annotatedContent = annotatedString,
|
||||||
|
images = images,
|
||||||
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
|
isMine = event.author() == currentUserPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMessage(model: MessageUiModel) {
|
||||||
|
var isMessageClicked by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val bubbleShape = if (model.isMine) {
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||||
|
|
||||||
|
val contentColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
isMessageClicked = !isMessageClicked
|
||||||
|
},
|
||||||
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
shape = bubbleShape,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.annotatedContent,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.images.forEach { imageUrl ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = "Image from chat",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isMessageClicked,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.timestamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.align(
|
||||||
|
if (model.isMine) Alignment.End else Alignment.Start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -6,17 +6,7 @@ import android.net.Uri
|
|||||||
import android.speech.RecognizerIntent
|
import android.speech.RecognizerIntent
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
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
|
||||||
@@ -30,7 +20,6 @@ import androidx.compose.foundation.layout.ime
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.union
|
import androidx.compose.foundation.layout.union
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
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
|
||||||
@@ -41,25 +30,18 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.ScaffoldDefaults
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -71,34 +53,16 @@ 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.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_add_circle
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_audio
|
|
||||||
import coop.composeapp.generated.resources.ic_cancel
|
|
||||||
import coop.composeapp.generated.resources.ic_check_circle
|
|
||||||
import coop.composeapp.generated.resources.ic_send
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalChatViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
@@ -106,76 +70,10 @@ import su.reya.coop.LocalNostrViewModel
|
|||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.URL_REGEX
|
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.formatAsGroupHeader
|
||||||
import su.reya.coop.formatAsTime
|
|
||||||
import su.reya.coop.humanReadable
|
|
||||||
import su.reya.coop.isImageUrl
|
|
||||||
import su.reya.coop.rememberUiState
|
import su.reya.coop.rememberUiState
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class MessageModel(
|
|
||||||
val id: String,
|
|
||||||
val annotatedContent: AnnotatedString,
|
|
||||||
val images: List<String>,
|
|
||||||
val timestamp: String,
|
|
||||||
val isMine: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberMessageModel(
|
|
||||||
event: UnsignedEvent,
|
|
||||||
currentUserPublicKey: PublicKey?,
|
|
||||||
contentColor: Color
|
|
||||||
): MessageModel {
|
|
||||||
return remember(event, currentUserPublicKey, contentColor) {
|
|
||||||
val content = event.content()
|
|
||||||
val images = URL_REGEX.findAll(content)
|
|
||||||
.map { it.value }
|
|
||||||
.filter { it.isImageUrl() }
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
val cleanedContent = URL_REGEX.replace(content) { result ->
|
|
||||||
if (result.value.isImageUrl()) "" else result.value
|
|
||||||
}.replace(Regex("\\s+"), " ").trim()
|
|
||||||
|
|
||||||
val annotatedString = buildAnnotatedString {
|
|
||||||
var lastIndex = 0
|
|
||||||
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
|
||||||
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
|
||||||
val url = matchResult.value
|
|
||||||
pushLink(
|
|
||||||
LinkAnnotation.Url(
|
|
||||||
url = url,
|
|
||||||
styles = TextLinkStyles(
|
|
||||||
style = SpanStyle(
|
|
||||||
color = contentColor,
|
|
||||||
textDecoration = TextDecoration.Underline,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
append(url)
|
|
||||||
pop()
|
|
||||||
lastIndex = matchResult.range.last + 1
|
|
||||||
}
|
|
||||||
append(cleanedContent.substring(lastIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageModel(
|
|
||||||
id = event.id()?.toHex() ?: event.hashCode().toString(),
|
|
||||||
annotatedContent = annotatedString,
|
|
||||||
images = images,
|
|
||||||
timestamp = event.createdAt().formatAsTime(),
|
|
||||||
isMine = event.author() == currentUserPublicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -374,7 +272,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
||||||
) { event ->
|
) { event ->
|
||||||
val isMine = currentUser?.publicKey == event.author()
|
val isMine = currentUser?.publicKey == event.author()
|
||||||
val uiModel = rememberMessageModel(
|
val uiModel = rememberMessageUiModel(
|
||||||
event = event,
|
event = event,
|
||||||
currentUserPublicKey = currentUser?.publicKey,
|
currentUserPublicKey = currentUser?.publicKey,
|
||||||
contentColor = if (isMine) mineColor else otherColor
|
contentColor = if (isMine) mineColor else otherColor
|
||||||
@@ -485,285 +383,3 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ScreenerCard(room: Room) {
|
|
||||||
val pubkey = room.members.firstOrNull() ?: return
|
|
||||||
|
|
||||||
val nostrViewModel = LocalNostrViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var isContact by remember { mutableStateOf(false) }
|
|
||||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
|
||||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
|
||||||
|
|
||||||
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
|
||||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
LaunchedEffect(pubkey) {
|
|
||||||
scope.launch {
|
|
||||||
// Check contact
|
|
||||||
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
|
||||||
// Get mutual contacts
|
|
||||||
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
|
||||||
// Get the last activity
|
|
||||||
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(top = 48.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Avatar(
|
|
||||||
picture = profile?.picture,
|
|
||||||
description = "Profile picture",
|
|
||||||
modifier = Modifier.size(120.dp),
|
|
||||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = profile?.name ?: "No name",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontFamily = getExpressiveFontFamily()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = pubkey.short(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(
|
|
||||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
|
||||||
),
|
|
||||||
contentDescription = "Warning",
|
|
||||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (isContact) "Contact" else "Not a contact",
|
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(
|
|
||||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
|
||||||
),
|
|
||||||
contentDescription = "Warning",
|
|
||||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_check_circle),
|
|
||||||
contentDescription = "Warning",
|
|
||||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DateSeparator(date: String) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = date,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatMessage(model: MessageModel) {
|
|
||||||
var isMessageClicked by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val bubbleShape = if (model.isMine) {
|
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
|
||||||
} else {
|
|
||||||
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
val containerColor =
|
|
||||||
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
|
||||||
|
|
||||||
val contentColor =
|
|
||||||
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null
|
|
||||||
) {
|
|
||||||
isMessageClicked = !isMessageClicked
|
|
||||||
},
|
|
||||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
if (model.annotatedContent.isNotBlank()) {
|
|
||||||
Surface(
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
shape = bubbleShape,
|
|
||||||
modifier = Modifier.widthIn(max = 280.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = model.annotatedContent,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.images.forEach { imageUrl ->
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.widthIn(max = 280.dp)
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = "Image from chat",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp)),
|
|
||||||
contentScale = ContentScale.FillWidth
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isMessageClicked,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = model.timestamp,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.align(
|
|
||||||
if (model.isMine) Alignment.End else Alignment.Start
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatInput(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
onUpload: () -> Unit,
|
|
||||||
onMicClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
placeholder = { Text("Message") },
|
|
||||||
leadingIcon = {
|
|
||||||
IconButton(onClick = onUpload) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_add_circle),
|
|
||||||
contentDescription = "Upload",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = value.isNotEmpty(),
|
|
||||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
|
||||||
label = "send_mic_transition"
|
|
||||||
) { isNotEmpty ->
|
|
||||||
if (isNotEmpty) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_send),
|
|
||||||
contentDescription = "Send"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onMicClick,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_audio),
|
|
||||||
contentDescription = "Speech to Text"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user