1+ @file:OptIn(ExperimentalTime ::class )
2+
13package io.homeassistant.companion.android.tiles
24
35import android.content.Context
@@ -29,6 +31,7 @@ import androidx.wear.tiles.TileService
2931import com.google.common.util.concurrent.ListenableFuture
3032import com.mikepenz.iconics.IconicsColor
3133import com.mikepenz.iconics.IconicsDrawable
34+ import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial.Icon3
3235import com.mikepenz.iconics.utils.backgroundColor
3336import com.mikepenz.iconics.utils.colorInt
3437import com.mikepenz.iconics.utils.sizeDp
@@ -45,11 +48,17 @@ import javax.inject.Inject
4548import kotlin.coroutines.cancellation.CancellationException
4649import kotlin.math.min
4750import kotlin.math.roundToInt
51+ import kotlin.time.Clock
52+ import kotlin.time.Duration.Companion.seconds
53+ import kotlin.time.ExperimentalTime
54+ import kotlin.time.Instant
4855import kotlinx.coroutines.CoroutineScope
4956import kotlinx.coroutines.Dispatchers
5057import kotlinx.coroutines.Job
5158import kotlinx.coroutines.async
59+ import kotlinx.coroutines.delay
5260import kotlinx.coroutines.guava.future
61+ import kotlinx.coroutines.launch
5362import kotlinx.coroutines.runBlocking
5463import kotlinx.coroutines.withContext
5564import timber.log.Timber
@@ -61,6 +70,8 @@ private const val ICON_SIZE_SMALL = 40f * 0.7071f // square that fits in 48dp ci
6170private const val SPACING = 8f
6271private const val TEXT_SIZE = 8f
6372private const val TEXT_PADDING = 2f
73+ private val LOADING_TIMEOUT = 5 .seconds
74+ private const val LOADING_RESOURCE_SUFFIX = " _loading"
6475
6576@AndroidEntryPoint
6677class ShortcutsTile : TileService () {
@@ -73,6 +84,13 @@ class ShortcutsTile : TileService() {
7384 @Inject
7485 lateinit var wearPrefsRepository: WearPrefsRepository
7586
87+ @Inject
88+ lateinit var clock: Clock
89+
90+ private var pendingEntityId: String? = null
91+ private var pendingOriginalState: String? = null
92+ private var pendingTimestamp: Instant = Instant .DISTANT_PAST
93+
7694 override fun onTileRequest (requestParams : TileRequest ): ListenableFuture <Tile > = serviceScope.future {
7795 val state = requestParams.currentState
7896 if (state.lastClickableId.isNotEmpty()) {
@@ -82,13 +100,74 @@ class ShortcutsTile : TileService() {
82100 intent.setPackage(packageName)
83101 sendBroadcast(intent)
84102 }
103+
104+ // Enter loading state: store original state so we detect when it changes
105+ pendingEntityId = state.lastClickableId
106+ pendingTimestamp = clock.now()
107+ pendingOriginalState = if (serverManager.isRegistered()) {
108+ try {
109+ serverManager.integrationRepository().getEntity(state.lastClickableId)?.state
110+ } catch (e: CancellationException ) {
111+ throw e
112+ } catch (e: Exception ) {
113+ Timber .w(e, " Failed to fetch original state for ${state.lastClickableId} " )
114+ null
115+ }
116+ } else {
117+ null
118+ }
119+
120+ // Schedule polling re-renders to pick up state change
121+ serviceScope.launch {
122+ delay(1 .seconds)
123+ requestUpdate(this @ShortcutsTile)
124+ delay(3 .seconds)
125+ requestUpdate(this @ShortcutsTile)
126+ }
85127 }
86128
87129 val tileId = requestParams.tileId
88130 val entities = getEntities(tileId)
89131
132+ // Fetch entity states for resource version — ensures cache invalidation on state change
133+ val entityStatesMap = if (serverManager.isRegistered()) {
134+ entities.map { entity ->
135+ async {
136+ try {
137+ val e = serverManager.integrationRepository().getEntity(entity.entityId)
138+ entity.entityId to (e?.state ? : " unknown" )
139+ } catch (e: CancellationException ) {
140+ throw e
141+ } catch (e: Exception ) {
142+ Timber .w(e, " Failed to fetch entity ${entity.entityId} state for tile version" )
143+ entity.entityId to " unknown"
144+ }
145+ }
146+ }.map { it.await() }.toMap()
147+ } else {
148+ emptyMap()
149+ }
150+
151+ // Clear loading state if entity state has changed or timeout exceeded
152+ if (pendingEntityId != null ) {
153+ val elapsed = clock.now() - pendingTimestamp
154+ val currentState = entityStatesMap[pendingEntityId]
155+ if (elapsed > LOADING_TIMEOUT ||
156+ (pendingOriginalState != null && currentState != null && currentState != pendingOriginalState)
157+ ) {
158+ pendingEntityId = null
159+ pendingOriginalState = null
160+ }
161+ }
162+
163+ val entityStatesVersion = entityStatesMap.entries
164+ .sortedBy { it.key }
165+ .joinToString(" ," ) { " ${it.key} =${it.value} " }
166+ val loadingSuffix = pendingEntityId?.let { " |loading:$it " }.orEmpty()
167+ val resourcesVersion = " $entities |$entityStatesVersion$loadingSuffix "
168+
90169 Tile .Builder ()
91- .setResourcesVersion(entities.toString() )
170+ .setResourcesVersion(resourcesVersion )
92171 .setTileTimeline(
93172 if (serverManager.isRegistered()) {
94173 timeline(tileId)
@@ -130,7 +209,7 @@ class ShortcutsTile : TileService() {
130209 }
131210
132211 Resources .Builder ()
133- .setVersion(entities.toString() )
212+ .setVersion(requestParams.version )
134213 .apply {
135214 entities.map { entity ->
136215 // Find icon: try state-aware icon from full entity, fall back to domain icon
@@ -169,6 +248,30 @@ class ShortcutsTile : TileService() {
169248 }.forEach { (id, imageResource) ->
170249 addIdToImageMapping(id, imageResource)
171250 }
251+
252+ // Generate loading icon for the pending entity
253+ pendingEntityId?.let { loadingId ->
254+ val loadingBitmap = IconicsDrawable (this @ShortcutsTile, Icon3 .cmd_progress_clock).apply {
255+ colorInt = Color .WHITE
256+ sizeDp = iconSize.roundToInt()
257+ backgroundColor = IconicsColor .colorRes(R .color.colorOverlay)
258+ }.toBitmap(iconSizePx, iconSizePx, Bitmap .Config .RGB_565 )
259+ val loadingData = ByteBuffer .allocate(loadingBitmap.byteCount).apply {
260+ loadingBitmap.copyPixelsToBuffer(this )
261+ }.array()
262+ addIdToImageMapping(
263+ loadingId + LOADING_RESOURCE_SUFFIX ,
264+ ResourceBuilders .ImageResource .Builder ()
265+ .setInlineResource(
266+ ResourceBuilders .InlineImageResource .Builder ()
267+ .setData(loadingData)
268+ .setWidthPx(iconSizePx)
269+ .setHeightPx(iconSizePx)
270+ .setFormat(ResourceBuilders .IMAGE_FORMAT_RGB_565 )
271+ .build(),
272+ ).build(),
273+ )
274+ }
172275 }
173276 .build()
174277 }
@@ -249,6 +352,11 @@ class ShortcutsTile : TileService() {
249352
250353 private fun iconLayout (entity : SimplifiedEntity , showLabels : Boolean ): LayoutElement = Box .Builder ().apply {
251354 val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL
355+ val resourceId = if (entity.entityId == pendingEntityId) {
356+ entity.entityId + LOADING_RESOURCE_SUFFIX
357+ } else {
358+ entity.entityId
359+ }
252360 setWidth(dp(CIRCLE_SIZE ))
253361 setHeight(dp(CIRCLE_SIZE ))
254362 setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER )
@@ -279,7 +387,7 @@ class ShortcutsTile : TileService() {
279387 addContent(
280388 // Add icon
281389 LayoutElementBuilders .Image .Builder ()
282- .setResourceId(entity.entityId )
390+ .setResourceId(resourceId )
283391 .setWidth(dp(iconSize))
284392 .setHeight(dp(iconSize))
285393 .build(),
0 commit comments