wip
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import android.content.ClipDescription
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.paddingFrom
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.draganddrop.mimeTypes
|
||||
import androidx.compose.ui.draganddrop.toAndroidDragEvent
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||
import xyz.magicalbits.smsremote.data.exampleUiState
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Entry point for a conversation screen.
|
||||
*
|
||||
* @param uiState [ConversationUiState] that contains messages to display
|
||||
* @param navigateToProfile User action when navigation to a profile is requested
|
||||
* @param modifier [Modifier] to apply to this layout node
|
||||
* @param onNavIconPressed Sends an event up when the user clicks on the menu
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ConversationContent(
|
||||
uiState: ConversationUiState,
|
||||
navigateToProfile: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
) {
|
||||
val authorMe = stringResource(R.string.author_me)
|
||||
val timeNow = stringResource(id = R.string.now)
|
||||
|
||||
val scrollState = rememberLazyListState()
|
||||
val topBarState = rememberTopAppBarState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var background by remember {
|
||||
mutableStateOf(Color.Transparent)
|
||||
}
|
||||
|
||||
var borderStroke by remember {
|
||||
mutableStateOf(Color.Transparent)
|
||||
}
|
||||
|
||||
val dragAndDropCallback = remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
val clipData = event.toAndroidDragEvent().clipData
|
||||
|
||||
if (clipData.itemCount < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
uiState.addMessage(
|
||||
Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
super.onStarted(event)
|
||||
borderStroke = Color.Red
|
||||
}
|
||||
|
||||
override fun onEntered(event: DragAndDropEvent) {
|
||||
super.onEntered(event)
|
||||
background = Color.Red.copy(alpha = .3f)
|
||||
}
|
||||
|
||||
override fun onExited(event: DragAndDropEvent) {
|
||||
super.onExited(event)
|
||||
background = Color.Transparent
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
super.onEnded(event)
|
||||
background = Color.Transparent
|
||||
borderStroke = Color.Transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ChannelNameBar(
|
||||
channelName = uiState.channelName,
|
||||
channelMembers = uiState.channelMembers,
|
||||
onNavIconPressed = onNavIconPressed,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
// Exclude ime and navigation bar padding so this can be added by the UserInput composable
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.exclude(WindowInsets.navigationBars)
|
||||
.exclude(WindowInsets.ime),
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(paddingValues)
|
||||
.background(color = background)
|
||||
.border(width = 2.dp, color = borderStroke)
|
||||
.dragAndDropTarget(shouldStartDragAndDrop = { event ->
|
||||
event
|
||||
.mimeTypes()
|
||||
.contains(
|
||||
ClipDescription.MIMETYPE_TEXT_PLAIN,
|
||||
)
|
||||
}, target = dragAndDropCallback),
|
||||
) {
|
||||
Messages(
|
||||
messages = uiState.messages,
|
||||
navigateToProfile = navigateToProfile,
|
||||
modifier = Modifier.weight(1f),
|
||||
scrollState = scrollState,
|
||||
)
|
||||
UserInput(
|
||||
onMessageSent = { content ->
|
||||
uiState.addMessage(
|
||||
Message(authorMe, content, timeNow),
|
||||
)
|
||||
},
|
||||
resetScroll = {
|
||||
scope.launch {
|
||||
scrollState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
// let this element handle the padding so that the elevation is shown behind the
|
||||
// navigation bar
|
||||
modifier = Modifier.navigationBarsPadding().imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelNameBar(
|
||||
channelName: String,
|
||||
channelMembers: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
) {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
JetchatAppBar(
|
||||
modifier = modifier,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onNavIconPressed = onNavIconPressed,
|
||||
title = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Channel name
|
||||
Text(
|
||||
text = channelName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
// Number of members
|
||||
Text(
|
||||
text = stringResource(R.string.members, channelMembers),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Search icon
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_search),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.search),
|
||||
)
|
||||
// Info icon
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_info),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.info),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const val ConversationTestTag = "ConversationTestTag"
|
||||
|
||||
@Composable
|
||||
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(modifier = modifier) {
|
||||
|
||||
val authorMe = stringResource(id = R.string.author_me)
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
state = scrollState,
|
||||
modifier = Modifier
|
||||
.testTag(ConversationTestTag)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
for (index in messages.indices) {
|
||||
val prevAuthor = messages.getOrNull(index - 1)?.author
|
||||
val nextAuthor = messages.getOrNull(index + 1)?.author
|
||||
val content = messages[index]
|
||||
val isFirstMessageByAuthor = prevAuthor != content.author
|
||||
val isLastMessageByAuthor = nextAuthor != content.author
|
||||
|
||||
// Hardcode day dividers for simplicity
|
||||
if (index == messages.size - 1) {
|
||||
item {
|
||||
DayHeader("20 Aug")
|
||||
}
|
||||
} else if (index == 2) {
|
||||
item {
|
||||
DayHeader("Today")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Message(
|
||||
onAuthorClick = { name -> navigateToProfile(name) },
|
||||
msg = content,
|
||||
isUserMe = content.author == authorMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump to bottom button shows up when user scrolls past a threshold.
|
||||
// Convert to pixels:
|
||||
val jumpThreshold = with(LocalDensity.current) {
|
||||
JumpToBottomThreshold.toPx()
|
||||
}
|
||||
|
||||
// Show the button if the first visible item is not the first one or if the offset is
|
||||
// greater than the threshold.
|
||||
val jumpToBottomButtonEnabled by remember {
|
||||
derivedStateOf {
|
||||
scrollState.firstVisibleItemIndex != 0 ||
|
||||
scrollState.firstVisibleItemScrollOffset > jumpThreshold
|
||||
}
|
||||
}
|
||||
|
||||
JumpToBottom(
|
||||
// Only show if the scroller is not at the bottom
|
||||
enabled = jumpToBottomButtonEnabled,
|
||||
onClicked = {
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Message(
|
||||
onAuthorClick: (String) -> Unit,
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
) {
|
||||
val borderColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
|
||||
val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
|
||||
Row(modifier = spaceBetweenAuthors) {
|
||||
if (isLastMessageByAuthor) {
|
||||
// Avatar
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onAuthorClick(msg.author) })
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(42.dp)
|
||||
.border(1.5.dp, borderColor, CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Top),
|
||||
painter = painterResource(id = msg.authorImage),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
// Space under avatar
|
||||
Spacer(modifier = Modifier.width(74.dp))
|
||||
}
|
||||
AuthorAndTextMessage(
|
||||
msg = msg,
|
||||
isUserMe = isUserMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
authorClicked = onAuthorClick,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorAndTextMessage(
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
authorClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (isLastMessageByAuthor) {
|
||||
AuthorNameTimestamp(msg)
|
||||
}
|
||||
ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
|
||||
if (isFirstMessageByAuthor) {
|
||||
// Last bubble before next author
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
// Between bubbles
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorNameTimestamp(msg: Message) {
|
||||
// Combine author and timestamp for a11y.
|
||||
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||
Text(
|
||||
text = msg.author,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
.paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = msg.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alignBy(LastBaseline),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||
|
||||
@Composable
|
||||
fun DayHeader(dayString: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.height(16.dp),
|
||||
) {
|
||||
DayHeaderLine()
|
||||
Text(
|
||||
text = dayString,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DayHeaderLine()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.DayHeaderLine() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
|
||||
val backgroundBubbleColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
Column {
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
ClickableMessage(
|
||||
message = message,
|
||||
isUserMe = isUserMe,
|
||||
authorClicked = authorClicked,
|
||||
)
|
||||
}
|
||||
|
||||
message.image?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(it),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.size(160.dp),
|
||||
contentDescription = stringResource(id = R.string.attached_image),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
val styledMessage = messageFormatter(
|
||||
text = message.content,
|
||||
primary = isUserMe,
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = styledMessage,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
onClick = {
|
||||
styledMessage
|
||||
.getStringAnnotations(start = it, end = it)
|
||||
.firstOrNull()
|
||||
?.let { annotation ->
|
||||
when (annotation.tag) {
|
||||
SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
|
||||
SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConversationPreview() {
|
||||
JetchatTheme {
|
||||
ConversationContent(
|
||||
uiState = exampleUiState,
|
||||
navigateToProfile = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ChannelBarPrev() {
|
||||
JetchatTheme {
|
||||
ChannelNameBar(channelName = "composers", channelMembers = 52)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DayHeaderPrev() {
|
||||
DayHeader("Aug 6")
|
||||
}
|
||||
|
||||
private val JumpToBottomThreshold = 56.dp
|
||||
Reference in New Issue
Block a user