Private
Public Access
1
0

Implements message loading and display

This commit is contained in:
2023-08-24 00:45:18 -07:00
parent 4add2b4674
commit 6375900710
14 changed files with 371 additions and 132 deletions

View File

@@ -36,10 +36,10 @@ fun KordophoneApp(
}
composable(Destination.MessageList.route) {
val conversationID = it.arguments?.getString("id")
MessageListScreen(messages = listOf()) {
val conversationID = it.arguments?.getString("id")!!
MessageListScreen(conversationGUID = conversationID, backAction = {
navController.popBackStack()
}
})
}
}
}

View File

@@ -0,0 +1,76 @@
package net.buzzert.kordophonedroid.ui.messagelist
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@Composable
fun MessageEntry(
keyboardType: KeyboardType = KeyboardType.Text,
onTextChanged: (TextFieldValue) -> Unit,
textFieldValue: TextFieldValue,
onSend: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
) {
Row(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
.align(Alignment.Bottom)
.imePadding()
.navigationBarsPadding()
) {
BasicTextField(
value = textFieldValue,
onValueChange = { onTextChanged(it) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
decorationBox = { textContent ->
if (textFieldValue.text.isEmpty()) {
Text(
text = "Message",
style = MaterialTheme.typography.body1.copy(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
)
}
textContent()
}
)
Button(onClick = onSend) {
Text(text = "Send")
}
}
}
}
}

View File

@@ -1,20 +1,17 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.lazy.LazyColumn
@@ -22,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -34,74 +30,115 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
@Immutable
data class Message(
val content: String,
private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
data class MessageListViewItem(
val text: String,
val fromMe: Boolean,
val delivered: Boolean = true,
)
@Composable
fun MessageListScreen(
messages: List<Message>,
backAction: () -> Unit
conversationGUID: GUID,
backAction: () -> Unit,
viewModel: MessageListViewModel = hiltViewModel(),
) {
viewModel.conversationGUID = conversationGUID
LaunchedEffect(Unit) {
Log.d("MessageListScreen", "Launched effect")
viewModel.synchronize()
}
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
val messageItems = messages.map {
MessageListViewItem(
text = it.text,
fromMe = it.sender == null,
delivered = !viewModel.isPendingMessage(it)
)
}
Scaffold(topBar = { MessagesListTopAppBar(title = viewModel.title, backAction = backAction) }) { padding ->
MessageTranscript(
messages = messageItems,
paddingValues = padding,
onSendMessage = { text ->
viewModel.enqueueOutgoingMessage(text)
}
)
}
}
@Composable
fun MessagesListTopAppBar(title: String, backAction: () -> Unit) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {}
)
}
@Composable
fun MessageTranscript(
messages: List<MessageListViewItem>,
paddingValues: PaddingValues,
onSendMessage: (text: String) -> Unit,
) {
val scrollState = rememberLazyListState()
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Messages") },
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)) {
})
}
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)) {
Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState)
Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState)
MessageEntry(
onTextChanged = { textState = it },
textFieldValue = textState,
onSend = {
onSendMessage(textState.text)
MessageEntry(
onTextChanged = { textState = it },
textFieldValue = textState,
)
}
// Clear text state
textState = TextFieldValue()
},
)
}
}
@Composable
fun Messages(
messages: List<Message>,
messages: List<MessageListViewItem>,
modifier: Modifier = Modifier,
scrollState: LazyListState
) {
@@ -117,97 +154,59 @@ fun Messages(
for (index in messages.indices) {
val content = messages[index]
item {
MessageBubble(content)
MessageBubble(
text = content.text,
mine = content.fromMe,
modifier = Modifier.alpha(if (!content.delivered) 0.5F else 1.0f)
)
}
}
}
}
}
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
@Composable
fun MessageBubble(message: Message) {
val backgroundBubbleColor = MaterialTheme.colors.primary
fun MessageBubble(
text: String,
mine: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
Column(modifier = Modifier.padding(end = 16.dp)) {
Surface(
color = backgroundBubbleColor,
shape = ChatBubbleShape
Column() {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) {
Text(
text = message.content,
style = MaterialTheme.typography.body1,
modifier = Modifier
.padding(16.dp)
)
Surface(
color = backgroundBubbleColor,
shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
) {
Text(
text = text,
style = MaterialTheme.typography.body1,
modifier = Modifier
.padding(16.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@Composable
private fun MessageEntry(
keyboardType: KeyboardType = KeyboardType.Text,
onTextChanged: (TextFieldValue) -> Unit,
textFieldValue: TextFieldValue,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
) {
Row(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
.align(Alignment.Bottom)
.imePadding()
.navigationBarsPadding()
) {
BasicTextField(
value = textFieldValue,
onValueChange = { onTextChanged(it) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
decorationBox = { textContent ->
if (textFieldValue.text.isEmpty()) {
Text(
text = "Message",
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface)
)
}
textContent()
}
)
Button(onClick = { /*TODO*/ }) {
Text(text = "Send")
}
}
}
}
}
// -
@Preview
@Composable
private fun MessageListScreenPreview() {
val messages = listOf<Message>(
Message(content = "Hello"),
Message(content = "Hi there"),
)
val messages = listOf<MessageListViewItem>(
MessageListViewItem(text = "Hello", fromMe = false),
MessageListViewItem(text = "Hey there", fromMe = true),
MessageListViewItem(text = "How's it going", fromMe = true, delivered = false)
).reversed()
MessageListScreen(messages = messages) {}
Scaffold() {
MessageTranscript(messages = messages, paddingValues = it, onSendMessage = {})
}
}

View File

@@ -0,0 +1,84 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.server.ChatRepository
import java.util.Date
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class MessageListViewModel @Inject constructor(
private val repository: ChatRepository,
) : ViewModel()
{
var conversationGUID: GUID? = null
set(value) {
field = value
value?.let {
conversation = repository.conversationForGuid(it)
}
}
private var conversation: Conversation? = null
private val pendingMessages: MutableStateFlow<List<Message>> = MutableStateFlow(listOf())
init {
viewModelScope.launch {
// Remove pending message after message is delivered.
// By now, the repository should've committed this to the store.
repository.messageDeliveredChannel.collectLatest { event ->
pendingMessages.value =
pendingMessages.value.filter { it.guid != event.message.guid }
}
}
}
val messages: Flow<List<Message>>
get() = repository.messagesChanged(conversation!!)
.combine(pendingMessages) { a, b -> a.union(b) }
.map { messages ->
messages
.sortedBy { it.date }
.reversed()
}
val title: String get() = conversation!!.formattedDisplayName()
fun enqueueOutgoingMessage(text: String) {
val outgoingMessage = Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = null,
date = Date(),
conversation = conversation!!,
)
pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
repository.enqueueOutgoingMessage(outgoingMessage, conversation!!)
}
fun isPendingMessage(message: Message): Boolean {
return pendingMessages.value.contains(message)
}
fun synchronize() = viewModelScope.launch {
repository.synchronizeConversation(conversation!!)
}
}