From 9f0ead25180f8edaf65a47a67f0cfe18909bdbff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:53:08 +0000 Subject: [PATCH] feat(auto): align Auto node/message row UI with phone NodeItem and ContactItem Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/ebb54cc3-35cd-4c25-afd2-4b7fbaa05a5d Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com> --- .../feature/auto/MeshtasticCarScreen.kt | 136 +++++++++++++++++- 1 file changed, 129 insertions(+), 7 deletions(-) diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt index 897eae987..8135ce9f0 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node @@ -169,7 +170,11 @@ class MeshtasticCarScreen(carContext: CarContext) : // Favorite nodes — filter nodeDBbyNum to isFavorite, sort alphabetically. scope.launch { nodeRepository.nodeDBbyNum - .map { db -> db.values.filter { it.isFavorite }.sortedBy { it.user.long_name.ifEmpty { it.user.short_name } } } + .map { db -> + db.values + .filter { it.isFavorite } + .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } }) + } .distinctUntilChanged() .collect { nodes -> favorites = nodes @@ -205,6 +210,9 @@ class MeshtasticCarScreen(carContext: CarContext) : val toBroadcast = packet.to == DataPacket.ID_BROADCAST val userId = if (fromLocal) packet.to else packet.from + // Resolve the user once; used for both displayName and message prefix. + val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + val displayName = if (toBroadcast) { channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() } @@ -213,10 +221,17 @@ class MeshtasticCarScreen(carContext: CarContext) : // userId can be null for malformed packets (e.g. both `from` and `to` // are null). Fall back to a broadcast lookup which returns an "Unknown" // user rather than crashing. - val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" } } + // Mirror ContactsViewModel: prefix received DM text with the sender's short name, + // matching how ContactItem's ChatMetadata renders lastMessageText. + val shortName = if (!toBroadcast) user.short_name else "" + val lastMessageText = + packet.text?.let { text -> + if (fromLocal || shortName.isEmpty()) text else "$shortName: $text" + } + CarContact( contactKey = contactKey, displayName = displayName, @@ -224,6 +239,7 @@ class MeshtasticCarScreen(carContext: CarContext) : isBroadcast = toBroadcast, channelIndex = packet.channel, lastMessageTime = if (packet.time != 0L) packet.time else null, + lastMessageText = lastMessageText, ) } @@ -313,6 +329,74 @@ class MeshtasticCarScreen(carContext: CarContext) : .setSingleList(ItemList.Builder().addItem(buildStatusRow()).build()) .build() + /** + * Builds the Favorites tab: one row per starred node, mirroring the key status info shown + * by [org.meshtastic.feature.node.component.NodeItem] on the phone. + * + * - **Title**: node's long name (short name fallback). + * - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` — + * mirrors the signal row and last-heard chip in NodeItem. + * - **Text 2**: battery percentage and short name — mirrors the battery row and node chip. + */ + private fun buildFavoritesTemplate(): ListTemplate { + val items = ItemList.Builder() + val capped = favorites.take(MAX_LIST_ITEMS) + if (capped.isEmpty()) { + items.setNoItemsMessage("No favorite nodes") + } else { + capped.forEach { node -> items.addItem(buildFavoriteNodeRow(node)) } + } + return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build() + } + + /** + * Builds a single favorite-node row. + * + * Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]: + * - Title → `long_name` (prominent, matches NodeItem header text) + * - Text 1 → online/offline + hop distance (matches NodeItem signal row) + * - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row) + */ + private fun buildFavoriteNodeRow(node: Node): Row { + val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" } + + // Mirror NodeItem's signal row: online status + hops / direct info. + val statusText = buildString { + if (node.isOnline) { + append("Online") + when { + node.hopsAway == 0 -> append(" · Direct") + node.hopsAway > 0 -> append(" · ${node.hopsAway} hops") + } + } else { + append("Offline") + if (node.lastHeard > 0) { + // DateFormatter.formatRelativeTime takes millis; lastHeard is in seconds. + val ago = DateFormatter.formatRelativeTime(node.lastHeard * 1000L) + append(" · $ago") + } + } + } + + // Mirror NodeItem's battery row + node chip: "[SHORT] · 85%" or just "[SHORT]". + val detailText = buildString { + val shortName = node.user.short_name + if (shortName.isNotEmpty()) append(shortName) + val battery = node.batteryLevelStr + if (battery.isNotEmpty()) { + if (isNotEmpty()) append(" · ") + append(battery) + } + } + + return Row.Builder() + .setTitle(name) + .addText(statusText) + .apply { if (detailText.isNotEmpty()) addText(detailText) } + .setBrowsable(false) + .build() + } + /** * Builds the Messages tab content: channels first (always present, even if empty), followed * by DM conversations sorted by most-recent message — identical to the phone's Contacts screen. @@ -331,13 +415,26 @@ class MeshtasticCarScreen(carContext: CarContext) : /** * Fallback for Car API level 1–5 hosts that do not support [TabTemplate]. * - * Shows a status row followed by the combined contact list (channels first, then DMs) in a - * single [ListTemplate]. + * Shows a status row, then favorite-node rows, then conversation rows, all capped at + * [MAX_LIST_ITEMS] total — matching the three-tab content in a single list. + * + * The remaining slots after status are split evenly: half for favorites, half for messages. + * This prevents a long favorites list from crowding out all conversation entries. */ private fun buildFallbackListTemplate(): ListTemplate { val items = ItemList.Builder() + var remaining = MAX_LIST_ITEMS items.addItem(buildStatusRow()) - contacts.take(MAX_LIST_ITEMS).forEach { contact -> items.addItem(buildContactRow(contact)) } + remaining-- + // Give each section at most half the remaining space so neither dominates. + val halfRemaining = remaining / 2 + favorites.take(halfRemaining).forEach { node -> + items.addItem(buildFavoriteNodeRow(node)) + remaining-- + } + contacts.take(remaining).forEach { contact -> + items.addItem(buildContactRow(contact)) + } return ListTemplate.Builder().setTitle("Meshtastic").setSingleList(items.build()).build() } @@ -357,11 +454,32 @@ class MeshtasticCarScreen(carContext: CarContext) : .build() } + /** + * Builds a single conversation row. + * + * Mirrors [org.meshtastic.feature.messaging.ui.contact.ContactItem]: + * - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader). + * - **Text 1** → last message preview with sender prefix for received DMs, or "No messages + * yet" for empty channel placeholders (matches ChatMetadata's message text). + * - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp + * when there are none (matches the unread badge and date in ContactHeader/ChatMetadata). + */ private fun buildContactRow(contact: CarContact): Row { - val subtitle = if (contact.unreadCount > 0) "${contact.unreadCount} unread" else "" + // Mirror ChatMetadata: show the last message text or a placeholder for empty channels. + val preview = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet" + + // Mirror ContactItem header date + ChatMetadata unread badge. + val secondaryText = when { + contact.unreadCount > 0 -> "${contact.unreadCount} unread" + contact.lastMessageTime != null -> + DateFormatter.formatShortDate(contact.lastMessageTime) + else -> "" + } + return Row.Builder() .setTitle(contact.displayName) - .apply { if (subtitle.isNotEmpty()) addText(subtitle) } + .addText(preview) + .apply { if (secondaryText.isNotEmpty()) addText(secondaryText) } .setBrowsable(false) .build() } @@ -376,6 +494,8 @@ class MeshtasticCarScreen(carContext: CarContext) : * * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by * index). [lastMessageTime] drives DM ordering (most-recent first). + * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received + * DMs are prefixed with the sender's short name, matching [ContactItem]'s ChatMetadata. */ private data class CarContact( val contactKey: String, @@ -384,10 +504,12 @@ class MeshtasticCarScreen(carContext: CarContext) : val isBroadcast: Boolean, val channelIndex: Int, val lastMessageTime: Long?, + val lastMessageText: String?, ) companion object { private const val TAB_STATUS = "status" + private const val TAB_FAVORITES = "favorites" private const val TAB_MESSAGES = "messages" /**