Wear OS shortcut Tile (#1842)

* 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 <bassett.justint@gmail.com>
This commit is contained in:
leroyboerefijn 2021-11-11 22:09:32 +01:00 committed by GitHub
parent 55a9c51250
commit 3d909c621d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 673 additions and 11 deletions

View file

@ -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)

View file

@ -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
@ -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"
@ -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)
}

View file

@ -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")
@ -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")
}

View file

@ -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>

View file

@ -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"
}

View file

@ -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 {
@ -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)

View file

@ -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
@ -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()
}

View file

@ -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() {
@ -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)
@ -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())
@ -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)

View file

@ -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()
)
}
}
}

View file

@ -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
@ -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
@ -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()) {
@ -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) },
@ -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()
}
)
}
}
}
}

View file

@ -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
)
}
}

View file

@ -0,0 +1,103 @@
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.Button
import androidx.wear.compose.material.ButtonDefaults
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.data.SimplifiedEntity
import io.homeassistant.companion.android.util.RotaryEventState
import io.homeassistant.companion.android.util.getIcon
@Composable
fun SetTileShortcutsView(
shortcutEntities: MutableList<SimplifiedEntity>,
onShortcutEntitySelectionChange: (Int) -> 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)
}
items(shortcutEntities.size) { index ->
val iconBitmap = getIcon(
shortcutEntities[index].icon,
shortcutEntities[index].entityId.split(".")[0],
LocalContext.current
)
Chip(
modifier = Modifier
.fillMaxWidth(),
icon = {
Image(
iconBitmap ?: CommunityMaterial.Icon.cmd_cellphone,
colorFilter = ColorFilter.tint(Color.White)
)
},
label = {
Text(
text = stringResource(R.string.shortcut_n, index + 1)
)
},
secondaryLabel = {
Text(
text = shortcutEntities[index].friendlyName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = { onShortcutEntitySelectionChange(index) },
colors = ChipDefaults.secondaryChipColors()
)
}
if (shortcutEntities.size < 7) {
item {
Button(
modifier = Modifier.padding(top = 16.dp),
onClick = { onShortcutEntitySelectionChange(shortcutEntities.size) },
colors = ButtonDefaults.primaryButtonColors()
) {
Image(
CommunityMaterial.Icon3.cmd_plus_thick
)
}
}
}
}
}

View file

@ -1,5 +1,6 @@
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
@ -35,6 +36,7 @@ fun SettingsView(
favorites: List<String>,
onClickSetFavorites: () -> Unit,
onClearFavorites: () -> Unit,
onClickSetShortcuts: () -> Unit,
isHapticEnabled: Boolean,
isToastEnabled: Boolean,
onHapticEnabled: (Boolean) -> Unit,
@ -42,27 +44,27 @@ fun SettingsView(
) {
val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState()
RotaryEventState(scrollState = scalingLazyListState)
ScalingLazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = 10.dp,
start = 5.dp,
end = 5.dp,
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.settings)
}
item {
Chip(
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
.fillMaxWidth(),
icon = {
Image(asset = CommunityMaterial.Icon3.cmd_star)
},
@ -77,12 +79,10 @@ fun SettingsView(
)
)
}
item {
Chip(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
.fillMaxWidth(),
icon = {
Image(asset = CommunityMaterial.Icon.cmd_delete)
},
@ -168,6 +168,32 @@ fun SettingsView(
)
)
}
item {
ListHeader(
id = R.string.tile_settings,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Chip(
modifier = Modifier
.fillMaxWidth(),
icon = {
Image(asset = CommunityMaterial.Icon3.cmd_star_circle_outline)
},
label = {
Text(
text = stringResource(id = R.string.shortcuts)
)
},
onClick = onClickSetShortcuts,
colors = ChipDefaults.primaryChipColors(
contentColor = Color.Black
)
)
}
}
}
@ -183,6 +209,7 @@ private fun PreviewSettingsView() {
favorites = previewFavoritesList,
onClickSetFavorites = { /*TODO*/ },
onClearFavorites = {},
onClickSetShortcuts = {},
isHapticEnabled = true,
isToastEnabled = false,
{},

View file

@ -0,0 +1,214 @@
package io.homeassistant.companion.android.tiles
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.wear.tiles.ActionBuilders
import androidx.wear.tiles.ColorBuilders.argb
import androidx.wear.tiles.DimensionBuilders.dp
import androidx.wear.tiles.LayoutElementBuilders
import androidx.wear.tiles.LayoutElementBuilders.Box
import androidx.wear.tiles.LayoutElementBuilders.Column
import androidx.wear.tiles.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER
import androidx.wear.tiles.LayoutElementBuilders.Layout
import androidx.wear.tiles.LayoutElementBuilders.LayoutElement
import androidx.wear.tiles.LayoutElementBuilders.Row
import androidx.wear.tiles.LayoutElementBuilders.Spacer
import androidx.wear.tiles.ModifiersBuilders
import androidx.wear.tiles.RequestBuilders.ResourcesRequest
import androidx.wear.tiles.RequestBuilders.TileRequest
import androidx.wear.tiles.ResourceBuilders
import androidx.wear.tiles.ResourceBuilders.Resources
import androidx.wear.tiles.TileBuilders.Tile
import androidx.wear.tiles.TileService
import androidx.wear.tiles.TimelineBuilders.Timeline
import androidx.wear.tiles.TimelineBuilders.TimelineEntry
import com.google.common.util.concurrent.ListenableFuture
import com.mikepenz.iconics.IconicsColor
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.backgroundColor
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
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
import kotlinx.coroutines.guava.future
import java.nio.ByteBuffer
import javax.inject.Inject
import kotlin.math.min
import kotlin.math.roundToInt
private const val RESOURCES_VERSION = "1"
// Dimensions (dp)
private const val CIRCLE_SIZE = 56f
private const val ICON_SIZE = 48f * 0.7071f // square that fits in 48dp circle
private const val SPACING = 8f
class ShortcutsTile : TileService() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@Inject
lateinit var integrationUseCase: IntegrationRepository
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> =
serviceScope.future {
val entities = getEntities()
Tile.Builder()
.setResourcesVersion(entities.toString())
.setTimeline(
Timeline.Builder().addTimelineEntry(
TimelineEntry.Builder().setLayout(
Layout.Builder().setRoot(
layout(entities)
).build()
).build()
).build()
).build()
}
override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture<Resources> =
serviceScope.future {
val density = requestParams.deviceParameters!!.screenDensity
val iconSizePx = (ICON_SIZE * density).roundToInt()
val entities = getEntities()
Resources.Builder()
.setVersion(entities.toString())
.apply {
entities.map { entity ->
// Find icon name
val iconName: String = if (entity.icon.startsWith("mdi")) {
entity.icon.split(":")[1]
} else {
"palette" // Default scene icon
}
// Create Bitmap from icon name
val iconBitmap = IconicsDrawable(this@ShortcutsTile, "cmd-$iconName").apply {
colorInt = Color.WHITE
sizeDp = ICON_SIZE.roundToInt()
backgroundColor = IconicsColor.colorRes(R.color.colorOverlay)
}.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565)
// Make array of bitmap
val bitmapData = ByteBuffer.allocate(iconBitmap.byteCount).apply {
iconBitmap.copyPixelsToBuffer(this)
}.array()
// link the entity id to the bitmap data array
entity.entityId to ResourceBuilders.ImageResource.Builder()
.setInlineResource(
ResourceBuilders.InlineImageResource.Builder()
.setData(bitmapData)
.setWidthPx(iconSizePx)
.setHeightPx(iconSizePx)
.setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565)
.build()
)
.build()
}.forEach { (id, imageResource) ->
addIdToImageMapping(id, imageResource)
}
}
.build()
}
override fun onDestroy() {
super.onDestroy()
// Cleans up the coroutine
serviceJob.cancel()
}
private suspend fun getEntities(): List<SimplifiedEntity> {
DaggerTilesComponent.builder()
.appComponent((applicationContext as GraphComponentAccessor).appComponent)
.build()
.inject(this@ShortcutsTile)
return integrationUseCase.getTileShortcuts().map { SimplifiedEntity(it) }
}
fun layout(entities: List<SimplifiedEntity>): LayoutElement = Column.Builder().apply {
if (entities.isEmpty()) {
addContent(
LayoutElementBuilders.Text.Builder()
.setText(getString(R.string.shortcuts_tile_empty))
.build()
)
} else {
addContent(rowLayout(entities.subList(0, min(2, entities.size))))
if (entities.size > 2) {
addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
addContent(rowLayout(entities.subList(2, min(5, entities.size))))
}
if (entities.size > 5) {
addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
addContent(rowLayout(entities.subList(5, min(7, entities.size))))
}
}
}
.build()
private fun rowLayout(entities: List<SimplifiedEntity>): LayoutElement = Row.Builder().apply {
addContent(iconLayout(entities[0]))
entities.drop(1).forEach { entity ->
addContent(Spacer.Builder().setWidth(dp(SPACING)).build())
addContent(iconLayout(entity))
}
}
.build()
private fun iconLayout(entity: SimplifiedEntity): LayoutElement = Box.Builder().apply {
setWidth(dp(CIRCLE_SIZE))
setHeight(dp(CIRCLE_SIZE))
setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
setModifiers(
ModifiersBuilders.Modifiers.Builder()
// Set circular background
.setBackground(
ModifiersBuilders.Background.Builder()
.setColor(argb(ContextCompat.getColor(baseContext, R.color.colorOverlay)))
.setCorner(
ModifiersBuilders.Corner.Builder()
.setRadius(dp(CIRCLE_SIZE / 2))
.build()
)
.build()
)
// Make clickable and call activity
.setClickable(
ModifiersBuilders.Clickable.Builder()
.setOnClick(
ActionBuilders.LaunchAction.Builder()
.setAndroidActivity(
ActionBuilders.AndroidActivity.Builder()
.setClassName(TileActionActivity::class.java.name)
.setPackageName(this@ShortcutsTile.packageName)
.addKeyToExtraMapping("entity_id", ActionBuilders.stringExtra(entity.entityId))
.build()
)
.build()
)
.build()
)
.build()
)
addContent(
// Add icon
LayoutElementBuilders.Image.Builder()
.setResourceId(entity.entityId)
.setWidth(dp(ICON_SIZE))
.setHeight(dp(ICON_SIZE))
.build()
)
}
.build()
}

View file

@ -0,0 +1,68 @@
package io.homeassistant.companion.android.tiles
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.home.HomePresenterImpl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
class TileActionActivity : Activity() {
@Inject
lateinit var integrationUseCase: IntegrationRepository
companion object {
private const val TAG = "TileActionActivity"
fun newInstance(context: Context): Intent {
return Intent(context, TileActionActivity::class.java)
}
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DaggerTilesComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.build()
.inject(this)
val entityId: String? = intent.getStringExtra("entity_id")
if (entityId != null) {
mainScope.launch {
if (entityId.split(".")[0] in HomePresenterImpl.toggleDomains) {
integrationUseCase.callService(
entityId.split(".")[0],
"toggle",
hashMapOf("entity_id" to entityId)
)
} else {
integrationUseCase.callService(
entityId.split(".")[0],
"turn_on",
hashMapOf("entity_id" to entityId)
)
}
finish()
}
}
}
override fun onDestroy() {
super.onDestroy()
// Cleans up the coroutine
mainScope.cancel()
}
}

View file

@ -0,0 +1,12 @@
package io.homeassistant.companion.android.tiles
import dagger.Component
import io.homeassistant.companion.android.common.dagger.AppComponent
@Component(dependencies = [AppComponent::class])
interface TilesComponent {
fun inject(shortcutsTile: ShortcutsTile)
fun inject(tileActionActivity: TileActionActivity)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -22,12 +22,18 @@
<string name="login">Login</string>
<string name="logout">Logout</string>
<string name="manual_setup">Manual setup</string>
<string name="none">None</string>
<string name="other">Other</string>
<string name="password">Password</string>
<string name="registerDevice">Register watch</string>
<string name="scene">Scene</string>
<string name="scenes">Scenes</string>
<string name="scripts">Scripts</string>
<string name="shortcuts">Shortcuts</string>
<string name="shortcut_n">Shortcut %d</string>
<string name="shortcuts_tile_description">Select up to 7 entities</string>
<string name="shortcuts_tile_empty">Choose entities in settings</string>
<string name="tile_settings">Tile settings</string>
<string name="username">Username</string>
<string name="version">Version: %s</string>
<string name="lights">Lights</string>