Browse Source

Add Android poll voting, results and message reaction components

master
evgeny 6 days ago
parent
commit
44e0b7849e
  1. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt
  2. 141
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

14
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt

@ -37,7 +37,10 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit,
onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit,
onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {},
onInviteLink: (String) -> Unit = {}) {
onInviteLink: (String) -> Unit = {}, reactionCatalog: List<com.utun.chat.data.ReactionState> = emptyList(),
onVote: (Message, List<Int>, String) -> Unit = { _, _, _ -> },
onReact: (Message, Int?) -> Unit = { _, _ -> },
authorName: (Long) -> String = { "0x%016x".format(it) }) {
val context = LocalContext.current
var openFailed by remember { mutableStateOf(false) }
val onLink: (String) -> Unit = { link ->
@ -77,13 +80,18 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
}
LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message ->
items(messages.reversed(), key = { "${target.nativeId}:${it.messageId.ifEmpty { "${it.isOutgoing}:${it.id}" }}" }) { message ->
val group = (target as? ConversationTarget.Channel)?.id
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp)
Box(Modifier.weight(1f)) {
MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
Column {
if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName)
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
MessageReactions(message, reactionCatalog, onReact)
}
}
if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp), size = 32.dp)

141
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

@ -0,0 +1,141 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.*
@Composable
fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, Boolean, Boolean) -> Unit) {
var question by rememberSaveable { mutableStateOf("") }
var variants by rememberSaveable { mutableStateOf("") }
var multiple by rememberSaveable { mutableStateOf(false) }
var custom by rememberSaveable { mutableStateOf(false) }
val options = variants.lines().map { it.trim() }.filter { it.isNotEmpty() }
val bytes = 6 + question.trim().toByteArray().size + options.sumOf { 2 + it.toByteArray().size }
val valid = question.isNotBlank() && question.trim().toByteArray().size <= 512 && options.size in 2..32 &&
options.distinct().size == options.size && options.all { it.toByteArray().size <= 256 } && bytes <= 2048
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.poll_create)) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(question, { question = it }, label = { Text(stringResource(R.string.poll_question)) })
OutlinedTextField(variants, { variants = it }, label = { Text(stringResource(R.string.poll_options_hint)) }, minLines = 3)
Row(Modifier.clickable { multiple = false }) {
RadioButton(!multiple, onClick = { multiple = false }); Text(stringResource(R.string.poll_single))
}
Row(Modifier.clickable { multiple = true }) {
RadioButton(multiple, onClick = { multiple = true }); Text(stringResource(R.string.poll_multiple))
}
Row(Modifier.clickable { custom = !custom }) {
Checkbox(custom, { custom = it }); Text(stringResource(R.string.poll_allow_custom))
}
if (!valid) Text(stringResource(R.string.poll_invalid_input), style = MaterialTheme.typography.bodySmall)
} },
confirmButton = { TextButton(enabled = valid, onClick = { onCreate(question.trim(), options, multiple, custom); onDismiss() }) {
Text(stringResource(R.string.poll_create))
} },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } })
}
@Composable
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) }
val poll = message.feedback?.poll
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium)
if (poll == null) Text(stringResource(R.string.poll_pending))
else {
Text(poll.question, style = MaterialTheme.typography.titleMedium)
Text(stringResource(if (poll.multiple) R.string.poll_multiple else R.string.poll_single),
style = MaterialTheme.typography.labelSmall)
for (option in poll.options) {
Text(option.text)
LinearProgressIndicator(progress = { (option.percent / 100).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth())
Text(stringResource(R.string.poll_option_result, option.count, option.percent),
style = MaterialTheme.typography.labelSmall)
}
Text(stringResource(R.string.poll_voters, poll.voters))
TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) }
}
}
}
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
}
@Composable
private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> Unit,
onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
var selected by rememberSaveable(message.messageId) { mutableStateOf(ArrayList(poll.myChoices)) }
var custom by rememberSaveable(message.messageId) { mutableStateOf(poll.myCustom) }
var customSelected by rememberSaveable(message.messageId) { mutableStateOf(!poll.multiple && custom.isNotEmpty()) }
val valid = custom.toByteArray().size <= 1024 && (!customSelected || custom.isNotBlank())
AlertDialog(onDismissRequest = onDismiss, title = { Text(poll.question) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in poll.options) {
val choose = {
customSelected = false
selected = if (!poll.multiple) arrayListOf(option.id)
else ArrayList(if (option.id in selected) selected.filter { it != option.id } else selected + option.id)
}
Row(Modifier.fillMaxWidth().clickable(onClick = choose)) {
if (poll.multiple) Checkbox(option.id in selected, onCheckedChange = { choose() })
else RadioButton(!customSelected && option.id in selected, onClick = choose)
Text(option.text)
}
}
if (poll.allowCustom) {
if (!poll.multiple) Row(Modifier.clickable { customSelected = true; selected = arrayListOf() }) {
RadioButton(customSelected, onClick = { customSelected = true; selected = arrayListOf() })
Text(stringResource(R.string.poll_own_answer))
}
OutlinedTextField(custom, { custom = it }, enabled = poll.multiple || customSelected, isError = !valid,
label = { Text(stringResource(R.string.poll_own_answer)) })
if (!valid) Text(stringResource(R.string.poll_answer_error), color = MaterialTheme.colorScheme.error)
}
Text(stringResource(R.string.poll_voters, poll.voters))
for (answer in poll.answers) {
val parts = answer.choices.mapNotNull { id -> poll.options.find { it.id == id }?.text } +
listOfNotNull(answer.custom.takeIf { it.isNotEmpty() })
Text("${authorName(answer.nodeId)}: ${parts.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
}
} },
confirmButton = { TextButton(enabled = valid, onClick = {
onVote(message, selected, if (poll.allowCustom && (poll.multiple || customSelected)) custom.trim() else ""); onDismiss()
}) { Text(stringResource(R.string.poll_save)) } },
dismissButton = { Row {
TextButton(onClick = { onVote(message, emptyList(), ""); onDismiss() }) { Text(stringResource(R.string.poll_withdraw)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
} })
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) }
FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (reaction in message.feedback?.reactions.orEmpty()) {
FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) },
label = { Text("${reaction.emoji} ${reaction.count}") })
}
TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) }
}
if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (reaction in catalog) TextButton(onClick = { onReact(message, reaction.id); picker = false }) { Text(reaction.emoji) }
} },
confirmButton = { TextButton(onClick = { onReact(message, null); picker = false }) {
Text(stringResource(R.string.poll_clear_reactions))
} },
dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } })
}
Loading…
Cancel
Save