Skip to content
Draft
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
79d2f47
Add Compose UI Elements for Widget Button Configure Widget
BFadairo Mar 3, 2026
d4ca03a
Create ViewModel For ButtonWidgetConfigureActivity
BFadairo Mar 5, 2026
b7ae653
Create AlertDialog for Adding Fields
BFadairo Mar 12, 2026
1f6d05d
Refactor ViewModel & Composables to use ButtonWidgetUiState
BFadairo Mar 13, 2026
b6eca38
Add Filtering to Action Text Input Field
BFadairo Apr 27, 2026
53e2edc
Modify Action Text Field to use TextFieldState
BFadairo Apr 27, 2026
0a9f1e7
Update Dynamic Fields Text Input to track state
BFadairo Apr 27, 2026
90a69be
Add Widget Creation
BFadairo Apr 28, 2026
6cd5bee
Move Action Input Field to it's own Composable
BFadairo May 19, 2026
fb7af53
Fix Action Text not being restored when updating widget
BFadairo May 22, 2026
8dbd025
Add Entity Selection to Entity id dynamic field also update Add Butto…
BFadairo May 22, 2026
97b104c
Update UI and add Tap action to Button Widget
BFadairo May 22, 2026
93fb624
Merge branch 'main' into rework/button_config_activity_compose
BFadairo Jun 3, 2026
3802f24
Update App Widget State on tap
BFadairo Jun 12, 2026
a2bceaf
Add States for Error & Success
BFadairo Jun 12, 2026
c63314f
Merge branch 'main' into rework/button_config_activity_compose
BFadairo Jun 12, 2026
874f9cf
Modify Image to Properly adjust when expanding Widget
BFadairo Jul 23, 2026
f7cb4f6
Merge branch 'main' into rework/button_config_activity_compose
BFadairo Jul 23, 2026
f978b80
Ran ktlint
BFadairo Jul 23, 2026
b8aa8c3
Fix Build errors
BFadairo Jul 23, 2026
c9ce7db
lint
BFadairo Jul 23, 2026
4745a51
Merge branch 'main' into rework/button_config_activity_compose
BFadairo Jul 25, 2026
c3db459
Remove unused resources
BFadairo Jul 25, 2026
9ffcc99
Merge branch 'main' into rework/button_config_activity_compose
BFadairo Jul 27, 2026
7c3df0e
Fix Actions not running properly
BFadairo Jul 28, 2026
f6a3004
Update Icon Color
BFadairo Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ open class HomeAssistantApplication : Application() {

if (!isAutomotive()) {
// Update widgets when the screen turns on, updates are skipped if widgets were not added
val buttonWidget = ButtonWidget()
ButtonWidget().registerReceiver(this)
val entityWidget = EntityWidget()
val mediaPlayerWidget = MediaPlayerControlsWidget()
val templateWidget = TemplateWidget()
Expand All @@ -369,7 +369,6 @@ open class HomeAssistantApplication : Application() {
screenIntentFilter.addAction(Intent.ACTION_SCREEN_ON)
screenIntentFilter.addAction(Intent.ACTION_SCREEN_OFF)

ContextCompat.registerReceiver(this, buttonWidget, screenIntentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
ContextCompat.registerReceiver(this, entityWidget, screenIntentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
ContextCompat.registerReceiver(
this,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
package io.homeassistant.companion.android.widgets.button

import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.toColorInt
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalGlanceId
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.CircularProgressIndicator
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.color.ColorProviders
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.material.ColorProviders
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import androidx.glance.semantics.semantics
import androidx.glance.semantics.testTag
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.IconicsSize
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.padding
import com.mikepenz.iconics.utils.size
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.util.getIconByMdiName
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme
import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTypography
import io.homeassistant.companion.android.util.compose.glanceHaLightColors
import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.CALL_SERVICE
import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.CALL_SERVICE_AUTH
import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.DEFAULT_MAX_ICON_SIZE
import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.IS_LOADING_KEY
import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.SENT_SUCCESSFUL_KEY
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay
import timber.log.Timber

private val authKey = ActionParameters.Key<Boolean>("auth")
private val widgetIdKey = ActionParameters.Key<Int>("widgetId")

class ButtonGlanceAppWidget : GlanceAppWidget() {
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface ButtonGlanceWidgetEntryPoint {
fun buttonStateUpdater(): ButtonWidgetStateUpdater
}

override val sizeMode: SizeMode = SizeMode.Exact
internal val isActionRunningKey = booleanPreferencesKey(IS_LOADING_KEY)
internal val isActionSuccessKey = booleanPreferencesKey(ButtonWidget.IS_SUCCESS_KEY)
internal val wasActionSentSuccessfulKey = booleanPreferencesKey(SENT_SUCCESSFUL_KEY)

override suspend fun provideGlance(context: Context, id: GlanceId) {
val manager = GlanceAppWidgetManager(context)
val widgetId = manager.getAppWidgetId(id)

provideContent {
val isActionRunning = currentState(key = isActionRunningKey) ?: false
val isActionSuccess = currentState(key = isActionSuccessKey) ?: false
val wasActionSentSuccessful = currentState(key = wasActionSentSuccessfulKey) ?: true

val entryPoints = remember { EntryPoints.get(context, ButtonGlanceWidgetEntryPoint::class.java) }
val updater = remember { entryPoints.buttonStateUpdater() }

LaunchedEffect(widgetId, isActionRunning, wasActionSentSuccessful, isActionSuccess) {
updater.updateIsActionRunning(widgetId, isActionRunning)
updater.updateIsActionError(widgetId, !wasActionSentSuccessful)
updater.updateIsActionSuccess(widgetId, isActionSuccess)
}

val flow = remember(widgetId) { updater.getButtonEntityFlow(widgetId) }
val state by flow.collectAsState(Loading)

Timber.i("GlanceAppWidget $isActionRunning")
Timber.i("GlanceAppWidget $state")
HomeAssistantGlanceTheme(colors = getWidgetColors(state?.backgroundType, state?.textColor)) {
ScreenForState(
id = id,
context = context,
state = state,
maxIconSize = DEFAULT_MAX_ICON_SIZE,
)
}
}
}
}

@Composable
fun ScreenForState(
id: GlanceId,
context: Context,
state: ButtonWidgetState?,
maxIconSize: Int,
modifier: Modifier = Modifier,
) {
when (state) {
Loading -> LoadingScreen()
is ButtonStateWithData -> ButtonScreen(
context = context,
state = state,
maxIconSize = maxIconSize,
)

Error -> ErrorScreen(context, id)
Success -> SuccessScreen(context, id)

else -> {}
}
}

@Composable
private fun SuccessScreen(context: Context, widgetId: GlanceId, modifier: Modifier = Modifier) {
LaunchedEffect(Unit) {
delay(1.seconds)
updateAppWidgetState(context, widgetId) {
it[booleanPreferencesKey(ButtonWidget.IS_SUCCESS_KEY)] = false
}
ButtonGlanceAppWidget().update(context, widgetId)
}
Column(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.background(Color.Green).fillMaxSize(),
) {
Image(
provider = ImageProvider(R.drawable.ic_check_black_24dp),
contentDescription = null,
modifier = GlanceModifier.fillMaxSize(),
)
}
}

@Composable
private fun ErrorScreen(context: Context, widgetId: GlanceId, modifier: Modifier = Modifier) {
LaunchedEffect(Unit) {
delay(1.seconds)
updateAppWidgetState(context, widgetId) {
it[booleanPreferencesKey(IS_LOADING_KEY)] = false
}
ButtonGlanceAppWidget().update(context, widgetId)
}
Column(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.background(Color.Red).fillMaxSize(),
) {
Image(
provider = ImageProvider(R.drawable.ic_clear_black),
contentDescription = null,
modifier = GlanceModifier.fillMaxSize(),
)
}
}

@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Column(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.buttonWidgetBackground().semantics { testTag = "LoadingScreen" },
) {
CircularProgressIndicator(
color = GlanceTheme.colors.primary,
modifier = GlanceModifier.size(HomeAssistantGlanceTheme.dimensions.iconSize),
)
}
}

@SuppressLint("ComposeRedundantComposable")
@Composable
private fun GlanceModifier.buttonWidgetBackground(): GlanceModifier {
return this.appWidgetBackground().fillMaxSize().background(
GlanceTheme
.colors.widgetBackground,
)
}

@Composable
private fun ButtonScreen(
context: Context,
state: ButtonStateWithData?,
maxIconSize: Int,
modifier: Modifier = Modifier,
) {
val iconData = state?.icon?.let { CommunityMaterial.getIconByMdiName(it) }
?: CommunityMaterial.Icon2.cmd_flash // Lightning Bolt
val iconDrawable = IconicsDrawable(context, iconData).apply {
padding = IconicsSize.dp(2)
size = IconicsSize.dp(24)
}

val size = LocalSize.current
// Determine reasonable dimensions for drawing vector icon as a bitmap
val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble()
val awo = if (state != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(state.id) else null
val maxWidth = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxIconSize)
?: maxIconSize
).coerceAtLeast(16)
val maxHeight = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, maxIconSize)
?: maxIconSize
).coerceAtLeast(16)
val width: Int
val height: Int
if (maxWidth > maxHeight) {
width = maxWidth
height = (maxWidth * (1 / aspectRatio)).toInt()
} else {
width = (maxHeight * aspectRatio).toInt()
height = maxHeight
}

val icon = DrawableCompat.wrap(iconDrawable).toBitmap(width = width, height = height)

val requiresAuth = state?.requiresAuthentication

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier.fillMaxSize().buttonWidgetBackground()
.clickable(
onClick = actionRunCallback<TapAction>(
actionParametersOf(
authKey to (requiresAuth == true),
widgetIdKey to (state?.id ?: -1),
),
),
)
.padding(8.dp),
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
contentScale = ContentScale.Fit,
)

state?.label?.let {
Text(
text = it,
style = HomeAssistantGlanceTypography.bodySmall.copy(textAlign = TextAlign.Center),
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp),
)
}
}
}

@Composable
fun getWidgetColors(backgroundType: WidgetBackgroundType?, textColor: String?): ColorProviders {
return when (backgroundType) {
WidgetBackgroundType.DYNAMICCOLOR -> GlanceTheme.colors
WidgetBackgroundType.DAYNIGHT -> HomeAssistantGlanceTheme.colors
WidgetBackgroundType.TRANSPARENT -> ColorProviders(
glanceHaLightColors
.copy(
background = Color.Transparent,
onSurface = Color(
textColor?.toColorInt() ?: glanceHaLightColors.onSurface.toArgb(),
),
),
)

else -> HomeAssistantGlanceTheme.colors
}
}

class TapAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) {
it[booleanPreferencesKey(IS_LOADING_KEY)] = true
}
ButtonGlanceAppWidget().update(context, glanceId)
val auth = parameters[authKey] ?: false
val widgetId = parameters[widgetIdKey]
val intent = Intent(context, ButtonWidget::class.java).apply {
action = if (auth) CALL_SERVICE_AUTH else CALL_SERVICE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
}
context.sendBroadcast(intent)
}
}

@OptIn(ExperimentalGlancePreviewApi::class)
@Preview
@Composable
private fun ErrorScreenPreview() {
HomeAssistantGlanceTheme {
ErrorScreen(LocalContext.current, LocalGlanceId.current)
}
}
Loading
Loading