Implement support for multiple Shortcut Tiles on Wear OS (#3697)

* Extract JSONArray -> List<String> conversion to extension fun

* Refactor navigation around Shortcuts Tile settings

* Add ShortcutsTileId enum class

* Introduce multiple ShortcutTile subclasses and modify settings UI and storage to support multiple Tiles

* Check if correct ShortcutsTileId is passed as parameter to BaseShortcutTile

* Update TileUpdateRequester usages to account for multiple Tiles

* Add entity count to Shortcut Tile list in Settings

* Fix ktlint errors

* Fix more ktlint errors

* Extract string resource

* Add MULTI_INSTANCES_SUPPORTED to ShortcutsTile to be able to use any number of Tiles

* Refresh the list of Shortcut Tiles in the Settings without needing to restart the app

* Remove test logs

* Update androidx.wear.tiles:tiles to the latest version which doesn't yet require API 34 compileSdk version

* Fix crash when the preference's value is "{}"

* Fix crash when key String is "null" and converting to Int

* Rename placeholder variable name

* Add a comment explaining why to save the tiles in a getter

* Return emptyList() directly for clarity

* Remove icons from "Shortcut tile #n" entries in Settings

* Pass emptyList instead of using the non-null assertion operator to prevent NPEs in edge cases

* Refactor getTileShortcuts and getAllTileShortcuts in WearPrefsRepositoryImpl.
Make the code more readable and understandable, and reduce code duplication.

* Make it explicit that intKey is only intended to be null if stringKey == "null"

* Rename getTileShortcuts to getTileShortcutsAndSaveTileId and make it save the tileId not only when there's a null key in the map, but also when the tileId is not yet in the map. This way, actual Tiles and the tileId keys will be more in sync.

* Handle adding Shortcuts Tile immediately after updating the app to a new version which introduces support for multiple Shortcuts Tiles.

* Show message in the Settings when there are no Shortcuts tiles added yet.

* Refine message in the Settings when there are no Shortcuts tiles added yet

* WIP: ConfigShortcutsTile action

* Update comments about Wear OS versions

* Finalize ConfigShortcutsTile feature by applying @jpelgrom's suggestion to OpenShortcutTileSettingsActivity

* Only wrap the code in runCatching which is expected to throw an Exception under normal circumstances, when the pref value needs to be migrated from the old format.

* Call getTileShortcutsAndSaveTileId in OpenShortcutTileSettingsActivity

* Remove unnecessary stuff
This commit is contained in:
Márton Maráz 2023-07-31 21:14:01 +02:00 committed by GitHub
parent fcab330115
commit a8a7363317
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 421 additions and 100 deletions

View file

@ -1,8 +1,10 @@
package io.homeassistant.companion.android.common.data.prefs package io.homeassistant.companion.android.common.data.prefs
interface WearPrefsRepository { interface WearPrefsRepository {
suspend fun getTileShortcuts(): List<String> suspend fun getAllTileShortcuts(): Map<Int?, List<String>>
suspend fun setTileShortcuts(entities: List<String>) suspend fun getTileShortcutsAndSaveTileId(tileId: Int): List<String>
suspend fun setTileShortcuts(tileId: Int?, entities: List<String>)
suspend fun removeTileShortcuts(tileId: Int?): List<String>?
suspend fun getShowShortcutText(): Boolean suspend fun getShowShortcutText(): Boolean
suspend fun setShowShortcutTextEnabled(enabled: Boolean) suspend fun setShowShortcutTextEnabled(enabled: Boolean)
suspend fun getTemplateTileContent(): String suspend fun getTemplateTileContent(): String

View file

@ -1,8 +1,10 @@
package io.homeassistant.companion.android.common.data.prefs package io.homeassistant.companion.android.common.data.prefs
import io.homeassistant.companion.android.common.data.LocalStorage import io.homeassistant.companion.android.common.data.LocalStorage
import io.homeassistant.companion.android.common.util.toStringList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
@ -52,15 +54,68 @@ class WearPrefsRepositoryImpl @Inject constructor(
} }
} }
override suspend fun getTileShortcuts(): List<String> { override suspend fun getTileShortcutsAndSaveTileId(tileId: Int): List<String> {
val jsonArray = JSONArray(localStorage.getString(PREF_TILE_SHORTCUTS) ?: "[]") val tileIdToShortcutsMap = getAllTileShortcuts()
return List(jsonArray.length()) { return if (null in tileIdToShortcutsMap && tileId !in tileIdToShortcutsMap) {
jsonArray.getString(it) // if there are shortcuts with an unknown (null) tileId key from a previous installation,
// and the tileId parameter is not already present in the map, associate it with those shortcuts
val entities = removeTileShortcuts(null)!!
setTileShortcuts(tileId, entities)
entities
} else {
val entities = tileIdToShortcutsMap[tileId]
if (entities == null) {
setTileShortcuts(tileId, emptyList())
}
entities ?: emptyList()
} }
} }
override suspend fun setTileShortcuts(entities: List<String>) { override suspend fun getAllTileShortcuts(): Map<Int?, List<String>> {
localStorage.putString(PREF_TILE_SHORTCUTS, JSONArray(entities).toString()) return localStorage.getString(PREF_TILE_SHORTCUTS)?.let { jsonStr ->
runCatching {
JSONObject(jsonStr)
}.fold(
onSuccess = { jsonObject ->
buildMap {
jsonObject.keys().forEach { stringKey ->
val intKey = stringKey.takeUnless { it == "null" }?.toInt()
val jsonArray = jsonObject.getJSONArray(stringKey)
val entities = jsonArray.toStringList()
put(intKey, entities)
}
}
},
onFailure = {
// backward compatibility with the previous format when there was only one Shortcut Tile:
val jsonArray = JSONArray(jsonStr)
val entities = jsonArray.toStringList()
mapOf(
null to entities // the key is null since we don't (yet) have the tileId
)
}
)
} ?: emptyMap()
}
override suspend fun setTileShortcuts(tileId: Int?, entities: List<String>) {
val map = getAllTileShortcuts() + mapOf(tileId to entities)
setTileShortcuts(map)
}
private suspend fun setTileShortcuts(map: Map<Int?, List<String>>) {
val jsonArrayMap = map.map { (tileId, entities) ->
tileId.toString() to JSONArray(entities)
}.toMap()
val jsonStr = JSONObject(jsonArrayMap).toString()
localStorage.putString(PREF_TILE_SHORTCUTS, jsonStr)
}
override suspend fun removeTileShortcuts(tileId: Int?): List<String>? {
val tileShortcutsMap = getAllTileShortcuts().toMutableMap()
val entities = tileShortcutsMap.remove(tileId)
setTileShortcuts(tileShortcutsMap)
return entities
} }
override suspend fun getTemplateTileContent(): String { override suspend fun getTemplateTileContent(): String {

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.common.util
import org.json.JSONArray
fun JSONArray.toStringList(): List<String> =
List(length()) { i ->
getString(i)
}

View file

@ -217,6 +217,10 @@
<item quantity="one">%d entity found</item> <item quantity="one">%d entity found</item>
<item quantity="other">%d entities found</item> <item quantity="other">%d entities found</item>
</plurals> </plurals>
<plurals name="n_entities">
<item quantity="one">1 entity</item>
<item quantity="other">%d entities</item>
</plurals>
<string name="entity_attribute_checkbox">Append Attribute Value</string> <string name="entity_attribute_checkbox">Append Attribute Value</string>
<string name="entity_id">Entity ID</string> <string name="entity_id">Entity ID</string>
<string name="entity_id_name">Entity ID: %1$s</string> <string name="entity_id_name">Entity ID: %1$s</string>
@ -732,11 +736,14 @@
<string name="shortcuts_choose">Choose shortcuts</string> <string name="shortcuts_choose">Choose shortcuts</string>
<string name="shortcut5_note">Not all launchers support displaying 5 shortcuts, you may not see this shortcut if the above 4 are already added.</string> <string name="shortcut5_note">Not all launchers support displaying 5 shortcuts, you may not see this shortcut if the above 4 are already added.</string>
<string name="shortcuts_summary">Add shortcuts to launcher</string> <string name="shortcuts_summary">Add shortcuts to launcher</string>
<string name="shortcuts_tile">Shortcuts tile</string> <string name="shortcut_tiles">Shortcut tiles</string>
<string name="shortcuts_tile_n">Shortcuts tile #%d</string>
<string name="shortcuts_tile_select">Select shortcuts tile to manage</string>
<string name="shortcuts_tile_description">Select up to 7 entities</string> <string name="shortcuts_tile_description">Select up to 7 entities</string>
<string name="shortcuts_tile_empty">Choose entities in settings</string> <string name="shortcuts_tile_empty">Choose entities in settings</string>
<string name="shortcuts_tile_text_setting">Show names on shortcuts tile</string> <string name="shortcuts_tile_text_setting">Show names on shortcuts tile</string>
<string name="shortcuts_tile_log_in">Log in to Home Assistant to add your first shortcut</string> <string name="shortcuts_tile_log_in">Log in to Home Assistant to add your first shortcut</string>
<string name="shortcuts_tile_no_tiles_yet">There are no Shortcut tiles added yet</string>
<string name="shortcuts">Shortcuts</string> <string name="shortcuts">Shortcuts</string>
<string name="show">Show</string> <string name="show">Show</string>
<string name="show_share_logs_summary">Sharing logs with the Home Assistant team will help to solve issues. Please share the logs only if you have been asked to do so by a Home Assistant developer</string> <string name="show_share_logs_summary">Sharing logs with the Home Assistant team will help to solve issues. Please share the logs only if you have been asked to do so by a Home Assistant developer</string>

View file

@ -50,7 +50,7 @@ sentry-android = "6.26.0"
watchfaceComplicationsDataSourceKtx = "1.1.1" watchfaceComplicationsDataSourceKtx = "1.1.1"
wear = "1.2.0" wear = "1.2.0"
wear-compose-foundation = "1.1.2" wear-compose-foundation = "1.1.2"
wear-tiles = "1.1.0" wear-tiles = "1.2.0-alpha07"
wearPhoneInteractions = "1.0.1" wearPhoneInteractions = "1.0.1"
wearInput = "1.2.0-alpha02" wearInput = "1.2.0-alpha02"
webkit = "1.7.0" webkit = "1.7.0"

View file

@ -83,6 +83,17 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".tiles.OpenShortcutTileSettingsActivity"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="ConfigShortcutsTile" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
</activity>
<!-- To show confirmations and failures --> <!-- To show confirmations and failures -->
<activity android:name="androidx.wear.activity.ConfirmationActivity" /> <activity android:name="androidx.wear.activity.ConfirmationActivity" />
@ -122,6 +133,14 @@
<meta-data android:name="androidx.wear.tiles.PREVIEW" <meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/favorite_entities_tile_example" /> android:resource="@drawable/favorite_entities_tile_example" />
<meta-data
android:name="com.google.android.clockwork.tiles.MULTI_INSTANCES_SUPPORTED"
android:value="true" /> <!-- This is supported starting from Wear OS 3 -->
<meta-data
android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION"
android:value="ConfigShortcutsTile" />
</service> </service>
<service <service
android:name=".tiles.TemplateTile" android:name=".tiles.TemplateTile"
@ -219,4 +238,4 @@
</service> </service>
</application> </application>
</manifest> </manifest>

View file

@ -6,10 +6,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_SHORTCUT_TILE
import io.homeassistant.companion.android.home.views.LoadHomePage import io.homeassistant.companion.android.home.views.LoadHomePage
import io.homeassistant.companion.android.onboarding.OnboardingActivity import io.homeassistant.companion.android.onboarding.OnboardingActivity
import io.homeassistant.companion.android.sensors.SensorReceiver import io.homeassistant.companion.android.sensors.SensorReceiver
@ -34,6 +36,16 @@ class HomeActivity : ComponentActivity(), HomeView {
fun newInstance(context: Context): Intent { fun newInstance(context: Context): Intent {
return Intent(context, HomeActivity::class.java) return Intent(context, HomeActivity::class.java)
} }
fun getShortcutsTileSettingsIntent(
context: Context,
tileId: Int
) = Intent(
Intent.ACTION_VIEW,
"$DEEPLINK_PREFIX_SET_SHORTCUT_TILE/$tileId".toUri(),
context,
HomeActivity::class.java
)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {

View file

@ -38,8 +38,9 @@ interface HomePresenter {
suspend fun getDeviceRegistryUpdates(): Flow<DeviceRegistryUpdatedEvent>? suspend fun getDeviceRegistryUpdates(): Flow<DeviceRegistryUpdatedEvent>?
suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>? suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>?
suspend fun getTileShortcuts(): List<SimplifiedEntity> suspend fun getAllTileShortcuts(): Map<Int?, List<SimplifiedEntity>>
suspend fun setTileShortcuts(entities: List<SimplifiedEntity>) suspend fun getTileShortcuts(tileId: Int): List<SimplifiedEntity>
suspend fun setTileShortcuts(tileId: Int?, entities: List<SimplifiedEntity>)
suspend fun getWearHapticFeedback(): Boolean suspend fun getWearHapticFeedback(): Boolean
suspend fun setWearHapticFeedback(enabled: Boolean) suspend fun setWearHapticFeedback(enabled: Boolean)

View file

@ -233,12 +233,20 @@ class HomePresenterImpl @Inject constructor(
return serverManager.webSocketRepository().getEntityRegistryUpdates() return serverManager.webSocketRepository().getEntityRegistryUpdates()
} }
override suspend fun getTileShortcuts(): List<SimplifiedEntity> { override suspend fun getAllTileShortcuts(): Map<Int?, List<SimplifiedEntity>> {
return wearPrefsRepository.getTileShortcuts().map { SimplifiedEntity(it) } return wearPrefsRepository.getAllTileShortcuts().mapValues { (_, entities) ->
entities.map {
SimplifiedEntity(it)
}
}
} }
override suspend fun setTileShortcuts(entities: List<SimplifiedEntity>) { override suspend fun getTileShortcuts(tileId: Int): List<SimplifiedEntity> {
wearPrefsRepository.setTileShortcuts(entities.map { it.entityString }) return wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId).map { SimplifiedEntity(it) }
}
override suspend fun setTileShortcuts(tileId: Int?, entities: List<SimplifiedEntity>) {
wearPrefsRepository.setTileShortcuts(tileId, entities.map { it.entityString })
} }
override suspend fun getWearHapticFeedback(): Boolean { override suspend fun getWearHapticFeedback(): Boolean {

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -85,8 +86,8 @@ class MainViewModel @Inject constructor(
val favoriteEntityIds = favoritesDao.getAllFlow().collectAsState() val favoriteEntityIds = favoritesDao.getAllFlow().collectAsState()
private val favoriteCaches = favoriteCachesDao.getAll() private val favoriteCaches = favoriteCachesDao.getAll()
var shortcutEntities = mutableStateListOf<SimplifiedEntity>() val shortcutEntitiesMap = mutableStateMapOf<Int?, SnapshotStateList<SimplifiedEntity>>()
private set
var areas = mutableListOf<AreaRegistryResponse>() var areas = mutableListOf<AreaRegistryResponse>()
private set private set
@ -136,7 +137,7 @@ class MainViewModel @Inject constructor(
if (!homePresenter.isConnected()) { if (!homePresenter.isConnected()) {
return@launch return@launch
} }
shortcutEntities.addAll(homePresenter.getTileShortcuts()) loadShortcutTileEntities()
isHapticEnabled.value = homePresenter.getWearHapticFeedback() isHapticEnabled.value = homePresenter.getWearHapticFeedback()
isToastEnabled.value = homePresenter.getWearToastConfirmation() isToastEnabled.value = homePresenter.getWearToastConfirmation()
isShowShortcutTextEnabled.value = homePresenter.getShowShortcutText() isShowShortcutTextEnabled.value = homePresenter.getShowShortcutText()
@ -153,6 +154,16 @@ class MainViewModel @Inject constructor(
} }
} }
fun loadShortcutTileEntities() {
viewModelScope.launch {
val map = homePresenter.getAllTileShortcuts().mapValues { (_, entities) ->
entities.toMutableStateList()
}
shortcutEntitiesMap.clear()
shortcutEntitiesMap.putAll(map)
}
}
fun loadEntities() { fun loadEntities() {
if (!homePresenter.isConnected()) return if (!homePresenter.isConnected()) return
viewModelScope.launch { viewModelScope.launch {
@ -401,22 +412,24 @@ class MainViewModel @Inject constructor(
} }
} }
fun setTileShortcut(index: Int, entity: SimplifiedEntity) { fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) {
viewModelScope.launch { viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!
if (index < shortcutEntities.size) { if (index < shortcutEntities.size) {
shortcutEntities[index] = entity shortcutEntities[index] = entity
} else { } else {
shortcutEntities.add(entity) shortcutEntities.add(entity)
} }
homePresenter.setTileShortcuts(shortcutEntities) homePresenter.setTileShortcuts(tileId, entities = shortcutEntities)
} }
} }
fun clearTileShortcut(index: Int) { fun clearTileShortcut(tileId: Int?, index: Int) {
viewModelScope.launch { viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!
if (index < shortcutEntities.size) { if (index < shortcutEntities.size) {
shortcutEntities.removeAt(index) shortcutEntities.removeAt(index)
homePresenter.setTileShortcuts(shortcutEntities) homePresenter.setTileShortcuts(tileId, entities = shortcutEntities)
} }
} }
} }

View file

@ -1,10 +1,6 @@
package io.homeassistant.companion.android.home.views package io.homeassistant.companion.android.home.views
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
@ -21,6 +17,8 @@ import io.homeassistant.companion.android.tiles.TemplateTile
import io.homeassistant.companion.android.views.ChooseEntityView import io.homeassistant.companion.android.views.ChooseEntityView
private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId" private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ID = "shortcutsTileId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX = "shortcutsTileEntityIndex"
private const val SCREEN_LANDING = "landing" private const val SCREEN_LANDING = "landing"
private const val SCREEN_ENTITY_DETAIL = "entity_detail" private const val SCREEN_ENTITY_DETAIL = "entity_detail"
@ -29,18 +27,20 @@ private const val SCREEN_MANAGE_SENSORS = "manage_all_sensors"
private const val SCREEN_SINGLE_SENSOR_MANAGER = "sensor_manager" private const val SCREEN_SINGLE_SENSOR_MANAGER = "sensor_manager"
private const val SCREEN_SETTINGS = "settings" private const val SCREEN_SETTINGS = "settings"
private const val SCREEN_SET_FAVORITES = "set_favorites" private const val SCREEN_SET_FAVORITES = "set_favorites"
private const val SCREEN_SET_TILE_SHORTCUTS = "set_tile_shortcuts" private const val ROUTE_SHORTCUTS_TILE = "shortcuts_tile"
private const val SCREEN_SELECT_TILE_SHORTCUT = "select_tile_shortcut" private const val SCREEN_SELECT_SHORTCUTS_TILE = "select_shortcuts_tile"
private const val SCREEN_SET_SHORTCUTS_TILE = "set_shortcuts_tile"
private const val SCREEN_SHORTCUTS_TILE_CHOOSE_ENTITY = "shortcuts_tile_choose_entity"
private const val SCREEN_SET_TILE_TEMPLATE = "set_tile_template" private const val SCREEN_SET_TILE_TEMPLATE = "set_tile_template"
private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template_refresh_interval" private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template_refresh_interval"
const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER" const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER"
const val DEEPLINK_PREFIX_SET_SHORTCUT_TILE = "ha_wear://$SCREEN_SET_SHORTCUTS_TILE"
@Composable @Composable
fun LoadHomePage( fun LoadHomePage(
mainViewModel: MainViewModel mainViewModel: MainViewModel
) { ) {
var shortcutEntitySelectionIndex: Int by remember { mutableStateOf(0) }
val context = LocalContext.current val context = LocalContext.current
WearAppTheme { WearAppTheme {
@ -129,8 +129,9 @@ fun LoadHomePage(
}, },
onClearFavorites = { mainViewModel.clearFavorites() }, onClearFavorites = { mainViewModel.clearFavorites() },
onClickSetShortcuts = { onClickSetShortcuts = {
mainViewModel.loadShortcutTileEntities()
swipeDismissableNavController.navigate( swipeDismissableNavController.navigate(
SCREEN_SET_TILE_SHORTCUTS "$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE"
) )
}, },
onClickSensors = { onClickSensors = {
@ -162,33 +163,63 @@ fun LoadHomePage(
} }
} }
} }
composable(SCREEN_SET_TILE_SHORTCUTS) { composable("$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE") {
SetTileShortcutsView( SelectShortcutsTileView(
shortcutEntities = mainViewModel.shortcutEntities, shortcutTileEntitiesCountById = mainViewModel.shortcutEntitiesMap.mapValues { (_, entities) -> entities.size },
onShortcutEntitySelectionChange = { onSelectShortcutsTile = { tileId ->
shortcutEntitySelectionIndex = it swipeDismissableNavController.navigate("$ROUTE_SHORTCUTS_TILE/$tileId/$SCREEN_SET_SHORTCUTS_TILE")
swipeDismissableNavController.navigate(SCREEN_SELECT_TILE_SHORTCUT)
}, },
isShowShortcutTextEnabled = mainViewModel.isShowShortcutTextEnabled.value, isShowShortcutTextEnabled = mainViewModel.isShowShortcutTextEnabled.value,
onShowShortcutTextEnabled = { onShowShortcutTextEnabled = {
mainViewModel.setShowShortcutTextEnabled(it) mainViewModel.setShowShortcutTextEnabled(it)
TileService.getUpdater(context).requestUpdate(ShortcutsTile::class.java) ShortcutsTile.requestUpdate(context)
} }
) )
} }
composable(SCREEN_SELECT_TILE_SHORTCUT) { composable(
route = "$ROUTE_SHORTCUTS_TILE/{$ARG_SCREEN_SHORTCUTS_TILE_ID}/$SCREEN_SET_SHORTCUTS_TILE",
arguments = listOf(
navArgument(name = ARG_SCREEN_SHORTCUTS_TILE_ID) {
type = NavType.StringType
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "$DEEPLINK_PREFIX_SET_SHORTCUT_TILE/{$ARG_SCREEN_SHORTCUTS_TILE_ID}" }
)
) { backStackEntry ->
val tileId = backStackEntry.arguments!!.getString(ARG_SCREEN_SHORTCUTS_TILE_ID)!!.toIntOrNull()
SetShortcutsTileView(
shortcutEntities = mainViewModel.shortcutEntitiesMap[tileId] ?: emptyList(),
onShortcutEntitySelectionChange = { entityIndex ->
swipeDismissableNavController.navigate("$ROUTE_SHORTCUTS_TILE/$tileId/$SCREEN_SHORTCUTS_TILE_CHOOSE_ENTITY/$entityIndex")
}
)
}
composable(
route = "$ROUTE_SHORTCUTS_TILE/{$ARG_SCREEN_SHORTCUTS_TILE_ID}/$SCREEN_SHORTCUTS_TILE_CHOOSE_ENTITY/{$ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX}",
arguments = listOf(
navArgument(name = ARG_SCREEN_SHORTCUTS_TILE_ID) {
type = NavType.StringType
},
navArgument(name = ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX) {
type = NavType.IntType
}
)
) { backStackEntry ->
val entityIndex = backStackEntry.arguments!!.getInt(ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX)
val tileId = backStackEntry.arguments!!.getString(ARG_SCREEN_SHORTCUTS_TILE_ID)!!.toIntOrNull()
ChooseEntityView( ChooseEntityView(
entitiesByDomainOrder = mainViewModel.entitiesByDomainOrder, entitiesByDomainOrder = mainViewModel.entitiesByDomainOrder,
entitiesByDomain = mainViewModel.entitiesByDomain, entitiesByDomain = mainViewModel.entitiesByDomain,
favoriteEntityIds = mainViewModel.favoriteEntityIds, favoriteEntityIds = mainViewModel.favoriteEntityIds,
onNoneClicked = { onNoneClicked = {
mainViewModel.clearTileShortcut(shortcutEntitySelectionIndex) mainViewModel.clearTileShortcut(tileId, entityIndex)
TileService.getUpdater(context).requestUpdate(ShortcutsTile::class.java) ShortcutsTile.requestUpdate(context)
swipeDismissableNavController.navigateUp() swipeDismissableNavController.navigateUp()
}, },
onEntitySelected = { entity -> onEntitySelected = { entity ->
mainViewModel.setTileShortcut(shortcutEntitySelectionIndex, entity) mainViewModel.setTileShortcut(tileId, entityIndex, entity)
TileService.getUpdater(context).requestUpdate(ShortcutsTile::class.java) ShortcutsTile.requestUpdate(context)
swipeDismissableNavController.navigateUp() swipeDismissableNavController.navigateUp()
} }
) )

View file

@ -0,0 +1,136 @@
package io.homeassistant.companion.android.home.views
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.ToggleChip
import androidx.wear.compose.material.ToggleChipDefaults
import androidx.wear.compose.material.itemsIndexed
import androidx.wear.compose.material.rememberScalingLazyListState
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.theme.wearColorPalette
import io.homeassistant.companion.android.views.ListHeader
import io.homeassistant.companion.android.views.ThemeLazyColumn
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun SelectShortcutsTileView(
shortcutTileEntitiesCountById: Map<Int?, Int>,
onSelectShortcutsTile: (tileId: Int?) -> Unit,
isShowShortcutTextEnabled: Boolean,
onShowShortcutTextEnabled: (Boolean) -> Unit
) {
val scalingLazyListState = rememberScalingLazyListState()
WearAppTheme {
Scaffold(
positionIndicator = {
if (scalingLazyListState.isScrollInProgress) {
PositionIndicator(scalingLazyListState = scalingLazyListState)
}
},
timeText = { TimeText(scalingLazyListState = scalingLazyListState) }
) {
ThemeLazyColumn(state = scalingLazyListState) {
item {
ListHeader(id = commonR.string.shortcut_tiles)
}
item {
ToggleChip(
modifier = Modifier.fillMaxWidth(),
checked = isShowShortcutTextEnabled,
onCheckedChange = { onShowShortcutTextEnabled(it) },
label = {
Text(stringResource(commonR.string.shortcuts_tile_text_setting))
},
appIcon = {
Image(
asset =
if (isShowShortcutTextEnabled) {
CommunityMaterial.Icon.cmd_alphabetical
} else {
CommunityMaterial.Icon.cmd_alphabetical_off
},
colorFilter = ColorFilter.tint(wearColorPalette.onSurface)
)
},
toggleControl = {
Icon(
imageVector = ToggleChipDefaults.checkboxIcon(isShowShortcutTextEnabled),
contentDescription = if (isShowShortcutTextEnabled) {
stringResource(commonR.string.show)
} else {
stringResource(commonR.string.hide)
}
)
}
)
}
item {
ListHeader(id = commonR.string.shortcuts_tile_select)
}
if (shortcutTileEntitiesCountById.isEmpty()) {
item {
Text(stringResource(commonR.string.shortcuts_tile_no_tiles_yet))
}
} else {
itemsIndexed(shortcutTileEntitiesCountById.keys.toList()) { index, shortcutsTileId ->
Chip(
modifier = Modifier
.fillMaxWidth(),
label = {
Text(stringResource(commonR.string.shortcuts_tile_n, index + 1))
},
secondaryLabel = {
val entityCount = shortcutTileEntitiesCountById[shortcutsTileId] ?: 0
if (entityCount > 0) {
Text(pluralStringResource(commonR.plurals.n_entities, entityCount, entityCount))
}
},
onClick = { onSelectShortcutsTile(shortcutsTileId) },
colors = ChipDefaults.secondaryChipColors()
)
}
}
}
}
}
}
@Preview(device = Devices.WEAR_OS_LARGE_ROUND)
@Composable
private fun PreviewSelectShortcutsTileView() {
SelectShortcutsTileView(
shortcutTileEntitiesCountById = mapOf(
null to 7,
1111 to 1,
2222 to 0
),
onSelectShortcutsTile = {},
isShowShortcutTextEnabled = true,
onShowShortcutTextEnabled = {}
)
}
@Preview(device = Devices.WEAR_OS_LARGE_ROUND)
@Composable
private fun PreviewSelectShortcutsTileEmptyView() {
SelectShortcutsTileView(
shortcutTileEntitiesCountById = emptyMap(),
onSelectShortcutsTile = {},
isShowShortcutTextEnabled = true,
onShowShortcutTextEnabled = {}
)
}

View file

@ -16,18 +16,14 @@ import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.Chip import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text import androidx.wear.compose.material.Text
import androidx.wear.compose.material.ToggleChip
import androidx.wear.compose.material.ToggleChipDefaults
import androidx.wear.compose.material.rememberScalingLazyListState import androidx.wear.compose.material.rememberScalingLazyListState
import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.data.SimplifiedEntity import io.homeassistant.companion.android.data.SimplifiedEntity
import io.homeassistant.companion.android.theme.WearAppTheme import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.theme.wearColorPalette
import io.homeassistant.companion.android.util.getIcon import io.homeassistant.companion.android.util.getIcon
import io.homeassistant.companion.android.util.simplifiedEntity import io.homeassistant.companion.android.util.simplifiedEntity
import io.homeassistant.companion.android.views.ListHeader import io.homeassistant.companion.android.views.ListHeader
@ -35,11 +31,9 @@ import io.homeassistant.companion.android.views.ThemeLazyColumn
import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.R as commonR
@Composable @Composable
fun SetTileShortcutsView( fun SetShortcutsTileView(
shortcutEntities: MutableList<SimplifiedEntity>, shortcutEntities: List<SimplifiedEntity>,
onShortcutEntitySelectionChange: (Int) -> Unit, onShortcutEntitySelectionChange: (Int) -> Unit
isShowShortcutTextEnabled: Boolean,
onShowShortcutTextEnabled: (Boolean) -> Unit
) { ) {
val scalingLazyListState = rememberScalingLazyListState() val scalingLazyListState = rememberScalingLazyListState()
WearAppTheme { WearAppTheme {
@ -52,40 +46,6 @@ fun SetTileShortcutsView(
timeText = { TimeText(scalingLazyListState = scalingLazyListState) } timeText = { TimeText(scalingLazyListState = scalingLazyListState) }
) { ) {
ThemeLazyColumn(state = scalingLazyListState) { ThemeLazyColumn(state = scalingLazyListState) {
item {
ListHeader(id = commonR.string.shortcuts_tile)
}
item {
ToggleChip(
modifier = Modifier.fillMaxWidth(),
checked = isShowShortcutTextEnabled,
onCheckedChange = { onShowShortcutTextEnabled(it) },
label = {
Text(stringResource(commonR.string.shortcuts_tile_text_setting))
},
appIcon = {
Image(
asset =
if (isShowShortcutTextEnabled) {
CommunityMaterial.Icon.cmd_alphabetical
} else {
CommunityMaterial.Icon.cmd_alphabetical_off
},
colorFilter = ColorFilter.tint(wearColorPalette.onSurface)
)
},
toggleControl = {
Icon(
imageVector = ToggleChipDefaults.checkboxIcon(isShowShortcutTextEnabled),
contentDescription = if (isShowShortcutTextEnabled) {
stringResource(commonR.string.show)
} else {
stringResource(commonR.string.hide)
}
)
}
)
}
item { item {
ListHeader(id = commonR.string.shortcuts_choose) ListHeader(id = commonR.string.shortcuts_choose)
} }
@ -143,10 +103,8 @@ fun SetTileShortcutsView(
@Preview(device = Devices.WEAR_OS_LARGE_ROUND) @Preview(device = Devices.WEAR_OS_LARGE_ROUND)
@Composable @Composable
private fun PreviewSetTileShortcutsView() { private fun PreviewSetTileShortcutsView() {
SetTileShortcutsView( SetShortcutsTileView(
shortcutEntities = mutableListOf(simplifiedEntity), shortcutEntities = mutableListOf(simplifiedEntity),
onShortcutEntitySelectionChange = {}, onShortcutEntitySelectionChange = {}
isShowShortcutTextEnabled = true,
onShowShortcutTextEnabled = {}
) )
} }

View file

@ -216,7 +216,7 @@ fun SettingsView(
item { item {
SecondarySettingsChip( SecondarySettingsChip(
icon = CommunityMaterial.Icon3.cmd_star_circle_outline, icon = CommunityMaterial.Icon3.cmd_star_circle_outline,
label = stringResource(commonR.string.shortcuts_tile), label = stringResource(commonR.string.shortcut_tiles),
onClick = onClickSetShortcuts onClick = onClickSetShortcuts
) )
} }

View file

@ -58,7 +58,8 @@ class MobileAppIntegrationPresenterImpl @Inject constructor(
private fun updateTiles() = mainScope.launch { private fun updateTiles() = mainScope.launch {
try { try {
val updater = TileService.getUpdater(view as Context) val context = view as Context
val updater = TileService.getUpdater(context)
updater.requestUpdate(ConversationTile::class.java) updater.requestUpdate(ConversationTile::class.java)
updater.requestUpdate(ShortcutsTile::class.java) updater.requestUpdate(ShortcutsTile::class.java)
updater.requestUpdate(TemplateTile::class.java) updater.requestUpdate(TemplateTile::class.java)

View file

@ -0,0 +1,33 @@
package io.homeassistant.companion.android.tiles
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepositoryImpl
import io.homeassistant.companion.android.home.HomeActivity
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class OpenShortcutTileSettingsActivity : AppCompatActivity() {
@Inject
lateinit var wearPrefsRepository: WearPrefsRepositoryImpl
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val tileId = intent.extras?.getInt("com.google.android.clockwork.EXTRA_PROVIDER_CONFIG_TILE_ID")
tileId?.takeIf { it != 0 }?.let {
lifecycleScope.launch {
wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId)
}
val intent = HomeActivity.getShortcutsTileSettingsIntent(
context = this,
tileId = it
)
startActivity(intent)
}
finish()
}
}

View file

@ -1,5 +1,6 @@
package io.homeassistant.companion.android.tiles package io.homeassistant.companion.android.tiles
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
@ -9,6 +10,7 @@ import androidx.wear.tiles.ActionBuilders
import androidx.wear.tiles.ColorBuilders.argb import androidx.wear.tiles.ColorBuilders.argb
import androidx.wear.tiles.DimensionBuilders.dp import androidx.wear.tiles.DimensionBuilders.dp
import androidx.wear.tiles.DimensionBuilders.sp import androidx.wear.tiles.DimensionBuilders.sp
import androidx.wear.tiles.EventBuilders
import androidx.wear.tiles.LayoutElementBuilders import androidx.wear.tiles.LayoutElementBuilders
import androidx.wear.tiles.LayoutElementBuilders.Box import androidx.wear.tiles.LayoutElementBuilders.Box
import androidx.wear.tiles.LayoutElementBuilders.Column import androidx.wear.tiles.LayoutElementBuilders.Column
@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.guava.future import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import java.nio.ByteBuffer import java.nio.ByteBuffer
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
@ -78,13 +81,14 @@ class ShortcutsTile : TileService() {
} }
} }
val entities = getEntities() val tileId = requestParams.tileId
val entities = getEntities(tileId)
Tile.Builder() Tile.Builder()
.setResourcesVersion(entities.toString()) .setResourcesVersion(entities.toString())
.setTimeline( .setTimeline(
if (serverManager.isRegistered()) { if (serverManager.isRegistered()) {
timeline() timeline(tileId)
} else { } else {
loggedOutTimeline( loggedOutTimeline(
this@ShortcutsTile, this@ShortcutsTile,
@ -102,7 +106,7 @@ class ShortcutsTile : TileService() {
val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL
val density = requestParams.deviceParameters!!.screenDensity val density = requestParams.deviceParameters!!.screenDensity
val iconSizePx = (iconSize * density).roundToInt() val iconSizePx = (iconSize * density).roundToInt()
val entities = getEntities() val entities = getEntities(requestParams.tileId)
Resources.Builder() Resources.Builder()
.setVersion(entities.toString()) .setVersion(entities.toString())
@ -143,18 +147,45 @@ class ShortcutsTile : TileService() {
.build() .build()
} }
override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent) {
serviceScope.launch {
/**
* When the app is updated from an older version (which only supported a single Shortcut Tile),
* and the user is adding a new Shortcuts Tile, we can't tell for sure if it's the 1st or 2nd Tile.
* Even though we may have the shortcut list stored in the prefs, it doesn't guarantee that
* the tile was actually added to the Tiles carousel.
* The [WearPrefsRepositoryImpl::getTileShortcutsAndSaveTileId] method will handle both of the following cases:
* 1. There was no Tile added, but there were shortcuts stored in the prefs.
* In this case, the stored shortcuts will be associated to the new tileId.
* 2. There was a single Tile added, and there were shortcuts stored in the prefs.
* If there was a Tile update since updating the app, the tileId will be already
* associated to the shortcuts, because it also calls [getTileShortcutsAndSaveTileId].
* If there was no Tile update yet, the new Tile will "steal" the shortcuts from the existing Tile,
* and the old Tile will behave as it is the new Tile. This is needed because
* we don't know if it's the 1st or 2nd Tile.
*/
wearPrefsRepository.getTileShortcutsAndSaveTileId(requestParams.tileId)
}
}
override fun onTileRemoveEvent(requestParams: EventBuilders.TileRemoveEvent) {
serviceScope.launch {
wearPrefsRepository.removeTileShortcuts(requestParams.tileId)
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// Cleans up the coroutine // Cleans up the coroutine
serviceJob.cancel() serviceJob.cancel()
} }
private suspend fun getEntities(): List<SimplifiedEntity> { private suspend fun getEntities(tileId: Int): List<SimplifiedEntity> {
return wearPrefsRepository.getTileShortcuts().map { SimplifiedEntity(it) } return wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId).map { SimplifiedEntity(it) }
} }
private suspend fun timeline(): Timeline { private suspend fun timeline(tileId: Int): Timeline {
val entities = getEntities() val entities = getEntities(tileId)
val showLabels = wearPrefsRepository.getShowShortcutText() val showLabels = wearPrefsRepository.getShowShortcutText()
return Timeline.fromLayoutElement(layout(entities, showLabels)) return Timeline.fromLayoutElement(layout(entities, showLabels))
@ -253,4 +284,10 @@ class ShortcutsTile : TileService() {
} }
} }
.build() .build()
companion object {
fun requestUpdate(context: Context) {
getUpdater(context).requestUpdate(ShortcutsTile::class.java)
}
}
} }