Skip to content

Commit

Permalink
Wear OS shortcut Tile (#1842)
Browse files Browse the repository at this point in the history
* Add non-functional example of favorites tile

* Load real scene entities into the Tile

* Make the tile buttons actionable

* Add icons of the entities

* Add tile preview image

* Also support fewer than 7 entities

* Cleanup and pass ktlint formatting

* Add settings page for tile shortcuts

* Use new settings in Tile

* Make the tile update when the settings are changed

* Support all types of entities in TileActionActivity

* Rename tile and process comments

* ktlint

* Update layout of settings a bit

* Use a string resource like a normal person

* Remove remaining SetTitle instances

* Process review comments and add data class to store entity strings

* Process review comments

* tiny ktlint fix

* Fix broken previews

* Fix white lines after merge

* Move tile refresh to compose function.

* Fix crash when missing friendly name or icon.

* ktlint...

Co-authored-by: Justin Bassett <[email protected]>
  • Loading branch information
leroyboerefijn and JBassett authored Nov 11, 2021
1 parent 55a9c51 commit 3d909c6
Show file tree
Hide file tree
Showing 18 changed files with 673 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface IntegrationRepository {

suspend fun setWearHomeFavorites(favorites: Set<String>)
suspend fun getWearHomeFavorites(): Set<String>
suspend fun getTileShortcuts(): List<String>
suspend fun setTileShortcuts(entities: List<String>)
suspend fun setWearHapticFeedback(enabled: Boolean)
suspend fun getWearHapticFeedback(): Boolean
suspend fun setWearToastConfirmation(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.json.JSONArray
import java.util.regex.Pattern
import javax.inject.Inject
import javax.inject.Named
Expand Down Expand Up @@ -57,6 +58,7 @@ class IntegrationRepositoryImpl @Inject constructor(

private const val PREF_CHECK_SENSOR_REGISTRATION_NEXT = "sensor_reg_last"
private const val PREF_WEAR_HOME_FAVORITES = "wear_home_favorites"
private const val PREF_TILE_SHORTCUTS = "tile_shortcuts_list"
private const val PREF_WEAR_HAPTIC_FEEDBACK = "wear_haptic_feedback"
private const val PREF_WEAR_TOAST_CONFIRMATION = "wear_toast_confirmation"
private const val PREF_HA_VERSION = "ha_version"
Expand Down Expand Up @@ -354,6 +356,17 @@ class IntegrationRepositoryImpl @Inject constructor(
return localStorage.getStringSet(PREF_WEAR_HOME_FAVORITES) ?: setOf()
}

override suspend fun getTileShortcuts(): List<String> {
val jsonArray = JSONArray(localStorage.getString(PREF_TILE_SHORTCUTS) ?: "[]")
return List(jsonArray.length()) {
jsonArray.getString(it)
}
}

override suspend fun setTileShortcuts(entities: List<String>) {
localStorage.putString(PREF_TILE_SHORTCUTS, JSONArray(entities).toString())
}

override suspend fun setWearHapticFeedback(enabled: Boolean) {
localStorage.putBoolean(PREF_WEAR_HAPTIC_FEEDBACK, enabled)
}
Expand Down
4 changes: 4 additions & 0 deletions wear/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {
implementation(project(":common"))

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.5.1")

implementation("com.google.android.material:material:1.4.0")

Expand All @@ -103,4 +104,7 @@ dependencies {
implementation("androidx.wear.compose:compose-foundation:1.0.0-alpha10")
implementation("androidx.wear.compose:compose-material:1.0.0-alpha10")
implementation("androidx.wear.compose:compose-navigation:1.0.0-alpha10")

implementation("com.google.guava:guava:31.0.1-android")
implementation("androidx.wear.tiles:tiles:1.0.0")
}
18 changes: 18 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@

<!-- To show confirmations and failures -->
<activity android:name="androidx.wear.activity.ConfirmationActivity" />

<!-- Tiles -->
<service
android:name=".tiles.ShortcutsTile"
android:label="@string/shortcuts"
android:description="@string/shortcuts_tile_description"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER"
android:exported="true">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>

<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/favorite_entities_tile_example" />
</service>
<activity android:name=".tiles.TileActionActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.homeassistant.companion.android.data

data class SimplifiedEntity(
var entityId: String,
var friendlyName: String = entityId,
var icon: String = ""
) {
constructor(entityString: String) : this(
entityString.split(",")[0],
entityString.split(",")[1],
entityString.split(",")[2]
)

val entityString: String
get() = "$entityId,$friendlyName,$icon"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.home

import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.data.SimplifiedEntity

interface HomePresenter {

Expand All @@ -11,6 +12,8 @@ interface HomePresenter {
suspend fun getEntities(): List<Entity<*>>
suspend fun getWearHomeFavorites(): List<String>
suspend fun setWearHomeFavorites(favorites: List<String>)
suspend fun getTileShortcuts(): List<SimplifiedEntity>
suspend fun setTileShortcuts(entities: List<SimplifiedEntity>)

suspend fun getWearHapticFeedback(): Boolean
suspend fun setWearHapticFeedback(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.homeassistant.companion.android.common.data.authentication.SessionStat
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.data.SimplifiedEntity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -107,6 +108,14 @@ class HomePresenterImpl @Inject constructor(
integrationUseCase.setWearHomeFavorites(favorites.toSet())
}

override suspend fun getTileShortcuts(): List<SimplifiedEntity> {
return integrationUseCase.getTileShortcuts().map { SimplifiedEntity(it) }
}

override suspend fun setTileShortcuts(entities: List<SimplifiedEntity>) {
integrationUseCase.setTileShortcuts(entities.map { it.entityString })
}

override suspend fun getWearHapticFeedback(): Boolean {
return integrationUseCase.getWearHapticFeedback()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.data.SimplifiedEntity
import kotlinx.coroutines.launch

class MainViewModel : ViewModel() {
Expand All @@ -21,6 +22,8 @@ class MainViewModel : ViewModel() {
private set
var favoriteEntityIds = mutableStateListOf<String>()
private set
var shortcutEntities = mutableStateListOf<SimplifiedEntity>()
private set
var isHapticEnabled = mutableStateOf(false)
private set
var isToastEnabled = mutableStateOf(false)
Expand All @@ -29,6 +32,7 @@ class MainViewModel : ViewModel() {
private fun loadEntities() {
viewModelScope.launch {
favoriteEntityIds.addAll(homePresenter.getWearHomeFavorites())
shortcutEntities.addAll(homePresenter.getTileShortcuts())
isHapticEnabled.value = homePresenter.getWearHapticFeedback()
isToastEnabled.value = homePresenter.getWearToastConfirmation()
entities.addAll(homePresenter.getEntities())
Expand Down Expand Up @@ -69,6 +73,26 @@ class MainViewModel : ViewModel() {
}
}

fun setTileShortcut(index: Int, entity: SimplifiedEntity) {
viewModelScope.launch {
if (index < shortcutEntities.size) {
shortcutEntities[index] = entity
} else {
shortcutEntities.add(entity)
}
homePresenter.setTileShortcuts(shortcutEntities)
}
}

fun clearTileShortcut(index: Int) {
viewModelScope.launch {
if (index < shortcutEntities.size) {
shortcutEntities.removeAt(index)
homePresenter.setTileShortcuts(shortcutEntities)
}
}
}

fun setHapticEnabled(enabled: Boolean) {
viewModelScope.launch {
homePresenter.setWearHapticFeedback(enabled)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.homeassistant.companion.android.home.views

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.ScalingLazyColumn
import androidx.wear.compose.material.ScalingLazyListState
import androidx.wear.compose.material.Text
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.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.data.SimplifiedEntity
import io.homeassistant.companion.android.util.RotaryEventState
import io.homeassistant.companion.android.util.getIcon

@Composable
fun ChooseEntityView(
validEntities: List<Entity<*>>,
onNoneClicked: () -> Unit,
onEntitySelected: (entity: SimplifiedEntity) -> Unit
) {
val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState()
RotaryEventState(scrollState = scalingLazyListState)
ScalingLazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = 40.dp,
start = 8.dp,
end = 8.dp,
bottom = 40.dp
),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
state = scalingLazyListState
) {
item {
ListHeader(id = R.string.shortcuts)
}
item {
Chip(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
icon = { Image(asset = CommunityMaterial.Icon.cmd_delete) },
label = { Text(stringResource(id = R.string.none)) },
onClick = onNoneClicked,
colors = ChipDefaults.primaryChipColors(
contentColor = Color.Black
)
)
}
items(validEntities.size) { index ->
val attributes = validEntities[index].attributes as Map<*, *>
val iconBitmap = getIcon(
attributes["icon"] as String?,
validEntities[index].entityId.split(".")[0],
LocalContext.current
)
Chip(
modifier = Modifier
.fillMaxWidth(),
icon = {
Image(
asset = iconBitmap ?: CommunityMaterial.Icon.cmd_cellphone,
colorFilter = ColorFilter.tint(Color.White)
)
},
label = {
Text(
text = attributes["friendly_name"].toString(),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
},
enabled = validEntities[index].state != "unavailable",
onClick = {
onEntitySelected(
SimplifiedEntity(
validEntities[index].entityId,
attributes["friendly_name"] as String? ?: validEntities[index].entityId,
attributes["icon"] as String? ?: ""
)
)
},
colors = ChipDefaults.secondaryChipColors()
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
Expand All @@ -16,9 +21,11 @@ import androidx.wear.compose.material.Text
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import androidx.wear.tiles.TileService
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.home.HomePresenterImpl
import io.homeassistant.companion.android.home.MainViewModel
import io.homeassistant.companion.android.tiles.ShortcutsTile
import io.homeassistant.companion.android.util.LocalRotaryEventDispatcher
import io.homeassistant.companion.android.util.RotaryEventDispatcher
import io.homeassistant.companion.android.util.RotaryEventHandlerSetup
Expand All @@ -27,12 +34,16 @@ import io.homeassistant.companion.android.util.setChipDefaults
private const val SCREEN_LANDING = "landing"
private const val SCREEN_SETTINGS = "settings"
private const val SCREEN_SET_FAVORITES = "set_favorites"
private const val SCREEN_SET_TILE_SHORTCUTS = "set_tile_shortcuts"
private const val SCREEN_SELECT_TILE_SHORTCUT = "select_tile_shortcut"

@ExperimentalWearMaterialApi
@Composable
fun LoadHomePage(
mainViewModel: MainViewModel
) {
var shortcutEntitySelectionIndex: Int by remember { mutableStateOf(0) }
val context = LocalContext.current

val rotaryEventDispatcher = RotaryEventDispatcher()
if (mainViewModel.entities.isNullOrEmpty() && mainViewModel.favoriteEntityIds.isNullOrEmpty()) {
Expand Down Expand Up @@ -78,6 +89,7 @@ fun LoadHomePage(
mainViewModel.favoriteEntityIds,
{ swipeDismissableNavController.navigate(SCREEN_SET_FAVORITES) },
{ mainViewModel.clearFavorites() },
{ swipeDismissableNavController.navigate(SCREEN_SET_TILE_SHORTCUTS) },
mainViewModel.isHapticEnabled.value,
mainViewModel.isToastEnabled.value,
{ mainViewModel.setHapticEnabled(it) },
Expand All @@ -98,6 +110,31 @@ fun LoadHomePage(
}
}
}
composable(SCREEN_SET_TILE_SHORTCUTS) {
SetTileShortcutsView(
mainViewModel.shortcutEntities
) {
shortcutEntitySelectionIndex = it
swipeDismissableNavController.navigate(SCREEN_SELECT_TILE_SHORTCUT)
}
}
composable(SCREEN_SELECT_TILE_SHORTCUT) {
val validEntities = mainViewModel.entities
.filter { it.entityId.split(".")[0] in HomePresenterImpl.supportedDomains }
ChooseEntityView(
validEntities,
{
mainViewModel.clearTileShortcut(shortcutEntitySelectionIndex)
TileService.getUpdater(context).requestUpdate(ShortcutsTile::class.java)
swipeDismissableNavController.navigateUp()
},
{ entity ->
mainViewModel.setTileShortcut(shortcutEntitySelectionIndex, entity)
TileService.getUpdater(context).requestUpdate(ShortcutsTile::class.java)
swipeDismissableNavController.navigateUp()
}
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ fun ListHeader(
}

@Composable
fun ListHeader(id: Int) {
fun ListHeader(id: Int, modifier: Modifier = Modifier) {
ListHeader {
Row {
Text(
text = stringResource(id = id),
color = Color.White
color = Color.White,
modifier = modifier
)
}
}
Expand Down
Loading

0 comments on commit 3d909c6

Please sign in to comment.