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()
}
@Suppress("LongMethod", "CyclomaticComplexMethod") return ComposeView(requireContext()).apply {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
super.onViewCreated(view, savedInstanceState) setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
setContent {
binding.toolbar.setNavigationOnClickListener { AppTheme {
parentFragmentManager.popBackStack() MessageScreen(
} contactKey = contactKey,
message = message,
contactKey = arguments?.getString("contactKey").toString() viewModel = model,
if (arguments?.getString("message") != null) { ) { parentFragmentManager.popBackStack() }
binding.messageInputText.setText(arguments?.getString("message").toString())
}
val channelIndex = contactKey[0].digitToIntOrNull()
val nodeId = contactKey.substring(1)
val channelName = channelIndex?.let { model.channels.value.getChannel(it)?.name }
?: "Unknown Channel"
binding.toolbar.title = when (nodeId) {
DataPacket.ID_BROADCAST -> channelName
else -> model.getUser(nodeId).longName
}
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
binding.toolbar.title = "${binding.toolbar.title}🔒"
} else if (nodeId != DataPacket.ID_BROADCAST) {
binding.toolbar.subtitle = "(ch: $channelIndex - $channelName)"
}
fun sendMessageInputText() {
val str = binding.messageInputText.text.toString().trim()
if (str.isNotEmpty()) {
model.sendMessage(str, contactKey)
}
binding.messageInputText.setText("") // blow away the string the user just entered
// requireActivity().hideKeyboard()
}
binding.sendButton.setOnClickListener {
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) },
)
} }
} }
} }
}
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages }
model.connectionState.asLiveData().observe(viewLifecycleOwner) {
// If we don't know our node ID and we are offline don't let user try to send sealed class MessageMenuAction {
val isConnected = model.isConnected() data object ClipboardCopy : MessageMenuAction()
binding.textInputLayout.isEnabled = isConnected data object Delete : MessageMenuAction()
binding.sendButton.isEnabled = isConnected data object Dismiss : MessageMenuAction()
for (subView: View in binding.quickChatLayout.allViews) { data object SelectAll : MessageMenuAction()
if (subView is Button) { }
subView.isEnabled = isConnected
} @Suppress("LongMethod", "CyclomaticComplexMethod")
} @Composable
} internal fun MessageScreen(
contactKey: String,
model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions -> message: String,
actions?.let { viewModel: UIViewModel = hiltViewModel(),
// This seems kinda hacky it might be better to replace with a recycler view onNavigateBack: () -> Unit
binding.quickChatLayout.removeAllViews() ) {
for (action in actions) { val coroutineScope = rememberCoroutineScope()
val button = Button(context) val clipboardManager = LocalClipboardManager.current
button.text = action.name
button.isEnabled = model.isConnected() val channelIndex = contactKey[0].digitToIntOrNull()
if (action.mode == QuickChatAction.Mode.Instant) { val nodeId = contactKey.substring(1)
button.backgroundTintList = val channelName = channelIndex?.let { viewModel.channels.value.getChannel(it)?.name }
ContextCompat.getColorStateList(requireActivity(), R.color.colorMyMsg) ?: "Unknown Channel"
}
button.setOnClickListener { val title = when (nodeId) {
if (action.mode == QuickChatAction.Mode.Append) { DataPacket.ID_BROADCAST -> channelName
val originalText = binding.messageInputText.text ?: "" else -> viewModel.getUser(nodeId).longName
val needsSpace = }
!originalText.endsWith(' ') && originalText.isNotEmpty()
val newText = buildString { // if (channelIndex != DataPacket.PKC_CHANNEL_INDEX && nodeId != DataPacket.ID_BROADCAST) {
append(originalText) // subtitle = "(ch: $channelIndex - $channelName)"
if (needsSpace) append(' ') // }
append(action.message)
} val selectedIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
binding.messageInputText.setText(newText) val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
binding.messageInputText.setSelection(newText.length)
} else { val connState by viewModel.connectionState.collectAsStateWithLifecycle()
model.sendMessage(action.message, contactKey) val quickChat by viewModel.quickChatActions.collectAsStateWithLifecycle()
} val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf())
}
binding.quickChatLayout.addView(button) val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) {
} mutableStateOf(TextFieldValue(message))
} }
}
} var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
override fun onDestroyView() { DeleteMessageDialog(
super.onDestroyView() size = selectedIds.value.size,
actionMode?.finish() onConfirm = {
actionMode = null viewModel.deleteMessages(selectedIds.value.toList())
_binding = null selectedIds.value = emptySet()
} showDeleteDialog = false
},
private inner class ActionModeCallback : ActionMode.Callback { onDismiss = { showDeleteDialog = false }
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { )
mode.menuInflater.inflate(R.menu.menu_messages, menu) }
menu.findItem(R.id.muteButton).isVisible = false
mode.title = "1" Scaffold(
return true topBar = {
} if (inSelectionMode) {
ActionModeTopBar(selectedIds.value) { action ->
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { when (action) {
return false MessageMenuAction.ClipboardCopy -> coroutineScope.launch {
} val copiedText = messages
.filter { it.uuid in selectedIds.value }
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { .joinToString("\n") { it.text }
when (item.itemId) {
R.id.deleteButton -> { clipboardManager.setText(AnnotatedString(copiedText))
val deleteMessagesString = resources.getQuantityString( selectedIds.value = emptySet()
R.plurals.delete_messages, }
selectedList.size,
selectedList.size MessageMenuAction.Delete -> {
) showDeleteDialog = true
MaterialAlertDialogBuilder(requireContext()) }
.setMessage(deleteMessagesString)
.setPositiveButton(getString(R.string.delete)) { _, _ -> MessageMenuAction.Dismiss -> selectedIds.value = emptySet()
debug("User clicked deleteButton") MessageMenuAction.SelectAll -> {
model.deleteMessages(selectedList.map { it.uuid }) if (selectedIds.value.size == messages.size) {
mode.finish() selectedIds.value = emptySet()
} } else {
.setNeutralButton(R.string.cancel) { _, _ -> selectedIds.value = messages.map { it.uuid }.toSet()
} }
.show() }
} }
R.id.selectAllButton -> lifecycleScope.launch { }
model.getMessagesFrom(contactKey).firstOrNull()?.let { messages -> } else {
if (selectedList.size == messages.size) { MessageTopBar(title, channelIndex, onNavigateBack)
// if all selected -> unselect all }
selectedList.clear() },
mode.finish() bottomBar = {
} else { val isConnected = connState.isConnected()
// else --> select all Column(
selectedList.clear() modifier = Modifier
selectedList.addAll(messages) .background(MaterialTheme.colors.background)
} .padding(start = 8.dp, end = 8.dp, bottom = 4.dp),
actionMode?.title = selectedList.size.toString() ) {
} QuickChatRow(isConnected, quickChat) { action ->
} if (action.mode == QuickChatAction.Mode.Append) {
R.id.resendButton -> lifecycleScope.launch { val originalText = messageInput.value.text
debug("User clicked resendButton") val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty()
val resendText = getSelectedMessagesText() val newText = buildString {
binding.messageInputText.setText(resendText) append(originalText)
mode.finish() if (needsSpace) append(' ')
} append(action.message)
R.id.copyButton -> lifecycleScope.launch { }
val copyText = getSelectedMessagesText() messageInput.value = TextFieldValue(newText, TextRange(newText.length))
val clipboardManager = } else {
requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager viewModel.sendMessage(action.message, contactKey)
clipboardManager.setPrimaryClip(ClipData.newPlainText("message text", copyText)) }
requireActivity().toast(getString(R.string.copied)) }
mode.finish() TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) }
} }
} }
return true ) { innerPadding ->
} if (messages.isNotEmpty()) {
MessageListView(
override fun onDestroyActionMode(mode: ActionMode) { messages = messages,
selectedList.clear() selectedIds = selectedIds,
actionMode = null onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) },
} contentPadding = innerPadding
} ) {
// TODO onCLick()
private fun openNodeInfo(msg: Message) = lifecycleScope.launch { }
model.nodeList.firstOrNull()?.find { it.user.id == msg.user.id }?.let { node -> }
parentFragmentManager.popBackStack() }
model.focusUserNode(node) }
}
} @Composable
private fun DeleteMessageDialog(
private fun getSelectedMessagesText(): String { size: Int,
var messageText = "" onConfirm: () -> Unit = {},
selectedList.forEach { onDismiss: () -> Unit = {},
messageText = messageText + it.text + System.lineSeparator() ) {
} val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, size, size)
if (messageText != "") {
messageText = messageText.substring(0, messageText.length - 1) AlertDialog(
} onDismissRequest = onDismiss,
return messageText shape = RoundedCornerShape(16.dp),
backgroundColor = MaterialTheme.colors.background,
text = {
Text(
text = deleteMessagesString,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
private fun ActionModeTopBar(
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,
)
}
}
}
}
@Composable
private fun TextInput(
enabled: Boolean,
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)
)
}
}
@PreviewLightDark
@Composable
private fun TextInputPreview() {
AppTheme {
TextInput(
enabled = true,
message = remember { mutableStateOf(TextFieldValue("")) },
)
} }
} }

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>