extract reply bubble view to the components module

This commit is contained in:
Adam Brown 2022-10-22 11:46:33 +01:00
parent d17ee34d78
commit e024860a77
3 changed files with 170 additions and 235 deletions

View File

@ -32,7 +32,7 @@ data class BubbleMeta(
fun BubbleMeta.isNotSelf() = !this.isSelf fun BubbleMeta.isNotSelf() = !this.isSelf
@Composable @Composable
fun LazyItemScope.AlignedContainer( fun LazyItemScope.AlignedDraggableContainer(
avatar: Avatar, avatar: Avatar,
isSelf: Boolean, isSelf: Boolean,
wasPreviousMessageSameSender: Boolean, wasPreviousMessageSameSender: Boolean,

View File

@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -20,72 +21,156 @@ import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
data class Event(val authorName: String, val edited: Boolean, val time: String) sealed interface BubbleModel {
data class ImageContent(val width: Int?, val height: Int?, val url: String) val event: Event
data class Text(val content: String, override val event: Event) : BubbleModel
data class Encrypted(override val event: Event) : BubbleModel
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel {
data class ImageContent(val width: Int?, val height: Int?, val url: String)
}
data class Reply(val replyingTo: BubbleModel, val reply: BubbleModel) : BubbleModel {
override val event = reply.event
}
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
}
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
@Composable @Composable
fun Bubble(bubble: BubbleMeta, content: @Composable () -> Unit) { fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit) {
Box(modifier = Modifier.padding(start = 6.dp)) { when (model) {
Box( is BubbleModel.Text -> TextBubble(bubble, model, status)
Modifier is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status)
.padding(4.dp) is BubbleModel.Image -> ImageBubble(bubble, model, status)
.clip(bubble.shape) is BubbleModel.Reply -> ReplyBubble(bubble, model, status)
.background(bubble.background)
.height(IntrinsicSize.Max),
) {
content()
}
} }
} }
@Composable @Composable
fun TextBubble(bubble: BubbleMeta, event: Event, textContent: String, status: @Composable () -> Unit) { private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit) {
Bubble(bubble) { Bubble(bubble) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(event, bubble) AuthorName(model.event, bubble)
}
TextContent(bubble, text = textContent)
Footer(event, bubble, status)
} }
TextContent(bubble, text = model.content)
Footer(model.event, bubble, status)
} }
} }
@Composable @Composable
fun EncryptedBubble(bubble: BubbleMeta, event: Event, status: @Composable () -> Unit) { private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit) {
TextBubble(bubble, event, textContent = "Encrypted message", status) TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status)
} }
@Composable @Composable
fun ImageBubble(bubble: BubbleMeta, event: Event, imageContent: ImageContent, status: @Composable () -> Unit, imageRequest: ImageRequest) { private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit) {
Bubble(bubble) { Bubble(bubble) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(event, bubble) AuthorName(model.event, bubble)
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Image( Image(
modifier = Modifier.size(imageContent.scale(LocalDensity.current, LocalConfiguration.current)), modifier = Modifier.size(model.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(model = imageRequest), painter = rememberAsyncImagePainter(model = model.imageRequest),
contentDescription = null, contentDescription = null,
) )
Footer(event, bubble, status) Footer(model.event, bubble, status)
}
} }
} }
private fun ImageContent.scale(density: Density, configuration: Configuration): DpSize { @Composable
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit) {
Bubble(bubble) {
Column(
Modifier
.fillMaxWidth()
.background(
if (bubble.isNotSelf()) SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.1f) else SmallTalkTheme.extendedColors.onSelfBubble.copy(
alpha = 0.2f
), RoundedCornerShape(12.dp)
)
.padding(8.dp)
) {
val replyName = if (!bubble.isNotSelf() && model.isReplyingToSelf()) "You" else model.replyingTo.event.authorName
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = bubble.textColor()
)
Spacer(modifier = Modifier.height(2.dp))
when (val replyingTo = model.replyingTo) {
is BubbleModel.Text -> {
Text(
text = replyingTo.content,
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is BubbleModel.Encrypted -> {
Text(
text = "Encrypted message",
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(replyingTo.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(replyingTo.imageRequest),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
is BubbleModel.Reply -> {
// TODO - a reply to a reply
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
when (val message = model.reply) {
is BubbleModel.Text -> TextContent(bubble, message.content)
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message")
is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(message.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(model = message.imageRequest),
contentDescription = null,
)
}
is BubbleModel.Reply -> {
// TODO - a reply to a reply
}
}
Footer(model.event, bubble, status)
}
}
private fun BubbleModel.Image.ImageContent.scale(density: Density, configuration: Configuration): DpSize {
val height = this@scale.height ?: 250 val height = this@scale.height ?: 250
val width = this@scale.width ?: 250 val width = this@scale.width ?: 250
return with(density) { return with(density) {
@ -101,14 +186,35 @@ private fun ImageContent.scale(density: Density, configuration: Configuration):
} }
} }
private fun Int.scalerFor(max: Float): Float { private fun Int.scalerFor(max: Float): Float {
return max / this return max / this
} }
@Composable @Composable
private fun Footer(event: Event, bubble: BubbleMeta, status: @Composable () -> Unit) { private fun Bubble(bubble: BubbleMeta, content: @Composable () -> Unit) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(bubble.shape)
.background(bubble.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
content()
}
}
}
}
@Composable
private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Composable () -> Unit) {
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 2.dp)) { Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 2.dp)) {
val editedPrefix = if (event.edited) "(edited) " else null val editedPrefix = if (event.edited) "(edited) " else null
Text( Text(
@ -134,7 +240,7 @@ private fun TextContent(bubble: BubbleMeta, text: String) {
} }
@Composable @Composable
private fun AuthorName(event: Event, bubble: BubbleMeta) { private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
Text( Text(
fontSize = 11.sp, fontSize = 11.sp,
text = event.authorName, text = event.authorName,

View File

@ -1,6 +1,5 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.content.res.Configuration
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -24,10 +23,8 @@ import androidx.compose.runtime.*
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -189,208 +186,40 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions
val previousEvent = if (index != 0) state.events[index - 1] else null val previousEvent = if (index != 0) state.events[index - 1] else null
val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id
AlignedContainer( AlignedDraggableContainer(
avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value), avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value),
isSelf = self == item.author.id, isSelf = self == item.author.id,
wasPreviousMessageSameSender = wasPreviousMessageSameSender, wasPreviousMessageSameSender = wasPreviousMessageSameSender,
onReply = { replyActions.onReply(item) }, onReply = { replyActions.onReply(item) },
) { ) {
val event = Event(item.author.displayName ?: item.author.id.value, item.edited, item.time) val event = BubbleModel.Event(item.author.id.value, item.author.displayName ?: item.author.id.value, item.edited, item.time)
val status = @Composable { SendStatus(item) } val status = @Composable { SendStatus(item) }
when (item) { MessageBubble(this, item.toModel(event), status)
}
}
}
}
@Composable
private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (this) {
is RoomEvent.Message -> BubbleModel.Text(this.content, event)
is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
is RoomEvent.Image -> { is RoomEvent.Image -> {
val context = LocalContext.current val context = LocalContext.current
val imageRequest = ImageRequest.Builder(context) val imageRequest = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current) .fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(item.imageMeta.url) .memoryCacheKey(this.imageMeta.url)
.data(item) .data(this)
.build() .build()
ImageBubble(this, event, ImageContent(item.imageMeta.width, item.imageMeta.height, item.imageMeta.url), status, imageRequest) val imageContent = BubbleModel.Image.ImageContent(this.imageMeta.width, this.imageMeta.height, this.imageMeta.url)
} BubbleModel.Image(imageContent, imageRequest, event)
is RoomEvent.Message -> TextBubble(this, event, item.content, status = status)
is RoomEvent.Reply -> ReplyBubble(this, item)
is RoomEvent.Encrypted -> EncryptedBubble(this, event, status)
}
}
}
}
}
private fun RoomEvent.Image.ImageMeta.scale(density: Density, configuration: Configuration): DpSize {
val height = this@scale.height ?: 250
val width = this@scale.width ?: 250
return with(density) {
val scaler = minOf(
height.scalerFor(configuration.screenHeightDp.dp.toPx() * 0.5f),
width.scalerFor(configuration.screenWidthDp.dp.toPx() * 0.6f)
)
DpSize(
width = (width * scaler).toDp(),
height = (height * scaler).toDp(),
)
}
}
private fun Int.scalerFor(max: Float): Float {
return max / this
}
@Composable
private fun BubbleMeta.textColor(): Color {
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
}
@Composable
private fun ReplyBubble(bubble: BubbleMeta, event: RoomEvent.Reply) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(bubble.shape)
.background(bubble.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
val context = LocalContext.current
Column(
Modifier
.fillMaxWidth()
.background(
if (bubble.isNotSelf()) SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.1f) else SmallTalkTheme.extendedColors.onSelfBubble.copy(
alpha = 0.2f
), RoundedCornerShape(12.dp)
)
.padding(8.dp)
) {
val replyName = if (!bubble.isNotSelf() && event.replyingToSelf) "You" else event.replyingTo.author.displayName
?: event.replyingTo.author.id.value
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = bubble.textColor()
)
Spacer(modifier = Modifier.height(2.dp))
when (val replyingTo = event.replyingTo) {
is RoomEvent.Message -> {
Text(
text = replyingTo.content,
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(replyingTo.imageMeta.url)
.data(replyingTo)
.build()
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
} }
is RoomEvent.Reply -> { is RoomEvent.Reply -> {
// TODO - a reply to a reply BubbleModel.Reply(this.replyingTo.toModel(event), this.message.toModel(event))
}
is RoomEvent.Encrypted -> {
Text(
text = "Encrypted message",
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (bubble.isNotSelf()) {
Text(
fontSize = 11.sp,
text = event.message.author.displayName ?: event.message.author.id.value,
maxLines = 1,
color = bubble.textColor()
)
}
when (val message = event.message) {
is RoomEvent.Message -> {
Text(
text = message.content,
color = bubble.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(message)
.memoryCacheKey(message.imageMeta.url)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.build()
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
is RoomEvent.Reply -> {
// TODO - a reply to a reply
}
is RoomEvent.Encrypted -> {
Text(
text = "Encrypted message",
color = bubble.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
fontSize = 9.sp,
text = event.time,
textAlign = TextAlign.End,
color = bubble.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(event.message)
}
}
}
} }
} }
@Composable @Composable
private fun SendStatus(message: RoomEvent) { private fun SendStatus(message: RoomEvent) {
when (val meta = message.meta) { when (val meta = message.meta) {