mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
feat(auto): replace ListTemplate with TabTemplate for iOS CarPlay parity
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/67580c49-612a-450b-8452-9c88875df1c3 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
eb3a27a3d3
commit
dac4880e0f
@@ -33,9 +33,10 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- TabTemplate requires Car API level 2 (androidx.car.app 1.2.0+) -->
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1" />
|
||||
android:value="2" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -19,11 +19,14 @@ package org.meshtastic.feature.auto
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.SectionedItemList
|
||||
import androidx.car.app.model.Tab
|
||||
import androidx.car.app.model.TabTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -51,10 +54,12 @@ import org.meshtastic.proto.ChannelSettings
|
||||
/**
|
||||
* Root screen displayed in Android Auto.
|
||||
*
|
||||
* Shows three sections mirroring the iOS CarPlay implementation:
|
||||
* Shows three tabs mirroring the iOS CarPlay tab-based navigation:
|
||||
* - **Status**: Connection state and active device name
|
||||
* - **Favorites**: Favorited mesh nodes with unread message counts
|
||||
* - **Channels**: Active channels with unread message counts
|
||||
*
|
||||
* Requires Car API level 2+ (androidx.car.app:app 1.2.0+) for [TabTemplate] support.
|
||||
*/
|
||||
class MeshtasticCarScreen(carContext: CarContext) :
|
||||
Screen(carContext),
|
||||
@@ -69,6 +74,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var observeJob: Job? = null
|
||||
|
||||
private var activeTabId = TAB_STATUS
|
||||
private var connectionState: ConnectionState = ConnectionState.Disconnected
|
||||
private var favoriteNodes: List<Node> = emptyList()
|
||||
private var channels: List<Pair<Int, ChannelSettings>> = emptyList()
|
||||
@@ -141,24 +147,48 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val listBuilder = ListTemplate.Builder()
|
||||
|
||||
listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
|
||||
|
||||
val favoritesSection = buildFavoritesSection()
|
||||
if (favoritesSection.items.isNotEmpty()) {
|
||||
listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
|
||||
val tabCallback = TabTemplate.TabCallback { tabContentId ->
|
||||
activeTabId = tabContentId
|
||||
invalidate()
|
||||
}
|
||||
|
||||
val channelsSection = buildChannelsSection()
|
||||
if (channelsSection.items.isNotEmpty()) {
|
||||
listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
|
||||
val activeContent = when (activeTabId) {
|
||||
TAB_FAVORITES -> TabTemplate.TabContents.Builder(buildFavoritesTemplate()).build()
|
||||
TAB_CHANNELS -> TabTemplate.TabContents.Builder(buildChannelsTemplate()).build()
|
||||
else -> TabTemplate.TabContents.Builder(buildStatusTemplate()).build()
|
||||
}
|
||||
|
||||
return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
|
||||
return TabTemplate.Builder(tabCallback)
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Status")
|
||||
.setIcon(carIcon(R.drawable.auto_ic_status))
|
||||
.setContentId(TAB_STATUS)
|
||||
.build(),
|
||||
)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Favorites")
|
||||
.setIcon(carIcon(R.drawable.auto_ic_favorites))
|
||||
.setContentId(TAB_FAVORITES)
|
||||
.build(),
|
||||
)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Channels")
|
||||
.setIcon(carIcon(R.drawable.auto_ic_channels))
|
||||
.setContentId(TAB_CHANNELS)
|
||||
.build(),
|
||||
)
|
||||
.setTabContents(activeContent)
|
||||
.setActiveTabContentId(activeTabId)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildStatusSection(): ItemList {
|
||||
private fun carIcon(resId: Int) = CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).build()
|
||||
|
||||
private fun buildStatusTemplate(): ListTemplate {
|
||||
val statusText =
|
||||
when (connectionState) {
|
||||
is ConnectionState.Connected -> "Connected"
|
||||
@@ -177,55 +207,70 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
return ItemList.Builder().addItem(row).build()
|
||||
return ListTemplate.Builder()
|
||||
.setTitle("Status")
|
||||
.setSingleList(ItemList.Builder().addItem(row).build())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildFavoritesSection(): ItemList {
|
||||
val builder = ItemList.Builder()
|
||||
|
||||
for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
|
||||
val contactKey = "0${node.user.id}"
|
||||
val unread = unreadCounts[contactKey] ?: 0
|
||||
val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
|
||||
val subtitle = buildString {
|
||||
append(node.user.short_name)
|
||||
if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
|
||||
if (unread > 0) append(" · $unread unread")
|
||||
private fun buildFavoritesTemplate(): ListTemplate {
|
||||
val items = ItemList.Builder()
|
||||
if (favoriteNodes.isEmpty()) {
|
||||
items.setNoItemsMessage("No favorite contacts")
|
||||
} else {
|
||||
for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
|
||||
val contactKey = "0${node.user.id}"
|
||||
val unread = unreadCounts[contactKey] ?: 0
|
||||
val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
|
||||
val subtitle = buildString {
|
||||
append(node.user.short_name)
|
||||
if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
|
||||
if (unread > 0) append(" · $unread unread")
|
||||
}
|
||||
items.addItem(Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build())
|
||||
}
|
||||
|
||||
val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
|
||||
builder.addItem(row)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
return ListTemplate.Builder()
|
||||
.setTitle("Favorites")
|
||||
.setSingleList(items.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildChannelsSection(): ItemList {
|
||||
val builder = ItemList.Builder()
|
||||
private fun buildChannelsTemplate(): ListTemplate {
|
||||
val items = ItemList.Builder()
|
||||
if (channels.isEmpty()) {
|
||||
items.setNoItemsMessage("No active channels")
|
||||
} else {
|
||||
for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
|
||||
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
|
||||
val unread = unreadCounts[contactKey] ?: 0
|
||||
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
|
||||
val subtitle = if (unread > 0) "$unread unread" else ""
|
||||
|
||||
for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
|
||||
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
|
||||
val unread = unreadCounts[contactKey] ?: 0
|
||||
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
|
||||
val subtitle = if (unread > 0) "$unread unread" else ""
|
||||
|
||||
val row =
|
||||
Row.Builder()
|
||||
.setTitle(channelName)
|
||||
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
builder.addItem(row)
|
||||
val row =
|
||||
Row.Builder()
|
||||
.setTitle(channelName)
|
||||
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
items.addItem(row)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
return ListTemplate.Builder()
|
||||
.setTitle("Channels")
|
||||
.setSingleList(items.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAB_STATUS = "status"
|
||||
private const val TAB_FAVORITES = "favorites"
|
||||
private const val TAB_CHANNELS = "channels"
|
||||
|
||||
/**
|
||||
* Android Auto enforces a maximum item count per [ListTemplate] section. Car API level 1 supports up to 6 items
|
||||
* per section.
|
||||
* Android Auto enforces a maximum item count per [ListTemplate]. Car API level 2 supports up to 6 items.
|
||||
*/
|
||||
private const val MAX_LIST_ITEMS = 6
|
||||
}
|
||||
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_channels.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_channels.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 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/>.
|
||||
-->
|
||||
|
||||
<!-- Material Design "chat" icon — used for the Channels tab in Android Auto -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_favorites.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_favorites.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 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/>.
|
||||
-->
|
||||
|
||||
<!-- Material Design "star" icon — used for the Favorites tab in Android Auto -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
|
||||
</vector>
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_status.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_status.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 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/>.
|
||||
-->
|
||||
|
||||
<!-- Material Design "wifi" icon — used for the Status tab in Android Auto -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user