diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml index a6c4ed78f..472c4f5e3 100644 --- a/feature/auto/src/main/AndroidManifest.xml +++ b/feature/auto/src/main/AndroidManifest.xml @@ -33,9 +33,10 @@ + + android:value="2" /> 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 35c287f1c..cc7a2e309 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 @@ -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 = emptyList() private var channels: List> = 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 } diff --git a/feature/auto/src/main/res/drawable/auto_ic_channels.xml b/feature/auto/src/main/res/drawable/auto_ic_channels.xml new file mode 100644 index 000000000..80d446141 --- /dev/null +++ b/feature/auto/src/main/res/drawable/auto_ic_channels.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/feature/auto/src/main/res/drawable/auto_ic_favorites.xml b/feature/auto/src/main/res/drawable/auto_ic_favorites.xml new file mode 100644 index 000000000..7df47eb31 --- /dev/null +++ b/feature/auto/src/main/res/drawable/auto_ic_favorites.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/feature/auto/src/main/res/drawable/auto_ic_status.xml b/feature/auto/src/main/res/drawable/auto_ic_status.xml new file mode 100644 index 000000000..242d80eae --- /dev/null +++ b/feature/auto/src/main/res/drawable/auto_ic_status.xml @@ -0,0 +1,28 @@ + + + + + + +