Decouple MessageScreen from UiViewModel (#3210)

This commit is contained in:
Phil Oliver 2025-09-26 14:46:49 -04:00 committed by GitHub
parent 6c0b2c55a0
commit 4deed11343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 261 additions and 88 deletions

View file

@ -65,20 +65,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.DataPacket
@ -281,27 +278,6 @@ constructor(
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _showQuickChat
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }
onChanged(toggled)
}
}
val nodeList: StateFlow<List<Node>> =
nodeDB
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
@ -418,22 +394,6 @@ constructor(
initialValue = emptyList(),
)
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
contactKeyForMessages.value = contactKey
return messagesForContactKey
}
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val messagesForContactKey: StateFlow<List<Message>> =
contactKeyForMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun generatePacketId(): Int? {
return try {
meshService?.packetId
@ -443,23 +403,6 @@ constructor(
}
}
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
// if the destination is a node, we need to ensure it's a
// favorite so it does not get removed from the on-device node database.
if (channel == null) { // no channel specified, so we assume it's a direct message
val node = nodeDB.getNode(dest)
if (!node.isFavorite) {
favoriteNode(nodeDB.getNode(dest))
}
}
val p = DataPacket(dest, channel ?: 0, str, replyId)
sendDataPacket(p)
}
fun sendWaypoint(wpt: MeshProtos.Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
@ -477,9 +420,6 @@ constructor(
}
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<AdminProtos.SharedContact?>
get() = _sharedContactRequested.asStateFlow()
@ -533,17 +473,8 @@ constructor(
fun deleteContacts(contacts: List<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
fun deleteMessages(uuidList: List<Long>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.clearUnreadCount(contact, timestamp)
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
}
// Connection state to our radio device
val connectionState
get() = serviceRepository.connectionState

View file

@ -65,7 +65,6 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel:
MessageScreen(
contactKey = args.contactKey,
message = args.message,
viewModel = uiViewModel,
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },

View file

@ -95,7 +95,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel
import com.geeksville.mesh.ui.common.components.SecurityIcon
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
@ -120,7 +119,7 @@ private const val ROUNDED_CORNER_PERCENT = 100
*
* @param contactKey A unique key identifying the contact or channel.
* @param message An optional message to pre-fill in the input field.
* @param viewModel The [UIViewModel] instance for handling business logic and state.
* @param viewModel The [MessageViewModel] instance for handling business logic and state.
* @param navigateToMessages Callback to navigate to a different message thread.
* @param navigateToNodeDetails Callback to navigate to a node's detail screen.
* @param onNavigateBack Callback to navigate back from this screen.
@ -130,7 +129,7 @@ private const val ROUNDED_CORNER_PERCENT = 100
internal fun MessageScreen(
contactKey: String,
message: String,
viewModel: UIViewModel = hiltViewModel(),
viewModel: MessageViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
navigateToQuickChatOptions: () -> Unit,
@ -139,9 +138,9 @@ internal fun MessageScreen(
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
// State from ViewModel
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(initialValue = false)
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
@ -296,13 +295,17 @@ internal fun MessageScreen(
Column(Modifier.padding(paddingValues)) {
Box(modifier = Modifier.weight(1f)) {
MessageList(
nodes = nodes,
ourNode = ourNode,
isConnected = connectionState.isConnected(),
modifier = Modifier.fillMaxSize(),
listState = listState,
messages = messages,
selectedIds = selectedMessageIds,
onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
viewModel = viewModel,
onDeleteMessages = { viewModel.deleteMessages(it) },
onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) },
contactKey = contactKey,
onReply = { message -> replyingToPacketId = message?.packetId },
onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) },
@ -314,7 +317,7 @@ internal fun MessageScreen(
}
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
enabled = isConnected,
enabled = connectionState.isConnected(),
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
@ -337,7 +340,7 @@ internal fun MessageScreen(
ourNode = ourNode,
)
MessageInput(
isEnabled = isConnected,
isEnabled = connectionState.isConnected(),
textFieldState = messageInputState,
onSendMessage = {
val messageText = messageInputState.text.toString().trim()

View file

@ -46,8 +46,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.message.components.MessageItem
import com.geeksville.mesh.ui.message.components.ReactionDialog
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@ -57,6 +55,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
@ -106,6 +105,9 @@ fun DeliveryInfo(
@Suppress("LongMethod")
@Composable
internal fun MessageList(
nodes: List<Node>,
ourNode: Node?,
isConnected: Boolean,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
messages: List<Message>,
@ -113,7 +115,8 @@ internal fun MessageList(
onUnreadChanged: (Long) -> Unit,
onSendReaction: (String, Int) -> Unit,
onNodeMenuAction: (NodeMenuAction) -> Unit,
viewModel: UIViewModel,
onDeleteMessages: (List<Long>) -> Unit,
onSendMessage: (messageText: String, contactKey: String) -> Unit,
contactKey: String,
onReply: (Message?) -> Unit,
) {
@ -131,9 +134,9 @@ internal fun MessageList(
text = text,
onConfirm = {
val deleteList: List<Long> = listOf(msg.uuid)
viewModel.deleteMessages(deleteList)
onDeleteMessages(deleteList)
showStatusDialog = null
viewModel.sendMessage(msg.text, contactKey)
onSendMessage(msg.text, contactKey)
},
onDismiss = { showStatusDialog = null },
resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false,
@ -152,9 +155,6 @@ internal fun MessageList(
value += uuid
}
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val coroutineScope = rememberCoroutineScope()
LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) {
items(messages, key = { it.uuid }) { msg ->
@ -165,7 +165,7 @@ internal fun MessageList(
MessageItem(
modifier = Modifier.animateItem(),
node = node,
ourNode = ourNode!!,
ourNode = ourNode,
message = msg,
selected = selected,
onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) },

View file

@ -0,0 +1,238 @@
/*
* Copyright (c) 2025 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/>.
*/
package com.geeksville.mesh.ui.message
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.service.ServiceRepository
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.core.prefs.ui.UiPrefs
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MessageViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
private val serviceRepository: ServiceRepository,
private val packetRepository: PacketRepository,
private val uiPrefs: UiPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
) : ViewModel() {
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
val ourNodeInfo = nodeRepository.ourNodeInfo
val connectionState = serviceRepository.connectionState
val nodeList: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val channels =
radioConfigRepository.channelSetFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
channelSet {},
)
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _showQuickChat
val quickChatActions =
quickChatActionRepository
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val messagesForContactKey: StateFlow<List<Message>> =
contactKeyForMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
// TODO this should be moved to a repository class
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
contactKeyForMessages.value = contactKey
return messagesForContactKey
}
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }
onChanged(toggled)
}
}
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
// if the destination is a node, we need to ensure it's a
// favorite so it does not get removed from the on-device node database.
if (channel == null) { // no channel specified, so we assume it's a direct message
val node = nodeRepository.getNode(dest)
if (!node.isFavorite) {
favoriteNode(nodeRepository.getNode(dest))
}
}
val p = DataPacket(dest, channel ?: 0, str, replyId)
sendDataPacket(p)
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
fun deleteMessages(uuidList: List<Long>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.clearUnreadCount(contact, timestamp)
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
}
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> removeNode(action.node.num)
is NodeMenuAction.Ignore -> ignoreNode(action.node)
is NodeMenuAction.Favorite -> favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
is NodeMenuAction.TraceRoute -> {
requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
}
else -> {}
}
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
private fun sendDataPacket(p: DataPacket) {
try {
serviceRepository.meshService?.send(p)
} catch (ex: RemoteException) {
Timber.e("Send DataPacket error: ${ex.message}")
}
}
private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
Timber.i("Removing node '$nodeNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Timber.e("Remove node error: ${ex.message}")
}
}
private fun ignoreNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Ignore node error:")
}
}
private fun requestUserInfo(destNum: Int) {
Timber.i("Requesting UserInfo for '$destNum'")
try {
serviceRepository.meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) {
Timber.e("Request NodeInfo error: ${ex.message}")
}
}
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
Timber.i("Requesting position for '$destNum'")
try {
serviceRepository.meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
Timber.e("Request position error: ${ex.message}")
}
}
fun requestTraceroute(destNum: Int) {
Timber.i("Requesting traceroute for '$destNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) {
Timber.e("Request traceroute error: ${ex.message}")
}
}
}