From 3c581f81a8fdc483344f74541444b79e0f53ffc4 Mon Sep 17 00:00:00 2001 From: Andre K Date: Sat, 30 Nov 2024 23:20:09 -0300 Subject: [PATCH] refactor: migrate `MessagesFragment` to Compose (#1444) --- .../java/com/geeksville/mesh/model/UIState.kt | 8 - .../geeksville/mesh/ui/ContactsFragment.kt | 2 - .../com/geeksville/mesh/ui/MessageListView.kt | 26 +- .../geeksville/mesh/ui/MessagesFragment.kt | 603 +++++++++++------- .../java/com/geeksville/mesh/ui/NavGraph.kt | 3 + .../java/com/geeksville/mesh/ui/NodeItem.kt | 20 +- .../com/geeksville/mesh/ui/ShareFragment.kt | 2 +- .../com/geeksville/mesh/ui/UsersFragment.kt | 12 - .../com/geeksville/mesh/ui/map/MapFragment.kt | 10 +- .../geeksville/mesh/util/CompatExtensions.kt | 9 - .../drawable/ic_twotone_content_copy_24.xml | 15 - .../ic_twotone_content_paste_go_24.xml | 18 - app/src/main/res/layout/messages_fragment.xml | 99 --- app/src/main/res/menu/menu_messages.xml | 10 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 16 files changed, 388 insertions(+), 451 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_twotone_content_copy_24.xml delete mode 100644 app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml delete mode 100644 app/src/main/res/layout/messages_fragment.xml diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 03e7b0f26..4ca7688c5 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -195,9 +195,6 @@ class UIViewModel @Inject constructor( val quickChatActions get() = quickChatActionRepository.getAllActions() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - private val _focusedNode = MutableStateFlow(null) - val focusedNode: StateFlow = _focusedNode - private val nodeFilterText = MutableStateFlow("") private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD) private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false)) @@ -729,11 +726,6 @@ class UIViewModel @Inject constructor( _currentTab.value = tab } - fun focusUserNode(node: NodeEntity?) { - _currentTab.value = 1 - _focusedNode.value = node - } - fun setNodeFilterText(text: String) { nodeFilterText.value = text } diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt index 420563282..06cc1fca1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -126,8 +126,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { private inner class ActionModeCallback : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { 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" return true } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt index da8cf8a2f..5a6bbceec 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.ui +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,12 +43,12 @@ import kotlinx.coroutines.flow.debounce @Composable internal fun MessageListView( messages: List, - selectedList: List, - onClick: (Message) -> Unit, - onLongClick: (Message) -> Unit, - onChipClick: (Message) -> Unit, + selectedIds: MutableState>, onUnreadChanged: (Long) -> Unit, + contentPadding: PaddingValues, + onClick: (Message) -> Unit = {} ) { + val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } val listState = rememberLazyListState( initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0) ) @@ -60,14 +62,20 @@ internal fun MessageListView( SimpleAlertDialog(title = title, text = text) { showStatusDialog = null } } + fun toggle(uuid: Long) = if (selectedIds.value.contains(uuid)) { + selectedIds.value -= uuid + } else { + selectedIds.value += uuid + } + LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, reverseLayout = true, - // contentPadding = PaddingValues(8.dp) + contentPadding = contentPadding ) { 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( shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL }, @@ -75,9 +83,9 @@ internal fun MessageListView( messageTime = msg.time, messageStatus = msg.status, selected = selected, - onClick = { onClick(msg) }, - onLongClick = { onLongClick(msg) }, - onChipClick = { onChipClick(msg) }, + onClick = { if (inSelectionMode) toggle(msg.uuid) }, + onLongClick = { toggle(msg.uuid) }, + onChipClick = { onClick(msg) }, onStatusClick = { showStatusDialog = msg } ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index 8ce297c42..56a99b825 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -17,55 +17,85 @@ package com.geeksville.mesh.ui -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.Button -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.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.os.bundleOf -import androidx.core.view.allViews import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels -import androidx.lifecycle.asLiveData +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.android.toast 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.getChannel +import com.geeksville.mesh.ui.components.NodeKeyStatusIcon 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 kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -internal fun FragmentManager.navigateToMessages(contactKey: 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) { +internal fun FragmentManager.navigateToMessages(contactKey: String, message: String = "") { val messagesFragment = MessagesFragment().apply { arguments = bundleOf("contactKey" to contactKey, "message" to message) } @@ -77,256 +107,345 @@ internal fun FragmentManager.navigateToPreInitMessages(contactKey: String, messa @AndroidEntryPoint 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 lateinit var contactKey: String - - private val selectedList = emptyList().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( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = MessagesFragmentBinding.inflate(inflater, container, false) - return binding.root + val contactKey = arguments?.getString("contactKey").toString() + 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() } + } + } + } } +} - @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) +sealed class MessageMenuAction { + data object ClipboardCopy : MessageMenuAction() + data object Delete : MessageMenuAction() + data object Dismiss : MessageMenuAction() + data object SelectAll : MessageMenuAction() +} - binding.toolbar.setNavigationOnClickListener { - parentFragmentManager.popBackStack() - } +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun MessageScreen( + contactKey: String, + message: String, + viewModel: UIViewModel = hiltViewModel(), + onNavigateBack: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + + val channelIndex = contactKey[0].digitToIntOrNull() + val nodeId = contactKey.substring(1) + val channelName = channelIndex?.let { viewModel.channels.value.getChannel(it)?.name } + ?: "Unknown Channel" + + val title = when (nodeId) { + DataPacket.ID_BROADCAST -> channelName + else -> viewModel.getUser(nodeId).longName + } - contactKey = arguments?.getString("contactKey").toString() - if (arguments?.getString("message") != null) { - 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" +// if (channelIndex != DataPacket.PKC_CHANNEL_INDEX && nodeId != DataPacket.ID_BROADCAST) { +// subtitle = "(ch: $channelIndex - $channelName)" +// } - binding.toolbar.title = when (nodeId) { - DataPacket.ID_BROADCAST -> channelName - else -> model.getUser(nodeId).longName - } + val selectedIds = rememberSaveable { mutableStateOf(emptySet()) } + val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } - if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) { - binding.toolbar.title = "${binding.toolbar.title}🔒" - } else if (nodeId != DataPacket.ID_BROADCAST) { - binding.toolbar.subtitle = "(ch: $channelIndex - $channelName)" - } + val connState by viewModel.connectionState.collectAsStateWithLifecycle() + val quickChat by viewModel.quickChatActions.collectAsStateWithLifecycle() + val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf()) - 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() - } + val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(message)) + } - // max payload length should be 237 bytes but anything over 200 becomes less reliable - binding.messageInputText.filters += Utf8ByteLengthFilter(200) + var showDeleteDialog by remember { mutableStateOf(false) } + if (showDeleteDialog) { + DeleteMessageDialog( + size = selectedIds.value.size, + onConfirm = { + viewModel.deleteMessages(selectedIds.value.toList()) + selectedIds.value = emptySet() + showDeleteDialog = false + }, + onDismiss = { showDeleteDialog = false } + ) + } - binding.messageListView.setContent { - val messages by model.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf()) + 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() + } - AppTheme { - if (messages.isNotEmpty()) { - MessageListView( - messages = messages, - selectedList = selectedList, - onClick = ::onClick, - onLongClick = ::onLongClick, - onChipClick = ::openNodeInfo, - onUnreadChanged = { model.clearUnreadCount(contactKey, it) }, - ) - } - } - } + MessageMenuAction.Delete -> { + showDeleteDialog = true + } - // 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 - 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 + MessageMenuAction.Dismiss -> selectedIds.value = emptySet() + MessageMenuAction.SelectAll -> { + if (selectedIds.value.size == messages.size) { + selectedIds.value = emptySet() + } else { + selectedIds.value = messages.map { it.uuid }.toSet() + } + } + } } + } else { + MessageTopBar(title, channelIndex, onNavigateBack) } - } - - model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions -> - actions?.let { - // This seems kinda hacky it might be better to replace with a recycler view - binding.quickChatLayout.removeAllViews() - for (action in actions) { - val button = Button(context) - button.text = action.name - button.isEnabled = model.isConnected() - if (action.mode == QuickChatAction.Mode.Instant) { - button.backgroundTintList = - ContextCompat.getColorStateList(requireActivity(), R.color.colorMyMsg) - } - button.setOnClickListener { - if (action.mode == QuickChatAction.Mode.Append) { - val originalText = binding.messageInputText.text ?: "" - val needsSpace = - !originalText.endsWith(' ') && originalText.isNotEmpty() - val newText = buildString { - append(originalText) - if (needsSpace) append(' ') - append(action.message) - } - binding.messageInputText.setText(newText) - binding.messageInputText.setSelection(newText.length) - } else { - model.sendMessage(action.message, contactKey) + }, + 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) { + val originalText = messageInput.value.text + val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty() + val newText = buildString { + append(originalText) + if (needsSpace) append(' ') + append(action.message) } + messageInput.value = TextFieldValue(newText, TextRange(newText.length)) + } else { + 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() { - super.onDestroyView() - actionMode?.finish() - actionMode = null - _binding = null - } - - private inner class ActionModeCallback : ActionMode.Callback { - 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" - return true +@Composable +private fun DeleteMessageDialog( + size: Int, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {}, +) { + val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, size, size) + + AlertDialog( + onDismissRequest = onDismiss, + 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)) + } } + ) +} - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - return false +@Composable +private fun ActionModeTopBar( + selectedList: Set, + 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), + ) } - - 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) - .setPositiveButton(getString(R.string.delete)) { _, _ -> - debug("User clicked deleteButton") - model.deleteMessages(selectedList.map { it.uuid }) - mode.finish() - } - .setNeutralButton(R.string.cancel) { _, _ -> - } - .show() - } - 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 + }, + actions = { + IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(id = R.string.copy) + ) } - - override fun onDestroyActionMode(mode: ActionMode) { - selectedList.clear() - actionMode = null + 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) } } - - 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 QuickChatRow( + enabled: Boolean, + actions: List, + 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 getSelectedMessagesText(): String { - var messageText = "" - selectedList.forEach { - messageText = messageText + it.text + System.lineSeparator() - } - if (messageText != "") { - messageText = messageText.substring(0, messageText.length - 1) +@Composable +private fun TextInput( + enabled: Boolean, + message: MutableState, + 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), + ) } - return messageText + } + 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("")) }, + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 133a45ab5..11dc1baf6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -181,6 +181,9 @@ enum class AdminRoute(@StringRes val title: Int) { } sealed interface Route { + @Serializable + data class Messages(val contactKey: String, val message: String = "") : Route + @Serializable data class RadioConfig(val destNum: Int? = null) : Route @Serializable data object User : Route diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index 83618ff85..4fdc64d7d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -17,12 +17,6 @@ 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.Box import androidx.compose.foundation.layout.Column @@ -87,7 +81,6 @@ fun NodeItem( tempInFahrenheit: Boolean, ignoreIncomingList: List = emptyList(), menuItemActionClicked: (MenuItemAction) -> Unit = {}, - blinking: Boolean = false, expanded: Boolean = false, currentTimeMillis: Long, isConnected: Boolean = false, @@ -112,16 +105,6 @@ fun NodeItem( 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) { LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) } else { @@ -142,8 +125,7 @@ fun NodeItem( Column( modifier = Modifier .fillMaxWidth() - .padding(8.dp) - .background(bgColor), + .padding(8.dp), ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt index 7a3d042d5..529cf388e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt @@ -49,7 +49,7 @@ class ShareFragment : ScreenFragment("Messages"), Logging { private fun shareMessage(contact: Contact) { debug("calling MessagesFragment filter:${contact.contactKey}") - parentFragmentManager.navigateToPreInitMessages( + parentFragmentManager.navigateToMessages( contact.contactKey, arguments?.getString("message").toString() ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index e7c58ea9c..41ba2a0d9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -100,16 +99,6 @@ fun NodesScreen( val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle() 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 connectionState by model.connectionState.collectAsStateWithLifecycle() @@ -153,7 +142,6 @@ fun NodesScreen( MenuItemAction.MoreDetails -> navigateToNodeDetails(node.num) } }, - blinking = node == focusedNode, expanded = state.showDetails, currentTimeMillis = currentTimeMillis, isConnected = connectionState.isConnected(), diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index 8c064641d..c54911f9f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -334,11 +334,11 @@ fun MapView( position = nodePosition icon = markerIcon - setOnLongClickListener { - performHapticFeedback() - model.focusUserNode(node) - true - } +// setOnLongClickListener { +// performHapticFeedback() +// TODO NodeMenu? +// true +// } setNodeColors(node.colors) setPrecisionBits(p.precisionBits) } diff --git a/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt index 99b838b96..6698b3108 100644 --- a/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt @@ -17,7 +17,6 @@ package com.geeksville.mesh.util -import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -30,14 +29,6 @@ import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat 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 Parcel.readParcelableCompat(loader: ClassLoader?): T? = ParcelCompat.readParcelable(this, loader, T::class.java) diff --git a/app/src/main/res/drawable/ic_twotone_content_copy_24.xml b/app/src/main/res/drawable/ic_twotone_content_copy_24.xml deleted file mode 100644 index 5c99d3fa8..000000000 --- a/app/src/main/res/drawable/ic_twotone_content_copy_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml b/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml deleted file mode 100644 index 21fc0ae3a..000000000 --- a/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/messages_fragment.xml b/app/src/main/res/layout/messages_fragment.xml deleted file mode 100644 index c5c283ece..000000000 --- a/app/src/main/res/layout/messages_fragment.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_messages.xml b/app/src/main/res/menu/menu_messages.xml index 2d8601e1f..a0192c268 100644 --- a/app/src/main/res/menu/menu_messages.xml +++ b/app/src/main/res/menu/menu_messages.xml @@ -23,11 +23,6 @@ android:icon="@drawable/ic_twotone_volume_off_24" android:title="@string/mute" app:showAsAction="ifRoom" /> - - \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4ec92dcdc..4c6dd666f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -278,5 +278,4 @@ Skoki do: %1$d. Skoki od: %2$d Kopiuj - Skopiowano diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a6b9ff87..010f9a53d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -311,5 +311,4 @@ Not Selected Unknown Age Copy - Copied