-
-
Notifications
You must be signed in to change notification settings - Fork 955
Allow settings auto favorite from automotive #6718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2657805
65f3651
b9f75c1
ceebc75
03b9cc0
1e42075
251f8a2
74cc6f1
e2cd297
82f2143
ce72bb6
2bb7210
f39eaf4
df516d2
d43b4aa
a473bc0
13129c8
b65ce01
ee5a158
622c276
6da3ba5
62465f1
4af0bf0
2df7f86
51bd2d3
b413653
0ed7c46
3b44395
0a74259
2782057
9c2ca73
d9e331b
80bcfc0
10f6bf9
b622996
e91c50c
1fed001
dea98ca
02dffc4
17cf7ad
9bab495
e395938
00c7879
86b234e
d58dba4
216a401
e65e004
f7ed752
f438fee
cb34a67
edb2625
9358e49
db57201
549a9ee
bcabf58
a038ae6
1917abf
a4007d5
95e5972
54be43f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import io.homeassistant.companion.android.common.util.isAutomotive | |
| import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS | ||
| import io.homeassistant.companion.android.util.vehicle.getDomainList | ||
| import io.homeassistant.companion.android.util.vehicle.getHeaderBuilder | ||
| import io.homeassistant.companion.android.util.vehicle.getManageFavoritesAction | ||
| import io.homeassistant.companion.android.util.vehicle.nativeModeAction | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
|
|
@@ -71,8 +72,20 @@ class DomainListScreen( | |
|
|
||
| return GridTemplate.Builder().apply { | ||
| val headerBuilder = carContext.getHeaderBuilder(R.string.all_entities) | ||
| if (isAutomotive && !isDrivingOptimized && BuildConfig.FLAVOR != "full") { | ||
| headerBuilder.addEndHeaderAction(nativeModeAction(carContext)) | ||
| if (isAutomotive && !isDrivingOptimized) { | ||
| if (BuildConfig.FLAVOR != "full") { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a shame that we can't use your screen also in minimal flavor. You should consider a UI that would allow both the native button and add favorite to be displayed.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you add a screenshot showing both? |
||
| headerBuilder.addEndHeaderAction(nativeModeAction(carContext)) | ||
| } | ||
|
|
||
| headerBuilder.addEndHeaderAction( | ||
| getManageFavoritesAction( | ||
| carContext, | ||
| screenManager, | ||
| serverId, | ||
| allEntities, | ||
| prefsRepository, | ||
| ), | ||
| ) | ||
| } | ||
| setHeader(headerBuilder.build()) | ||
| val domainBuild = domainList.build() | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,210 @@ | ||||||
| package io.homeassistant.companion.android.vehicle | ||||||
|
|
||||||
| import androidx.car.app.CarContext | ||||||
| import androidx.car.app.constraints.ConstraintManager | ||||||
| import androidx.car.app.model.ItemList | ||||||
| import androidx.car.app.model.ListTemplate | ||||||
| import androidx.car.app.model.Row | ||||||
| import androidx.car.app.model.Template | ||||||
| import androidx.car.app.model.Toggle | ||||||
| import androidx.lifecycle.Lifecycle | ||||||
| import androidx.lifecycle.lifecycleScope | ||||||
| import androidx.lifecycle.repeatOnLifecycle | ||||||
| import io.homeassistant.companion.android.common.R as commonR | ||||||
| import io.homeassistant.companion.android.common.data.integration.Entity | ||||||
| import io.homeassistant.companion.android.common.data.prefs.AutoFavorite | ||||||
| import io.homeassistant.companion.android.common.data.prefs.PrefsRepository | ||||||
| import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS_WITH_STRING | ||||||
| import io.homeassistant.companion.android.util.vehicle.getHeaderBuilder | ||||||
| import kotlinx.coroutines.flow.Flow | ||||||
| import kotlinx.coroutines.flow.StateFlow | ||||||
| import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.sync.Mutex | ||||||
| import kotlinx.coroutines.sync.withLock | ||||||
|
|
||||||
| /** | ||||||
| * A Car App screen that allows users to manage their automotive favorites when the vehicle is | ||||||
| * parked. Each entity from the supported domains is displayed with a toggle to add or remove | ||||||
| * it from the favorites list. Current favorites are sorted to the top. | ||||||
| * | ||||||
| * This screen stays fully within the Car App API, making it compliant with Play Store | ||||||
| * automotive distribution policies. | ||||||
| */ | ||||||
| class ManageFavoritesVehicleScreen( | ||||||
| carContext: CarContext, | ||||||
| private val serverId: StateFlow<Int>, | ||||||
| private val allEntities: Flow<Map<String, Entity>>, | ||||||
| private val prefsRepository: PrefsRepository, | ||||||
| ) : BaseVehicleScreen(carContext) { | ||||||
|
|
||||||
| private var entities: List<Entity> = emptyList() | ||||||
| private var favoritesList: List<AutoFavorite> = emptyList() | ||||||
| private var isLoaded = false | ||||||
| private var page = 0 | ||||||
| private val toggleMutex = Mutex() | ||||||
|
|
||||||
| init { | ||||||
| lifecycleScope.launch { | ||||||
| lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||||
| favoritesList = prefsRepository.getAutoFavorites() | ||||||
| allEntities.collect { entityMap -> | ||||||
| val favoriteEntityIds = favoritesList | ||||||
| .asSequence() | ||||||
| .filter { it.serverId == serverId.value } | ||||||
| .map { it.entityId } | ||||||
| .toSet() | ||||||
|
|
||||||
| val newEntities = entityMap.values | ||||||
| .filter { it.domain in SUPPORTED_DOMAINS_WITH_STRING } | ||||||
| .sortedWith( | ||||||
| compareByDescending<Entity> { entity -> | ||||||
| favoriteEntityIds.contains(entity.entityId) | ||||||
| }.thenBy { it.attributes["friendly_name"]?.toString() ?: it.entityId }, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ) | ||||||
| val listChanged = newEntities.map { it.entityId } != entities.map { it.entityId } | ||||||
| if (listChanged) page = 0 | ||||||
| val shouldInvalidate = !isLoaded || listChanged | ||||||
| entities = newEntities | ||||||
| isLoaded = true | ||||||
| if (shouldInvalidate) invalidate() | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| override fun onDrivingOptimizedChanged(newState: Boolean) { | ||||||
| if (newState) { | ||||||
| lifecycleScope.launch { | ||||||
| screenManager.pop() | ||||||
|
cddu33 marked this conversation as resolved.
|
||||||
| } | ||||||
| } | ||||||
| invalidate() | ||||||
| } | ||||||
|
|
||||||
| override fun onGetTemplate(): Template { | ||||||
|
cddu33 marked this conversation as resolved.
|
||||||
| val listLimit = carContext.getCarService(ConstraintManager::class.java) | ||||||
| .getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST) | ||||||
| val pageSlice = computePageSlice(entities.size, page, listLimit) | ||||||
| val pageEntities = if (isLoaded && pageSlice.fromIndex < entities.size) { | ||||||
| entities.subList(pageSlice.fromIndex, pageSlice.toIndex) | ||||||
| } else { | ||||||
| emptyList() | ||||||
| } | ||||||
|
|
||||||
| return ListTemplate.Builder() | ||||||
| .setHeader(carContext.getHeaderBuilder(commonR.string.android_automotive_favorites).build()) | ||||||
| .setLoading(!isLoaded) | ||||||
| .apply { | ||||||
| if (isLoaded) setSingleList(buildList(pageEntities, pageSlice).build()) | ||||||
| } | ||||||
| .build() | ||||||
| } | ||||||
|
|
||||||
| private fun buildList(pageEntities: List<Entity>, pageSlice: PageSlice): ItemList.Builder { | ||||||
| val listBuilder = ItemList.Builder() | ||||||
|
|
||||||
| if (pageSlice.hasPreviousPage) { | ||||||
| listBuilder.addItem( | ||||||
| buildNavigationRow(commonR.string.aa_previous_page) { | ||||||
| page-- | ||||||
| invalidate() | ||||||
| }, | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| pageEntities.forEach { entity -> | ||||||
| listBuilder.addItem(buildEntityRow(entity)) | ||||||
| } | ||||||
|
|
||||||
| if (pageSlice.hasNextPage) { | ||||||
| listBuilder.addItem( | ||||||
| buildNavigationRow(commonR.string.aa_next_page) { | ||||||
| page++ | ||||||
| invalidate() | ||||||
| }, | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| if (isLoaded && entities.isEmpty()) { | ||||||
| listBuilder.setNoItemsMessage(carContext.getString(commonR.string.no_supported_entities)) | ||||||
| } | ||||||
|
|
||||||
| return listBuilder | ||||||
| } | ||||||
|
|
||||||
| private fun buildNavigationRow(titleRes: Int, onClick: () -> Unit): Row = Row.Builder() | ||||||
| .setTitle(carContext.getString(titleRes)) | ||||||
| .setOnClickListener(onClick) | ||||||
| .build() | ||||||
|
|
||||||
| private fun buildEntityRow(entity: Entity): Row { | ||||||
| val isFavorite = favoritesList.any { | ||||||
| it.serverId == serverId.value && it.entityId == entity.entityId | ||||||
| } | ||||||
| val friendlyName = entity.attributes["friendly_name"]?.toString() ?: entity.entityId | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
We already have an extension to get the name and the fallback properly. |
||||||
| val domainLabel = SUPPORTED_DOMAINS_WITH_STRING[entity.domain] | ||||||
| ?.let { carContext.getString(it) } | ||||||
| ?: entity.domain | ||||||
|
|
||||||
| return Row.Builder() | ||||||
| .setTitle(friendlyName) | ||||||
| .addText(domainLabel) | ||||||
| .setToggle( | ||||||
| Toggle.Builder { isChecked -> | ||||||
| lifecycleScope.launch { | ||||||
| toggleMutex.withLock { | ||||||
| val favorite = AutoFavorite( | ||||||
| serverId = serverId.value, | ||||||
| entityId = entity.entityId, | ||||||
| ) | ||||||
| if (isChecked) { | ||||||
| prefsRepository.addAutoFavorite(favorite) | ||||||
| } else { | ||||||
| prefsRepository.setAutoFavorites( | ||||||
| favoritesList.filterNot { it == favorite }, | ||||||
| ) | ||||||
| } | ||||||
| favoritesList = prefsRepository.getAutoFavorites() | ||||||
| val favoriteEntityIds = favoritesList | ||||||
| .filter { it.serverId == serverId.value } | ||||||
| .map { it.entityId } | ||||||
| .toSet() | ||||||
| entities = entities.sortedWith( | ||||||
| compareByDescending<Entity> { it.entityId in favoriteEntityIds } | ||||||
| .thenBy { it.attributes["friendly_name"]?.toString() ?: it.entityId }, | ||||||
| ) | ||||||
| invalidate() | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| .setChecked(isFavorite) | ||||||
| .build(), | ||||||
| ) | ||||||
| .build() | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| internal data class PageSlice( | ||||||
| val fromIndex: Int, | ||||||
| val toIndex: Int, | ||||||
| val hasPreviousPage: Boolean, | ||||||
| val hasNextPage: Boolean, | ||||||
| ) | ||||||
|
|
||||||
| /** | ||||||
| * Computes the slice of entities to display for the given page. | ||||||
| * | ||||||
| * Always reserves 2 rows for navigation (previous/next), giving a consistent | ||||||
| * [itemsPerPage] across all pages and avoiding skipped entities. | ||||||
| */ | ||||||
| internal fun computePageSlice(totalItems: Int, page: Int, listLimit: Int): PageSlice { | ||||||
| val itemsPerPage = (listLimit - 2).coerceAtLeast(1) | ||||||
| val fromIndex = page * itemsPerPage | ||||||
| val toIndex = minOf(fromIndex + itemsPerPage, totalItems) | ||||||
| return PageSlice( | ||||||
| fromIndex = fromIndex, | ||||||
| toIndex = toIndex, | ||||||
| hasPreviousPage = page > 0, | ||||||
| hasNextPage = toIndex < totalItems, | ||||||
| ) | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.