564 lines
19 KiB
Kotlin
564 lines
19 KiB
Kotlin
/*
|
|
* 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
|