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()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _focusedNode = MutableStateFlow<NodeEntity?>(null)
val focusedNode: StateFlow<NodeEntity?> = _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
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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<Message>,
selectedList: List<Message>,
onClick: (Message) -> Unit,
onLongClick: (Message) -> Unit,
onChipClick: (Message) -> Unit,
selectedIds: MutableState<Set<Long>>,
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 }
)
}

Wyświetl plik

@ -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<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(
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()
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
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 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) },
)
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() }
}
}
}
// 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
}
}
}
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)
}
}
binding.quickChatLayout.addView(button)
}
}
}
}
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
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
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)
.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
}
override fun onDestroyActionMode(mode: ActionMode) {
selectedList.clear()
actionMode = null
}
}
private fun openNodeInfo(msg: Message) = lifecycleScope.launch {
model.nodeList.firstOrNull()?.find { it.user.id == msg.user.id }?.let { node ->
parentFragmentManager.popBackStack()
model.focusUserNode(node)
}
}
private fun getSelectedMessagesText(): String {
var messageText = ""
selectedList.forEach {
messageText = messageText + it.text + System.lineSeparator()
}
if (messageText != "") {
messageText = messageText.substring(0, messageText.length - 1)
}
return messageText
}
}
sealed class MessageMenuAction {
data object ClipboardCopy : MessageMenuAction()
data object Delete : MessageMenuAction()
data object Dismiss : MessageMenuAction()
data object SelectAll : MessageMenuAction()
}
@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
}
// if (channelIndex != DataPacket.PKC_CHANNEL_INDEX && nodeId != DataPacket.ID_BROADCAST) {
// 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))
}
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 }
)
}
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()
}
MessageMenuAction.Delete -> {
showDeleteDialog = true
}
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)
}
},
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)
}
}
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()
}
}
}
}
@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))
}
}
)
}
@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 {
@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

Wyświetl plik

@ -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<Int> = 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

Wyświetl plik

@ -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()
)

Wyświetl plik

@ -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(),

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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 <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
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:title="@string/mute"
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
android:id="@+id/deleteButton"
android:icon="@drawable/ic_twotone_delete_24"
@ -38,9 +33,4 @@
android:icon="@drawable/ic_twotone_select_all_24"
android:title="@string/select_all"
app:showAsAction="ifRoom" />
<item
android:id="@+id/copyButton"
android:icon="@drawable/ic_twotone_content_copy_24"
android:title="@string/copy"
app:showAsAction="ifRoom" />
</menu>

Wyświetl plik

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

Wyświetl plik

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