refactor: migrate `MessagesFragment` to Compose (#1444)

pull/1445/head^2
Andre K 2024-11-30 23:20:09 -03:00 zatwierdzone przez GitHub
rodzic 5d3b36532f
commit 3c581f81a8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
16 zmienionych plików z 414 dodań i 477 usunięć

Wyświetl plik

@ -195,9 +195,6 @@ class UIViewModel @Inject constructor(
val quickChatActions get() = quickChatActionRepository.getAllActions() val quickChatActions get() = quickChatActionRepository.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _focusedNode = MutableStateFlow<NodeEntity?>(null)
val focusedNode: StateFlow<NodeEntity?> = _focusedNode
private val nodeFilterText = MutableStateFlow("") private val nodeFilterText = MutableStateFlow("")
private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD) private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD)
private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false)) private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false))
@ -729,11 +726,6 @@ class UIViewModel @Inject constructor(
_currentTab.value = tab _currentTab.value = tab
} }
fun focusUserNode(node: NodeEntity?) {
_currentTab.value = 1
_focusedNode.value = node
}
fun setNodeFilterText(text: String) { fun setNodeFilterText(text: String) {
nodeFilterText.value = text nodeFilterText.value = text
} }

Wyświetl plik

@ -126,8 +126,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging {
private inner class ActionModeCallback : ActionMode.Callback { private inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu) mode.menuInflater.inflate(R.menu.menu_messages, menu)
menu.findItem(R.id.resendButton).isVisible = false
menu.findItem(R.id.copyButton).isVisible = false
mode.title = "1" mode.title = "1"
return true return true
} }

Wyświetl plik

@ -17,6 +17,7 @@
package com.geeksville.mesh.ui package com.geeksville.mesh.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@ -24,6 +25,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -41,12 +43,12 @@ import kotlinx.coroutines.flow.debounce
@Composable @Composable
internal fun MessageListView( internal fun MessageListView(
messages: List<Message>, messages: List<Message>,
selectedList: List<Message>, selectedIds: MutableState<Set<Long>>,
onClick: (Message) -> Unit,
onLongClick: (Message) -> Unit,
onChipClick: (Message) -> Unit,
onUnreadChanged: (Long) -> Unit, onUnreadChanged: (Long) -> Unit,
contentPadding: PaddingValues,
onClick: (Message) -> Unit = {}
) { ) {
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0) initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0)
) )
@ -60,14 +62,20 @@ internal fun MessageListView(
SimpleAlertDialog(title = title, text = text) { showStatusDialog = null } SimpleAlertDialog(title = title, text = text) { showStatusDialog = null }
} }
fun toggle(uuid: Long) = if (selectedIds.value.contains(uuid)) {
selectedIds.value -= uuid
} else {
selectedIds.value += uuid
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
reverseLayout = true, reverseLayout = true,
// contentPadding = PaddingValues(8.dp) contentPadding = contentPadding
) { ) {
items(messages, key = { it.uuid }) { msg -> items(messages, key = { it.uuid }) { msg ->
val selected by remember { derivedStateOf { selectedList.contains(msg) } } val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
MessageItem( MessageItem(
shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL }, shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL },
@ -75,9 +83,9 @@ internal fun MessageListView(
messageTime = msg.time, messageTime = msg.time,
messageStatus = msg.status, messageStatus = msg.status,
selected = selected, selected = selected,
onClick = { onClick(msg) }, onClick = { if (inSelectionMode) toggle(msg.uuid) },
onLongClick = { onLongClick(msg) }, onLongClick = { toggle(msg.uuid) },
onChipClick = { onChipClick(msg) }, onChipClick = { onClick(msg) },
onStatusClick = { showStatusDialog = msg } onStatusClick = { showStatusDialog = msg }
) )
} }

Wyświetl plik

@ -17,55 +17,85 @@
package com.geeksville.mesh.ui package com.geeksville.mesh.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import androidx.compose.foundation.background
import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Column
import androidx.appcompat.view.ActionMode import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.toMutableStateList 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.draw.scale
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.allViews
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.asLiveData import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.geeksville.mesh.DataPacket import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.R import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.toast
import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.databinding.MessagesFragmentBinding
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel import com.geeksville.mesh.model.getChannel
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Utf8ByteLengthFilter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
internal fun FragmentManager.navigateToMessages(contactKey: String) { internal fun FragmentManager.navigateToMessages(contactKey: String, message: String = "") {
val messagesFragment = MessagesFragment().apply {
arguments = bundleOf("contactKey" to contactKey)
}
beginTransaction()
.add(R.id.mainActivityLayout, messagesFragment)
.addToBackStack(null)
.commit()
}
internal fun FragmentManager.navigateToPreInitMessages(contactKey: String, message: String) {
val messagesFragment = MessagesFragment().apply { val messagesFragment = MessagesFragment().apply {
arguments = bundleOf("contactKey" to contactKey, "message" to message) arguments = bundleOf("contactKey" to contactKey, "message" to message)
} }
@ -77,256 +107,345 @@ internal fun FragmentManager.navigateToPreInitMessages(contactKey: String, messa
@AndroidEntryPoint @AndroidEntryPoint
class MessagesFragment : Fragment(), Logging { class MessagesFragment : Fragment(), Logging {
private val actionModeCallback: ActionModeCallback = ActionModeCallback()
private var actionMode: ActionMode? = null
private var _binding: MessagesFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val model: UIViewModel by activityViewModels() private val model: UIViewModel by activityViewModels()
private lateinit var contactKey: String
private val selectedList = emptyList<Message>().toMutableStateList()
private fun onClick(message: Message) {
if (actionMode != null) {
onLongClick(message)
}
}
private fun onLongClick(message: Message) {
if (actionMode == null) {
actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback)
}
selectedList.apply {
if (contains(message)) remove(message) else add(message)
}
if (selectedList.isEmpty()) {
// finish action mode when no items selected
actionMode?.finish()
} else {
// show total items selected on action mode title
actionMode?.title = selectedList.size.toString()
}
}
override fun onPause() {
actionMode?.finish()
super.onPause()
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
_binding = MessagesFragmentBinding.inflate(inflater, container, false) val contactKey = arguments?.getString("contactKey").toString()
return binding.root val message = arguments?.getString("message").toString()
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
setContent {
AppTheme {
MessageScreen(
contactKey = contactKey,
message = message,
viewModel = model,
) { parentFragmentManager.popBackStack() }
}
}
}
}
}
sealed class MessageMenuAction {
data object ClipboardCopy : MessageMenuAction()
data object Delete : MessageMenuAction()
data object Dismiss : MessageMenuAction()
data object SelectAll : MessageMenuAction()
} }
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @Composable
super.onViewCreated(view, savedInstanceState) internal fun MessageScreen(
contactKey: String,
message: String,
viewModel: UIViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
binding.toolbar.setNavigationOnClickListener {
parentFragmentManager.popBackStack()
}
contactKey = arguments?.getString("contactKey").toString()
if (arguments?.getString("message") != null) {
binding.messageInputText.setText(arguments?.getString("message").toString())
}
val channelIndex = contactKey[0].digitToIntOrNull() val channelIndex = contactKey[0].digitToIntOrNull()
val nodeId = contactKey.substring(1) val nodeId = contactKey.substring(1)
val channelName = channelIndex?.let { model.channels.value.getChannel(it)?.name } val channelName = channelIndex?.let { viewModel.channels.value.getChannel(it)?.name }
?: "Unknown Channel" ?: "Unknown Channel"
binding.toolbar.title = when (nodeId) { val title = when (nodeId) {
DataPacket.ID_BROADCAST -> channelName DataPacket.ID_BROADCAST -> channelName
else -> model.getUser(nodeId).longName else -> viewModel.getUser(nodeId).longName
} }
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) { // if (channelIndex != DataPacket.PKC_CHANNEL_INDEX && nodeId != DataPacket.ID_BROADCAST) {
binding.toolbar.title = "${binding.toolbar.title}🔒" // subtitle = "(ch: $channelIndex - $channelName)"
} else if (nodeId != DataPacket.ID_BROADCAST) { // }
binding.toolbar.subtitle = "(ch: $channelIndex - $channelName)"
val selectedIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
val connState by viewModel.connectionState.collectAsStateWithLifecycle()
val quickChat by viewModel.quickChatActions.collectAsStateWithLifecycle()
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf())
val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(message))
} }
fun sendMessageInputText() { var showDeleteDialog by remember { mutableStateOf(false) }
val str = binding.messageInputText.text.toString().trim() if (showDeleteDialog) {
if (str.isNotEmpty()) { DeleteMessageDialog(
model.sendMessage(str, contactKey) size = selectedIds.value.size,
} onConfirm = {
binding.messageInputText.setText("") // blow away the string the user just entered viewModel.deleteMessages(selectedIds.value.toList())
// requireActivity().hideKeyboard() selectedIds.value = emptySet()
} showDeleteDialog = false
},
binding.sendButton.setOnClickListener { onDismiss = { showDeleteDialog = false }
debug("User clicked sendButton")
sendMessageInputText()
}
// max payload length should be 237 bytes but anything over 200 becomes less reliable
binding.messageInputText.filters += Utf8ByteLengthFilter(200)
binding.messageListView.setContent {
val messages by model.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf())
AppTheme {
if (messages.isNotEmpty()) {
MessageListView(
messages = messages,
selectedList = selectedList,
onClick = ::onClick,
onLongClick = ::onLongClick,
onChipClick = ::openNodeInfo,
onUnreadChanged = { model.clearUnreadCount(contactKey, it) },
) )
} }
}
Scaffold(
topBar = {
if (inSelectionMode) {
ActionModeTopBar(selectedIds.value) { action ->
when (action) {
MessageMenuAction.ClipboardCopy -> coroutineScope.launch {
val copiedText = messages
.filter { it.uuid in selectedIds.value }
.joinToString("\n") { it.text }
clipboardManager.setText(AnnotatedString(copiedText))
selectedIds.value = emptySet()
} }
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages MessageMenuAction.Delete -> {
model.connectionState.asLiveData().observe(viewLifecycleOwner) { showDeleteDialog = true
// If we don't know our node ID and we are offline don't let user try to send
val isConnected = model.isConnected()
binding.textInputLayout.isEnabled = isConnected
binding.sendButton.isEnabled = isConnected
for (subView: View in binding.quickChatLayout.allViews) {
if (subView is Button) {
subView.isEnabled = isConnected
}
}
} }
model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions -> MessageMenuAction.Dismiss -> selectedIds.value = emptySet()
actions?.let { MessageMenuAction.SelectAll -> {
// This seems kinda hacky it might be better to replace with a recycler view if (selectedIds.value.size == messages.size) {
binding.quickChatLayout.removeAllViews() selectedIds.value = emptySet()
for (action in actions) { } else {
val button = Button(context) selectedIds.value = messages.map { it.uuid }.toSet()
button.text = action.name
button.isEnabled = model.isConnected()
if (action.mode == QuickChatAction.Mode.Instant) {
button.backgroundTintList =
ContextCompat.getColorStateList(requireActivity(), R.color.colorMyMsg)
} }
button.setOnClickListener { }
}
}
} else {
MessageTopBar(title, channelIndex, onNavigateBack)
}
},
bottomBar = {
val isConnected = connState.isConnected()
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.padding(start = 8.dp, end = 8.dp, bottom = 4.dp),
) {
QuickChatRow(isConnected, quickChat) { action ->
if (action.mode == QuickChatAction.Mode.Append) { if (action.mode == QuickChatAction.Mode.Append) {
val originalText = binding.messageInputText.text ?: "" val originalText = messageInput.value.text
val needsSpace = val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty()
!originalText.endsWith(' ') && originalText.isNotEmpty()
val newText = buildString { val newText = buildString {
append(originalText) append(originalText)
if (needsSpace) append(' ') if (needsSpace) append(' ')
append(action.message) append(action.message)
} }
binding.messageInputText.setText(newText) messageInput.value = TextFieldValue(newText, TextRange(newText.length))
binding.messageInputText.setSelection(newText.length)
} else { } else {
model.sendMessage(action.message, contactKey) viewModel.sendMessage(action.message, contactKey)
} }
} }
binding.quickChatLayout.addView(button) TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) }
}
}
) { innerPadding ->
if (messages.isNotEmpty()) {
MessageListView(
messages = messages,
selectedIds = selectedIds,
onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) },
contentPadding = innerPadding
) {
// TODO onCLick()
} }
} }
} }
} }
override fun onDestroyView() { @Composable
super.onDestroyView() private fun DeleteMessageDialog(
actionMode?.finish() size: Int,
actionMode = null onConfirm: () -> Unit = {},
_binding = null onDismiss: () -> Unit = {},
} ) {
val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, size, size)
private inner class ActionModeCallback : ActionMode.Callback { AlertDialog(
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { onDismissRequest = onDismiss,
mode.menuInflater.inflate(R.menu.menu_messages, menu) shape = RoundedCornerShape(16.dp),
menu.findItem(R.id.muteButton).isVisible = false backgroundColor = MaterialTheme.colors.background,
mode.title = "1" text = {
return true Text(
} text = deleteMessagesString,
modifier = Modifier.fillMaxWidth(),
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { textAlign = TextAlign.Center,
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.deleteButton -> {
val deleteMessagesString = resources.getQuantityString(
R.plurals.delete_messages,
selectedList.size,
selectedList.size
) )
MaterialAlertDialogBuilder(requireContext()) },
.setMessage(deleteMessagesString) confirmButton = {
.setPositiveButton(getString(R.string.delete)) { _, _ -> TextButton(onClick = onConfirm) {
debug("User clicked deleteButton") Text(stringResource(R.string.delete))
model.deleteMessages(selectedList.map { it.uuid })
mode.finish()
} }
.setNeutralButton(R.string.cancel) { _, _ -> },
} dismissButton = {
.show() TextButton(onClick = onDismiss) {
} Text(stringResource(R.string.cancel))
R.id.selectAllButton -> lifecycleScope.launch {
model.getMessagesFrom(contactKey).firstOrNull()?.let { messages ->
if (selectedList.size == messages.size) {
// if all selected -> unselect all
selectedList.clear()
mode.finish()
} else {
// else --> select all
selectedList.clear()
selectedList.addAll(messages)
}
actionMode?.title = selectedList.size.toString()
} }
} }
R.id.resendButton -> lifecycleScope.launch { )
debug("User clicked resendButton")
val resendText = getSelectedMessagesText()
binding.messageInputText.setText(resendText)
mode.finish()
}
R.id.copyButton -> lifecycleScope.launch {
val copyText = getSelectedMessagesText()
val clipboardManager =
requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newPlainText("message text", copyText))
requireActivity().toast(getString(R.string.copied))
mode.finish()
}
}
return true
} }
override fun onDestroyActionMode(mode: ActionMode) { @Composable
selectedList.clear() private fun ActionModeTopBar(
actionMode = null selectedList: Set<Long>,
onAction: (MessageMenuAction) -> Unit,
) = TopAppBar(
title = { Text(text = selectedList.size.toString()) },
navigationIcon = {
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.clear),
)
}
},
actions = {
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(id = R.string.copy)
)
}
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.delete)
)
}
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.select_all)
)
}
},
backgroundColor = MaterialTheme.colors.primary,
)
@Composable
private fun MessageTopBar(
title: String,
channelIndex: Int?,
onNavigateBack: () -> Unit
) = TopAppBar(
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
},
actions = {
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = false)
}
}
)
@Composable
private fun QuickChatRow(
enabled: Boolean,
actions: List<QuickChatAction>,
modifier: Modifier = Modifier,
onClick: (QuickChatAction) -> Unit
) {
LazyRow(
modifier = modifier,
) {
items(actions, key = { it.uuid }) { action ->
Button(
onClick = { onClick(action) },
modifier = Modifier.padding(horizontal = 4.dp),
enabled = enabled,
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(id = R.color.colorMyMsg),
)
) {
Text(
text = action.name,
)
}
}
} }
} }
private fun openNodeInfo(msg: Message) = lifecycleScope.launch { @Composable
model.nodeList.firstOrNull()?.find { it.user.id == msg.user.id }?.let { node -> private fun TextInput(
parentFragmentManager.popBackStack() enabled: Boolean,
model.focusUserNode(node) message: MutableState<TextFieldValue>,
modifier: Modifier = Modifier,
maxSize: Int = 200,
onClick: (String) -> Unit = {}
) = Column(modifier) {
var isFocused by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = message.value,
onValueChange = {
if (it.text.toByteArray().size <= maxSize) {
message.value = it
}
},
modifier = Modifier
.weight(1f)
.onFocusEvent { isFocused = it.isFocused },
enabled = enabled,
placeholder = { Text(stringResource(id = R.string.send_text)) },
maxLines = 3,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
if (message.value.text.isNotEmpty()) {
onClick(message.value.text)
message.value = TextFieldValue("")
}
},
modifier = Modifier.size(48.dp),
enabled = enabled,
shape = CircleShape,
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(id = R.string.send_text),
modifier = Modifier.scale(scale = 1.5f),
)
}
}
if (isFocused) {
Text(
text = "${message.value.text.toByteArray().size}/$maxSize",
style = MaterialTheme.typography.caption,
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp, end = 72.dp)
)
} }
} }
private fun getSelectedMessagesText(): String { @PreviewLightDark
var messageText = "" @Composable
selectedList.forEach { private fun TextInputPreview() {
messageText = messageText + it.text + System.lineSeparator() AppTheme {
} TextInput(
if (messageText != "") { enabled = true,
messageText = messageText.substring(0, messageText.length - 1) message = remember { mutableStateOf(TextFieldValue("")) },
} )
return messageText
} }
} }

Wyświetl plik

@ -181,6 +181,9 @@ enum class AdminRoute(@StringRes val title: Int) {
} }
sealed interface Route { sealed interface Route {
@Serializable
data class Messages(val contactKey: String, val message: String = "") : Route
@Serializable @Serializable
data class RadioConfig(val destNum: Int? = null) : Route data class RadioConfig(val destNum: Int? = null) : Route
@Serializable data object User : Route @Serializable data object User : Route

Wyświetl plik

@ -17,12 +17,6 @@
package com.geeksville.mesh.ui package com.geeksville.mesh.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -87,7 +81,6 @@ fun NodeItem(
tempInFahrenheit: Boolean, tempInFahrenheit: Boolean,
ignoreIncomingList: List<Int> = emptyList(), ignoreIncomingList: List<Int> = emptyList(),
menuItemActionClicked: (MenuItemAction) -> Unit = {}, menuItemActionClicked: (MenuItemAction) -> Unit = {},
blinking: Boolean = false,
expanded: Boolean = false, expanded: Boolean = false,
currentTimeMillis: Long, currentTimeMillis: Long,
isConnected: Boolean = false, isConnected: Boolean = false,
@ -112,16 +105,6 @@ fun NodeItem(
thatNode.user.role.name thatNode.user.role.name
} }
val bgColor by animateColorAsState(
targetValue = if (blinking) Color(color = 0x33FFFFFF) else Color.Transparent,
animationSpec = repeatable(
iterations = 6,
animation = tween(durationMillis = 250, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "blinking node"
)
val style = if (thatNode.isUnknownUser) { val style = if (thatNode.isUnknownUser) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else { } else {
@ -142,8 +125,7 @@ fun NodeItem(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(8.dp),
.background(bgColor),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

Wyświetl plik

@ -49,7 +49,7 @@ class ShareFragment : ScreenFragment("Messages"), Logging {
private fun shareMessage(contact: Contact) { private fun shareMessage(contact: Contact) {
debug("calling MessagesFragment filter:${contact.contactKey}") debug("calling MessagesFragment filter:${contact.contactKey}")
parentFragmentManager.navigateToPreInitMessages( parentFragmentManager.navigateToMessages(
contact.contactKey, contact.contactKey,
arguments?.getString("message").toString() arguments?.getString("message").toString()
) )

Wyświetl plik

@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
@ -100,16 +99,6 @@ fun NodesScreen(
val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle() val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val focusedNode by model.focusedNode.collectAsStateWithLifecycle()
LaunchedEffect(focusedNode) {
focusedNode?.let { node ->
val index = nodes.indexOfFirst { it.num == node.num }
if (index != -1) {
listState.animateScrollToItem(index)
}
model.focusUserNode(null)
}
}
val currentTimeMillis = rememberTimeTickWithLifecycle() val currentTimeMillis = rememberTimeTickWithLifecycle()
val connectionState by model.connectionState.collectAsStateWithLifecycle() val connectionState by model.connectionState.collectAsStateWithLifecycle()
@ -153,7 +142,6 @@ fun NodesScreen(
MenuItemAction.MoreDetails -> navigateToNodeDetails(node.num) MenuItemAction.MoreDetails -> navigateToNodeDetails(node.num)
} }
}, },
blinking = node == focusedNode,
expanded = state.showDetails, expanded = state.showDetails,
currentTimeMillis = currentTimeMillis, currentTimeMillis = currentTimeMillis,
isConnected = connectionState.isConnected(), isConnected = connectionState.isConnected(),

Wyświetl plik

@ -334,11 +334,11 @@ fun MapView(
position = nodePosition position = nodePosition
icon = markerIcon icon = markerIcon
setOnLongClickListener { // setOnLongClickListener {
performHapticFeedback() // performHapticFeedback()
model.focusUserNode(node) // TODO NodeMenu?
true // true
} // }
setNodeColors(node.colors) setNodeColors(node.colors)
setPrecisionBits(p.precisionBits) setPrecisionBits(p.precisionBits)
} }

Wyświetl plik

@ -17,7 +17,6 @@
package com.geeksville.mesh.util package com.geeksville.mesh.util
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -30,14 +29,6 @@ import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
object PendingIntentCompat {
val FLAG_IMMUTABLE = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
}
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? = inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
ParcelCompat.readParcelable(this, loader, T::class.java) ParcelCompat.readParcelable(this, loader, T::class.java)

Wyświetl plik

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M8,7h11v14H8z"
android:strokeAlpha="0.3"
android:fillColor="@android:color/white"
android:fillAlpha="0.3"/>
<path
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"
android:fillColor="@android:color/white"/>
</vector>

Wyświetl plik

@ -1,18 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M10,17c0,-3.31 2.69,-6 6,-6h3V5h-2v3H7V5H5v14h5v-2z"
android:strokeAlpha="0.3"
android:fillColor="@android:color/white"
android:fillAlpha="0.3"/>
<path
android:pathData="M10,19L5,19L5,5h2v3h10L17,5h2v6h2L21,5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1s-2.4,0.84 -2.82,2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h5v-2zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1s0.45,-1 1,-1z"
android:fillColor="@android:color/white"/>
<path
android:pathData="m18.01,13l-1.42,1.41l1.58,1.58H12v2h6.17l-1.58,1.59l1.42,1.41l3.99,-4z"
android:fillColor="@android:color/white"/>
</vector>

Wyświetl plik

@ -1,99 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (c) 2024 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorAdvancedBackground"
>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/MyToolbar"
android:title="@string/channel_name"
android:layout_width="match_parent"
app:layout_constraintHeight_min="?attr/actionBarSize"
android:layout_height="wrap_content"
android:background="@color/toolbarBackground"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="?android:attr/homeAsUpIndicator"/>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/messageListView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="8dp"
android:contentDescription="@string/text_messages"
app:layout_constraintBottom_toTopOf="@+id/quickChatView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
</androidx.compose.ui.platform.ComposeView>
<HorizontalScrollView
android:id="@+id/quickChatView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/textInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:id="@+id/quickChatLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</HorizontalScrollView>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginBottom="4dp"
android:hint="@string/send_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/messageInputText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine|textCapSentences"
android:text="" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/sendButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:contentDescription="@string/send_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textInputLayout"
app:srcCompat="@drawable/ic_twotone_send_24" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -23,11 +23,6 @@
android:icon="@drawable/ic_twotone_volume_off_24" android:icon="@drawable/ic_twotone_volume_off_24"
android:title="@string/mute" android:title="@string/mute"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/resendButton"
android:icon="@drawable/ic_twotone_content_paste_go_24"
android:title="@string/resend"
app:showAsAction="ifRoom" />
<item <item
android:id="@+id/deleteButton" android:id="@+id/deleteButton"
android:icon="@drawable/ic_twotone_delete_24" android:icon="@drawable/ic_twotone_delete_24"
@ -38,9 +33,4 @@
android:icon="@drawable/ic_twotone_select_all_24" android:icon="@drawable/ic_twotone_select_all_24"
android:title="@string/select_all" android:title="@string/select_all"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/copyButton"
android:icon="@drawable/ic_twotone_content_copy_24"
android:title="@string/copy"
app:showAsAction="ifRoom" />
</menu> </menu>

Wyświetl plik

@ -278,5 +278,4 @@
</plurals> </plurals>
<string name="traceroute_diff">Skoki do: %1$d. Skoki od: %2$d</string> <string name="traceroute_diff">Skoki do: %1$d. Skoki od: %2$d</string>
<string name="copy">Kopiuj</string> <string name="copy">Kopiuj</string>
<string name="copied">Skopiowano</string>
</resources> </resources>

Wyświetl plik

@ -311,5 +311,4 @@
<string name="not_selected">Not Selected</string> <string name="not_selected">Not Selected</string>
<string name="unknown_age">Unknown Age</string> <string name="unknown_age">Unknown Age</string>
<string name="copy">Copy</string> <string name="copy">Copy</string>
<string name="copied">Copied</string>
</resources> </resources>