From 79d2f471e92226975abebe8a49d6407e02d97f0e Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 2 Mar 2026 19:55:22 -0800 Subject: [PATCH 01/14] Add Compose UI Elements for Widget Button Configure Widget --- .../button/ButtonWidgetConfigureActivity.kt | 234 +++++++++++++++++- 1 file changed, 232 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 1341bcac4aa..484e072e097 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.widgets.button import android.annotation.SuppressLint import android.appwidget.AppWidgetManager +import android.content.res.Configuration import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.os.Build @@ -15,7 +16,34 @@ import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.EditText import android.widget.Spinner +import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Checkbox +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.toColorInt import androidx.core.view.isVisible @@ -27,20 +55,33 @@ import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.composable.HAFilledButton +import io.homeassistant.companion.android.common.compose.composable.HAIconButton +import io.homeassistant.companion.android.common.compose.composable.rememberSelectedOption +import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.util.MapAnySerializer import io.homeassistant.companion.android.common.util.kotlinJsonMapper +import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.applySafeDrawingInsets +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu import io.homeassistant.companion.android.util.getHexForColor import io.homeassistant.companion.android.util.icondialog.IconDialogFragment import io.homeassistant.companion.android.util.icondialog.getIconByMdiName import io.homeassistant.companion.android.util.icondialog.mdiName +import io.homeassistant.companion.android.util.previewEntity1 +import io.homeassistant.companion.android.util.previewEntity2 +import io.homeassistant.companion.android.util.previewServer1 +import io.homeassistant.companion.android.util.previewServer2 +import io.homeassistant.companion.android.util.safeBottomWindowInsets +import io.homeassistant.companion.android.util.safeTopWindowInsets import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter @@ -215,8 +256,13 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() + + ButtonWidgetConfigureView( + servers = servers, + selectedServerId = 0, + onServerSelected = {}, + entities = emptyList(), + selectedEntityId = null, + onEntitySelected = {}, + dynamicFields = emptyList(), + onActionTextUpdated = {}, + selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + onBackgroundTypeSelected = {}, + ) +} + +@Composable +private fun ButtonWidgetConfigureView( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + entities: List, + selectedEntityId: String?, + onEntitySelected: (String?) -> Unit, + dynamicFields: List, + onActionTextUpdated: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.configure_action)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { contentPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(contentPadding) + .padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = selectedServerId, + onSelected = { onServerSelected(it) }, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + var actionInputText by remember { mutableStateOf("") } + TextField( + label = { Text(text = stringResource(commonR.string.label_action)) }, + value = actionInputText, + onValueChange = { + actionInputText = it + onActionTextUpdated(it) + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + ) + + if (dynamicFields.isNotEmpty()) { + dynamicFields.forEach { field -> + var fieldInputText by remember { mutableStateOf("") } + TextField( + label = { Text(text = field.field) }, + value = fieldInputText, + onValueChange = { + fieldInputText = it + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + ) + } + } + + HAFilledButton("Add Field", onClick = {}, modifier = Modifier.align(Alignment.End)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(commonR.string.label_icon)) + HAIconButton( + icon = ImageVector.vectorResource(commonR.drawable.ic_nfc), + onClick = { + }, + contentDescription = null, + ) + } + + var labelInputText by remember { mutableStateOf("") } + TextField( + label = { Text(text = stringResource(commonR.string.label)) }, + value = labelInputText, + onValueChange = { + labelInputText = it + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + placeholder = { Text(text = stringResource(commonR.string.widget_text_hint_label)) }, + ) + + var selectedOption by rememberSelectedOption() + + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { + onBackgroundTypeSelected(it) + }, + modifier = Modifier.padding(bottom = 16.dp), + ) + + // Radio Group for Widget text/icon color +// HARadioGroup( +// options = listOf( +// RadioOption( +// "key1", +// "White", +// ), +// RadioOption( +// "key2", +// "Black", +// ), +// ), +// onSelect = { selectedOption = it }, +// selectionKey = selectedOption?.selectionKey, +// ) + + var isRequireAuthenticationChecked by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isRequireAuthenticationChecked, + onCheckedChange = { isRequireAuthenticationChecked = it }, + ) + Text(text = stringResource(commonR.string.widget_checkbox_require_authentication)) + } + HAFilledButton( + text = stringResource(commonR.string.add_widget), + onClick = {}, + modifier = Modifier.align(Alignment.End), + ) + } + } +} + +@Composable +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ButtonWidgetConfigureScreenPreview() { + HATheme { + ButtonWidgetConfigureView( + servers = listOf( + previewServer1, + previewServer2, + ), + selectedServerId = 0, + onServerSelected = {}, + entities = listOf( + previewEntity1, + previewEntity2, + ), + selectedEntityId = previewEntity1.entityId, + onEntitySelected = {}, + dynamicFields = listOf(ActionFieldBinder("Test Action", "Testies", 1)), + onActionTextUpdated = {}, + selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + onBackgroundTypeSelected = {}, + ) + } +} From d4ca03a62143efed2b820042b99b1f588003558e Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Wed, 4 Mar 2026 16:27:24 -0800 Subject: [PATCH 02/14] Create ViewModel For ButtonWidgetConfigureActivity --- .../button/ButtonWidgetConfigureActivity.kt | 39 +++++++++----- .../widgets/button/ButtonWidgetViewModel.kt | 52 +++++++++++++++++++ .../res/layout/widget_button_configure.xml | 8 +-- 3 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 484e072e097..bdb579350aa 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -17,20 +17,24 @@ import android.widget.AutoCompleteTextView import android.widget.EditText import android.widget.Spinner import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Checkbox import androidx.compose.material.Scaffold import androidx.compose.material.Text -import androidx.compose.material.TextField import androidx.compose.material.TopAppBar +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,6 +52,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.toColorInt import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.mikepenz.iconics.IconicsDrawable @@ -69,7 +74,6 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.applySafeDrawingInsets import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu import io.homeassistant.companion.android.util.getHexForColor @@ -101,6 +105,8 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() - +private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { + val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) ButtonWidgetConfigureView( servers = servers, - selectedServerId = 0, - onServerSelected = {}, + selectedServerId = viewModel.selectedServerId, + onServerSelected = viewModel::setServer, entities = emptyList(), selectedEntityId = null, onEntitySelected = {}, - dynamicFields = emptyList(), - onActionTextUpdated = {}, - selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, - onBackgroundTypeSelected = {}, + dynamicFields = viewModel.dynamicFields, + onActionTextUpdated = viewModel::updateActionFields, + selectedBackgroundType = viewModel.selectedBackgroundType, + onBackgroundTypeSelected = { viewModel.selectedBackgroundType = it }, ) } @@ -633,7 +638,15 @@ private fun ButtonWidgetConfigureView( placeholder = { Text(text = stringResource(commonR.string.widget_text_hint_label)) }, ) - var selectedOption by rememberSelectedOption() + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { + onBackgroundTypeSelected(it) + }, + modifier = Modifier.padding(bottom = 16.dp), + ) + +// var selectedOption by rememberSelectedOption() WidgetBackgroundTypeExposedDropdownMenu( current = selectedBackgroundType, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt new file mode 100644 index 00000000000..8b35e214ef8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -0,0 +1,52 @@ +package io.homeassistant.companion.android.widgets.button + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.color.DynamicColors +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.integration.Action +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.widgets.common.ActionFieldBinder +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@HiltViewModel +class ButtonWidgetViewModel @Inject constructor( + val buttonWidgetDao: ButtonWidgetDao, + val serverManager: ServerManager, +) : ViewModel() { + + private var actions = mutableMapOf>() + private var entities = mutableMapOf>() + var dynamicFields = ArrayList() + var selectedBackgroundType by mutableStateOf( + if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + ) + + val servers = serverManager.serversFlow + private val selectedServerMutex = Mutex() + var selectedServerId by mutableIntStateOf(ServerManager.SERVER_ID_ACTIVE) + private set + + fun setServer(serverId: Int) { + if (selectedServerId == serverId) return + selectedServerId = serverId + viewModelScope.launch { selectedServerMutex.withLock { } } + } + + fun updateActionFields(text: String) { + } +} diff --git a/app/src/main/res/layout/widget_button_configure.xml b/app/src/main/res/layout/widget_button_configure.xml index 5b332b8de9c..b8041bfeb3e 100644 --- a/app/src/main/res/layout/widget_button_configure.xml +++ b/app/src/main/res/layout/widget_button_configure.xml @@ -25,7 +25,8 @@ android:layout_height="50dp" android:gravity="center" android:orientation="horizontal" - android:visibility="gone"> + android:visibility="gone" + tools:visibility="visible"> + android:visibility="gone" + tools:visibility="visible"/> - \ No newline at end of file + From b7ae65392c765af421ab6c435ef802afce32a671 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Wed, 11 Mar 2026 20:50:59 -0700 Subject: [PATCH 03/14] Create AlertDialog for Adding Fields --- .../button/ButtonWidgetConfigureActivity.kt | 91 +++++++++++++++---- .../widgets/button/ButtonWidgetViewModel.kt | 24 ++++- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index bdb579350aa..f11d3aef77b 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -20,14 +20,11 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Checkbox @@ -62,7 +59,6 @@ import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.compose.composable.HAFilledButton import io.homeassistant.companion.android.common.compose.composable.HAIconButton -import io.homeassistant.companion.android.common.compose.composable.rememberSelectedOption import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.common.data.integration.Entity @@ -74,6 +70,7 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel +import io.homeassistant.companion.android.util.compose.MdcAlertDialog import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu import io.homeassistant.companion.android.util.getHexForColor @@ -527,6 +524,8 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity Unit, dynamicFields: List, + onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, onActionTextUpdated: (String) -> Unit, selectedBackgroundType: WidgetBackgroundType, onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, ) { + var actionInputText by remember { mutableStateOf("") } + var addFieldDialog by remember { mutableStateOf(false) } + var labelInputText by remember { mutableStateOf("") } + Scaffold( topBar = { TopAppBar( @@ -572,6 +577,28 @@ private fun ButtonWidgetConfigureView( .padding(all = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (addFieldDialog) { + AddFieldDialog( + action = actionInputText, + onCancel = { + addFieldDialog = false + }, + onOk = { actionField -> + if (dynamicFields.any { + it.field == actionInputText + } + ) { + addFieldDialog = false + return@AddFieldDialog + } + + onAddFieldDialogOkClicked(dynamicFields.size, actionField) + addFieldDialog = false + }, + modifier = Modifier, + ) + } + if (servers.size > 1) { ServerExposedDropdownMenu( servers = servers, @@ -581,7 +608,6 @@ private fun ButtonWidgetConfigureView( ) } - var actionInputText by remember { mutableStateOf("") } TextField( label = { Text(text = stringResource(commonR.string.label_action)) }, value = actionInputText, @@ -610,7 +636,11 @@ private fun ButtonWidgetConfigureView( } } - HAFilledButton("Add Field", onClick = {}, modifier = Modifier.align(Alignment.End)) + HAFilledButton( + text = stringResource(commonR.string.add_action_data_field), + onClick = { addFieldDialog = true }, + modifier = Modifier.align(Alignment.End), + ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -625,7 +655,6 @@ private fun ButtonWidgetConfigureView( ) } - var labelInputText by remember { mutableStateOf("") } TextField( label = { Text(text = stringResource(commonR.string.label)) }, value = labelInputText, @@ -648,14 +677,6 @@ private fun ButtonWidgetConfigureView( // var selectedOption by rememberSelectedOption() - WidgetBackgroundTypeExposedDropdownMenu( - current = selectedBackgroundType, - onSelected = { - onBackgroundTypeSelected(it) - }, - modifier = Modifier.padding(bottom = 16.dp), - ) - // Radio Group for Widget text/icon color // HARadioGroup( // options = listOf( @@ -689,6 +710,34 @@ private fun ButtonWidgetConfigureView( } } +@Composable +fun AddFieldDialog(action: String, onCancel: (() -> Unit)?, onOk: ((ActionFieldBinder) -> Unit)?, modifier: Modifier) { + val inputValue = remember { mutableStateOf("") } + + MdcAlertDialog( + modifier = modifier, + onDismissRequest = { }, + title = { Text(text = "Field") }, + content = { + TextField( + value = inputValue.value, + onValueChange = { input: String -> + inputValue.value = input + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + ) + }, + onCancel = onCancel, + onSave = null, + onOK = { + val field = ActionFieldBinder(action, inputValue.value) + onOk?.invoke(field) + }, + ) +} + @Composable @Preview(name = "Light Mode") @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -708,9 +757,19 @@ private fun ButtonWidgetConfigureScreenPreview() { selectedEntityId = previewEntity1.entityId, onEntitySelected = {}, dynamicFields = listOf(ActionFieldBinder("Test Action", "Testies", 1)), + onAddFieldDialogOkClicked = { i: Int, binder: ActionFieldBinder -> }, onActionTextUpdated = {}, selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, onBackgroundTypeSelected = {}, ) } } + +@Composable +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +fun AddFieldDialogPreview() { + HATheme { + AddFieldDialog("", {}, {}, modifier = Modifier) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 8b35e214ef8..72a52b3995d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -15,6 +15,9 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.any +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -27,7 +30,10 @@ class ButtonWidgetViewModel @Inject constructor( private var actions = mutableMapOf>() private var entities = mutableMapOf>() - var dynamicFields = ArrayList() + + val dynamicFields: MutableStateFlow> = + MutableStateFlow(arrayListOf()) + var selectedBackgroundType by mutableStateOf( if (DynamicColors.isDynamicColorAvailable()) { WidgetBackgroundType.DYNAMICCOLOR @@ -44,9 +50,21 @@ class ButtonWidgetViewModel @Inject constructor( fun setServer(serverId: Int) { if (selectedServerId == serverId) return selectedServerId = serverId - viewModelScope.launch { selectedServerMutex.withLock { } } + viewModelScope.launch { selectedServerMutex.withLock { } } + } + + fun updateDynamicFields(position: Int, field: ActionFieldBinder) { + val dynamicFields = dynamicFields.value + + dynamicFields.add(position, field) + + this.dynamicFields.update { dynamicFields } + } + + fun clearDynamicFields() { + dynamicFields.value = arrayListOf() } - fun updateActionFields(text: String) { + fun updateActionFields(action: String) { } } From 1f6d05dcbfa21250cb819af26fd15e230b0d2017 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 13 Mar 2026 13:10:02 -0700 Subject: [PATCH 04/14] Refactor ViewModel & Composables to use ButtonWidgetUiState --- .../button/ButtonWidgetConfigureActivity.kt | 788 ++++++------------ .../widgets/button/ButtonWidgetViewModel.kt | 325 +++++++- common/src/main/res/values/strings.xml | 2 +- 3 files changed, 558 insertions(+), 557 deletions(-) mode change 100644 => 100755 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt mode change 100644 => 100755 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt old mode 100644 new mode 100755 index f11d3aef77b..55e36285a58 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -1,36 +1,30 @@ package io.homeassistant.companion.android.widgets.button -import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.res.Configuration -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.os.Build import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import android.widget.EditText -import android.widget.Spinner import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.IconButton +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -39,218 +33,53 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.graphics.toColorInt -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment +import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.BaseActivity import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.compose.composable.HAFilledButton -import io.homeassistant.companion.android.common.compose.composable.HAIconButton +import io.homeassistant.companion.android.common.compose.composable.HAAccentButton +import io.homeassistant.companion.android.common.compose.composable.HADropdownItem +import io.homeassistant.companion.android.common.compose.composable.HADropdownMenu import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action -import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.util.MapAnySerializer -import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.server.Server -import io.homeassistant.companion.android.database.widget.ButtonWidgetDao -import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.compose.MdcAlertDialog -import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu -import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu import io.homeassistant.companion.android.util.getHexForColor -import io.homeassistant.companion.android.util.icondialog.IconDialogFragment -import io.homeassistant.companion.android.util.icondialog.getIconByMdiName -import io.homeassistant.companion.android.util.icondialog.mdiName -import io.homeassistant.companion.android.util.previewEntity1 -import io.homeassistant.companion.android.util.previewEntity2 +import io.homeassistant.companion.android.util.icondialog.IconDialog import io.homeassistant.companion.android.util.previewServer1 import io.homeassistant.companion.android.util.previewServer2 import io.homeassistant.companion.android.util.safeBottomWindowInsets import io.homeassistant.companion.android.util.safeTopWindowInsets -import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.button.ButtonWidgetViewModel.ButtonWidgetUiState import io.homeassistant.companion.android.widgets.common.ActionFieldBinder -import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import io.homeassistant.companion.android.widgets.common.WidgetDynamicFieldAdapter import io.homeassistant.companion.android.widgets.common.WidgetUtils -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import timber.log.Timber // TODO Migrate to compose https://github.com/home-assistant/android/issues/6305 @AndroidEntryPoint -class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { - private var actions = mutableMapOf>() - private var entities = mutableMapOf>() - private var dynamicFields = ArrayList() - private lateinit var dynamicFieldAdapter: WidgetDynamicFieldAdapter - - private lateinit var binding: WidgetButtonConfigureBinding - +class ButtonWidgetConfigureActivity : BaseActivity() { private val viewModel: ButtonWidgetViewModel by viewModels() - override val serverSelect: View - get() = binding.serverSelect - - override val serverSelectList: Spinner - get() = binding.serverSelectList - private var requestLauncherSetup = false - private var actionAdapter: SingleItemArrayAdapter? = null - - private val onAddFieldListener = View.OnClickListener { - val context = this@ButtonWidgetConfigureActivity - val fieldKeyInput = EditText(context) - fieldKeyInput.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, + private val supportedTextColors: List + get() = listOf( + application.getHexForColor(commonR.color.colorWidgetButtonLabelBlack), + application.getHexForColor(android.R.color.white), ) - AlertDialog.Builder(context) - .setTitle("Field") - .setView(fieldKeyInput) - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .setPositiveButton(android.R.string.ok) { _, _ -> - if (dynamicFields.any { - it.field == binding.widgetTextConfigService.text.toString() - } - ) { - return@setPositiveButton - } - - val position = dynamicFields.size - dynamicFields.add( - position, - ActionFieldBinder( - binding.widgetTextConfigService.text.toString(), - fieldKeyInput.text.toString(), - ), - ) - - dynamicFieldAdapter.notifyItemInserted(position) - } - .show() - } - - private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus -> - if (hasFocus && view is AutoCompleteTextView) { - view.showDropDown() - } - } - - private val actionTextWatcher: TextWatcher = ( - object : TextWatcher { - private var ongoingJob: Job? = null - - @SuppressLint("NotifyDataSetChanged") - override fun afterTextChanged(p0: Editable?) { - val actionText: String = p0.toString() - // To make sure only one job at the time is updating the data we keep a reference to the job and cancel - // it if it gets call again. - ongoingJob?.cancel() - ongoingJob = lifecycleScope.launch { - if (actions[selectedServerId].orEmpty().keys.contains(actionText)) { - Timber.d("Valid domain and action--processing dynamic fields") - - // Make sure there are not already any dynamic fields created - // This can happen if selecting the drop-down twice or pasting - dynamicFields.clear() - - // We only call this if servicesAvailable was fetched and is not null, - // so we can safely assume that it is not null here - val actionData = actions[selectedServerId]!![actionText]!!.actionData - val target = actionData.target - val fields = actionData.fields - - val fieldKeys = fields.keys - Timber.d("Fields applicable to this action: $fields") - - val existingActionData = mutableMapOf() - val addedFields = mutableListOf() - dao.get(appWidgetId)?.let { buttonWidget -> - if ( - buttonWidget.serverId != selectedServerId || - "${buttonWidget.domain}.${buttonWidget.service}" != actionText - ) { - return@let - } - - val dbMap: Map = kotlinJsonMapper.decodeFromString( - MapAnySerializer, - buttonWidget.serviceData, - ) - for (item in dbMap) { - val value = - item.value.toString().replace("[", "").replace("]", "") + - if (item.key == "entity_id") ", " else "" - existingActionData[item.key] = value.ifEmpty { null } - addedFields.add(item.key) - } - } - - if (target != false) { - dynamicFields.add( - 0, - ActionFieldBinder(actionText, "entity_id", existingActionData["entity_id"]), - ) - } - - fieldKeys.sorted().forEach { fieldKey -> - Timber.d("Creating a text input box for $fieldKey") - - // Insert a dynamic layout - // IDs get priority and go at the top, since the other fields - // are usually optional but the ID is required - if (fieldKey.contains("_id")) { - dynamicFields.add( - 0, - ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey]), - ) - } else { - dynamicFields.add(ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey])) - } - } - addedFields.minus("entity_id").minus(fieldKeys).forEach { extraFieldKey -> - Timber.d("Creating a text input box for extra $extraFieldKey") - dynamicFields.add( - ActionFieldBinder(actionText, extraFieldKey, existingActionData[extraFieldKey]), - ) - } - dynamicFieldAdapter.notifyDataSetChanged() - } else { - if (dynamicFields.isNotEmpty()) { - dynamicFields.clear() - dynamicFieldAdapter.notifyDataSetChanged() - } - } - } - } - - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} - } - ) - - private fun getActionString(action: Action): String { - return "${action.domain}.${action.action}" - } - public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -258,28 +87,22 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity - launch { - try { - actions[server.id] = HashMap() - serverManager.integrationRepository(server.id).getServices()?.forEach { - actions[server.id]!![getActionString(it)] = it - } - if (server.id == selectedServerId) setAdapterActions(server.id) - } catch (e: Exception) { - // Custom components can cause actions to not load - // Display error text - Timber.e(e, "Unable to load actions from Home Assistant") - if (server.id == selectedServerId) binding.widgetConfigServiceError.visibility = View.VISIBLE - } - } - launch { - try { - entities[server.id] = HashMap() - serverManager.integrationRepository(server.id).getEntities()?.forEach { - entities[server.id]!![it.entityId] = it - } - if (server.id == selectedServerId) setAdapterActions(server.id) - } catch (e: Exception) { - // If entities fail to load, it's okay to pass - // an empty map to the dynamicFieldAdapter - } - } - } - } - - binding.widgetTextConfigService.addTextChangedListener(actionTextWatcher) - - binding.backgroundType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - binding.textColor.isVisible = - parent?.adapter?.getItem(position) == getString(commonR.string.widget_background_type_transparent) - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - binding.textColor.isVisible = false - } - } - - binding.addFieldButton.setOnClickListener(onAddFieldListener) - binding.addButton.setOnClickListener { - if (requestLauncherSetup) { - val widgetConfigAction = binding.widgetTextConfigService.text.toString() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - selectedServerId != null && - ( - widgetConfigAction in actions[selectedServerId].orEmpty().keys || - widgetConfigAction.split(".", limit = 2).size == 2 - ) - ) { - lifecycleScope.launch { - requestWidgetCreation() - } - } else { - showAddWidgetError() - } - } else { - lifecycleScope.launch { - updateWidget() - } - } - } - - dynamicFieldAdapter = WidgetDynamicFieldAdapter(HashMap(), HashMap(), dynamicFields) - binding.widgetConfigFieldsLayout.adapter = dynamicFieldAdapter - binding.widgetConfigFieldsLayout.layoutManager = LinearLayoutManager(this) - } - - override fun onServerSelected(serverId: Int) { - binding.widgetTextConfigService.setText("") - setAdapterActions(serverId) - } - - override suspend fun getPendingDaoEntity(): ButtonWidgetEntity { - val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } - val actionText = binding.widgetTextConfigService.text.toString() - val actions = actions[serverId].orEmpty() - val actionTextParts = actionText.split(".", limit = 2) - val domain = actions[actionText]?.domain ?: actionTextParts.getOrElse(0) { "" } - val action = actions[actionText]?.action ?: actionTextParts.getOrElse(1) { "" } - val actionDataMap = HashMap() - - dynamicFields.forEach { - var value = it.value - if (value != null) { - if (it.field == "entity_id" && value is String) { - // Remove trailing commas and spaces - val trailingRegex = "[, ]+$".toRegex() - value = value.replace(trailingRegex, "") - } - actionDataMap[it.field] = value - } - } - - return ButtonWidgetEntity( - id = appWidgetId, - serverId = serverId, - domain = domain, - service = action, - label = binding.label.text.toString(), - iconName = binding.widgetConfigIconSelector.tag as String, - serviceData = kotlinJsonMapper.encodeToString(MapAnySerializer, actionDataMap), - backgroundType = when (binding.backgroundType.selectedItem as String?) { - getString(commonR.string.widget_background_type_dynamiccolor) -> WidgetBackgroundType.DYNAMICCOLOR - getString(commonR.string.widget_background_type_transparent) -> WidgetBackgroundType.TRANSPARENT - else -> WidgetBackgroundType.DAYNIGHT - }, - textColor = if (binding.backgroundType.selectedItem as String? == - getString(commonR.string.widget_background_type_transparent) - ) { - getHexForColor( - if (binding.textColorWhite.isChecked) { - android.R.color.white - } else { - commonR.color.colorWidgetButtonLabelBlack - }, - ) - } else { - null - }, - requireAuthentication = binding.widgetCheckboxRequireAuthentication.isChecked, - ) - } - - override val widgetClass: Class<*> = ButtonWidget::class.java - - @SuppressLint("NotifyDataSetChanged") - private fun setAdapterActions(serverId: Int) { - Timber.d("Actions found: $actions") - actionAdapter?.clearAll() - if (actions[serverId] != null) { - actionAdapter?.addAll(actions[serverId]?.values.orEmpty().toMutableList()) - actionAdapter?.sort() - } - dynamicFieldAdapter.replaceValues( - actions[serverId].orEmpty() as HashMap, - entities[serverId].orEmpty() as HashMap, - ) - - actionTextWatcher.afterTextChanged(binding.widgetTextConfigService.text) - - // Update action adapter - runOnUiThread { - actionAdapter?.filter?.filter(binding.widgetTextConfigService.text) - } - } - - private fun onIconDialogIconsSelected(selectedIcon: IIcon) { - binding.widgetConfigIconSelector.tag = selectedIcon.mdiName - val iconDrawable = IconicsDrawable(this, selectedIcon) - iconDrawable.colorFilter = - PorterDuffColorFilter(ContextCompat.getColor(this, commonR.color.colorIcon), PorterDuff.Mode.SRC_IN) - - binding.widgetConfigIconSelector.setImageBitmap(iconDrawable.toBitmap()) +// binding.addButton.setOnClickListener { +// if (requestLauncherSetup) { +// val widgetConfigAction = binding.widgetTextConfigService.text.toString() +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && +// selectedServerId != null && +// ( +// widgetConfigAction in actions[selectedServerId].orEmpty().keys || +// widgetConfigAction.split(".", limit = 2).size == 2 +// ) +// ) { +// lifecycleScope.launch { +// requestWidgetCreation() +// } +// } else { +// showAddWidgetError() +// } +// } else { +// lifecycleScope.launch { +// updateWidget() +// } +// } +// } } } @Composable private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { - val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) - val dynamicFields by viewModel.dynamicFields.collectAsStateWithLifecycle() + val state by viewModel.uiState.collectAsStateWithLifecycle(ButtonWidgetUiState()) ButtonWidgetConfigureView( - servers = servers, - selectedServerId = viewModel.selectedServerId, + action = state.action, + onActionTextUpdated = viewModel::updateActionText, + servers = state.servers, + selectedServerId = state.selectedServerId, onServerSelected = viewModel::setServer, - entities = emptyList(), - selectedEntityId = null, - onEntitySelected = {}, - dynamicFields = dynamicFields, - onAddFieldDialogOkClicked = viewModel::updateDynamicFields, - onActionTextUpdated = viewModel::updateActionFields, - selectedBackgroundType = viewModel.selectedBackgroundType, - onBackgroundTypeSelected = { viewModel.selectedBackgroundType = it }, + selectedServerActions = state.selectedServerActions, + dynamicFields = state.dynamicFields, + icon = state.selectedIcon, + onIconSelected = viewModel::selectIcon, + onAddFieldDialogOkClicked = viewModel::addDynamicField, + label = state.label, + onLabelUpdated = viewModel::updateLabel, + textColorIndex = state.textColorIndex, + onTextColorSelected = viewModel::updateTextColorIndex, + selectedBackgroundType = state.selectedBackgroundType, + onBackgroundTypeSelected = viewModel::updateSelectedBackgroundType, + isRequireAuthenticationChecked = state.requiresAuthentication, + onRequireAuthenticationChecked = viewModel::setRequiresAuthentication, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ButtonWidgetConfigureView( + action: String, + onActionTextUpdated: (String) -> Unit, servers: List, - selectedServerId: Int, + selectedServerId: Int?, onServerSelected: (Int) -> Unit, - entities: List, - selectedEntityId: String?, - onEntitySelected: (String?) -> Unit, + selectedServerActions: List, dynamicFields: List, + icon: IIcon, + onIconSelected: (IIcon) -> Unit, onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, - onActionTextUpdated: (String) -> Unit, + label: String, + onLabelUpdated: (String) -> Unit, selectedBackgroundType: WidgetBackgroundType, onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, + isRequireAuthenticationChecked: Boolean, + onRequireAuthenticationChecked: (Boolean) -> Unit, ) { - var actionInputText by remember { mutableStateOf("") } - var addFieldDialog by remember { mutableStateOf(false) } - var labelInputText by remember { mutableStateOf("") } - + var showAddFieldDialog by remember { mutableStateOf(false) } + var showIconDialog by remember { mutableStateOf(false) } + var isActionDropdownExpanded by remember { mutableStateOf(false) } + Timber.i("Selected Server: $selectedServerId") Scaffold( topBar = { TopAppBar( @@ -577,53 +205,88 @@ private fun ButtonWidgetConfigureView( .padding(all = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (addFieldDialog) { + if (showAddFieldDialog) { AddFieldDialog( - action = actionInputText, + action = action, onCancel = { - addFieldDialog = false + showAddFieldDialog = false }, onOk = { actionField -> if (dynamicFields.any { - it.field == actionInputText + it.field == action } ) { - addFieldDialog = false + showAddFieldDialog = false return@AddFieldDialog } onAddFieldDialogOkClicked(dynamicFields.size, actionField) - addFieldDialog = false + showAddFieldDialog = false }, modifier = Modifier, ) } + if (showIconDialog) { + IconDialog( + onSelect = { + showIconDialog = false + onIconSelected(it) + }, + onDismissRequest = { showIconDialog = false }, + ) + } + if (servers.size > 1) { - ServerExposedDropdownMenu( + ServerSelector( servers = servers, - current = selectedServerId, - onSelected = { onServerSelected(it) }, - modifier = Modifier.padding(bottom = 16.dp), + selectedServerId = selectedServerId, + onServerSelected = onServerSelected, + modifier = Modifier.fillMaxWidth(), ) } - TextField( - label = { Text(text = stringResource(commonR.string.label_action)) }, - value = actionInputText, - onValueChange = { - actionInputText = it - onActionTextUpdated(it) - }, - singleLine = true, - modifier = Modifier - .fillMaxWidth(), - ) + Box { + OutlinedTextField( + label = { Text(text = stringResource(commonR.string.label_action)) }, + value = action, + onValueChange = { + onActionTextUpdated(it) + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { state -> + isActionDropdownExpanded = state.hasFocus + }, + ) + if (selectedServerActions.isNotEmpty()) { + DropdownMenu( + expanded = isActionDropdownExpanded, + onDismissRequest = { + isActionDropdownExpanded = false + }, + properties = PopupProperties(focusable = false), + ) { + selectedServerActions.forEach { action -> + val text = "${action.domain}.${action.action}" + DropdownMenuItem( + text = { Text(text = text) }, + onClick = { + onActionTextUpdated(text) + isActionDropdownExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } if (dynamicFields.isNotEmpty()) { dynamicFields.forEach { field -> var fieldInputText by remember { mutableStateOf("") } - TextField( + OutlinedTextField( label = { Text(text = field.field) }, value = fieldInputText, onValueChange = { @@ -636,72 +299,55 @@ private fun ButtonWidgetConfigureView( } } - HAFilledButton( + HAAccentButton( text = stringResource(commonR.string.add_action_data_field), - onClick = { addFieldDialog = true }, + onClick = { showAddFieldDialog = true }, modifier = Modifier.align(Alignment.End), ) + Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { Text(text = stringResource(commonR.string.label_icon)) - HAIconButton( - icon = ImageVector.vectorResource(commonR.drawable.ic_nfc), + IconButton( onClick = { + showIconDialog = true }, - contentDescription = null, - ) + modifier = Modifier.size(24.dp), + ) { + com.mikepenz.iconics.compose.Image( + asset = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(colorResource(commonR.color.colorIcon)), + ) + } } - TextField( + OutlinedTextField( label = { Text(text = stringResource(commonR.string.label)) }, - value = labelInputText, - onValueChange = { - labelInputText = it - }, + value = label, + onValueChange = onLabelUpdated, singleLine = true, modifier = Modifier .fillMaxWidth(), - placeholder = { Text(text = stringResource(commonR.string.widget_text_hint_label)) }, ) - WidgetBackgroundTypeExposedDropdownMenu( - current = selectedBackgroundType, - onSelected = { - onBackgroundTypeSelected(it) - }, - modifier = Modifier.padding(bottom = 16.dp), - ) + BackgroundTypeSelector(selectedBackgroundType, onBackgroundTypeSelected) + + if (selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + WidgetTextColorSelector(textColorIndex, onTextColorSelected) + } -// var selectedOption by rememberSelectedOption() - - // Radio Group for Widget text/icon color -// HARadioGroup( -// options = listOf( -// RadioOption( -// "key1", -// "White", -// ), -// RadioOption( -// "key2", -// "Black", -// ), -// ), -// onSelect = { selectedOption = it }, -// selectionKey = selectedOption?.selectionKey, -// ) - - var isRequireAuthenticationChecked by remember { mutableStateOf(false) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = isRequireAuthenticationChecked, - onCheckedChange = { isRequireAuthenticationChecked = it }, + onCheckedChange = { onRequireAuthenticationChecked(it) }, ) Text(text = stringResource(commonR.string.widget_checkbox_require_authentication)) } - HAFilledButton( + HAAccentButton( text = stringResource(commonR.string.add_widget), onClick = {}, modifier = Modifier.align(Alignment.End), @@ -711,7 +357,12 @@ private fun ButtonWidgetConfigureView( } @Composable -fun AddFieldDialog(action: String, onCancel: (() -> Unit)?, onOk: ((ActionFieldBinder) -> Unit)?, modifier: Modifier) { +fun AddFieldDialog( + action: String, + onCancel: (() -> Unit)?, + onOk: ((ActionFieldBinder) -> Unit)?, + modifier: Modifier = Modifier, +) { val inputValue = remember { mutableStateOf("") } MdcAlertDialog( @@ -738,29 +389,98 @@ fun AddFieldDialog(action: String, onCancel: (() -> Unit)?, onOk: ((ActionFieldB ) } +@Composable +fun ServerSelector( + servers: List, + selectedServerId: Int?, + onServerSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val modelsByKey = remember(servers) { + servers.associateBy { it.id } + } + + val items = remember(modelsByKey) { + modelsByKey.map { (id, server) -> + HADropdownItem(key = id, label = server.friendlyName) + } + } + + HADropdownMenu( + items = items, + selectedKey = selectedServerId, + onItemSelected = { onServerSelected(it) }, + modifier = modifier, + label = "Select Server", + ) +} + +@Composable +fun BackgroundTypeSelector( + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val keys = remember { WidgetUtils.getBackgroundOptionList(context) } + val modelsByKey = remember(keys) { + keys.withIndex().associateBy { it.index } + } + val items = remember(modelsByKey) { + modelsByKey.map { (id, backgroundType) -> + HADropdownItem(key = id, label = backgroundType.value) + } + } + HADropdownMenu( + items = items, + selectedKey = WidgetUtils.getSelectedBackgroundOption(context, selectedBackgroundType, keys), + onItemSelected = { onBackgroundTypeSelected(WidgetUtils.getWidgetBackgroundType(context, keys[it])) }, + label = "Select background type", + modifier = modifier, + ) +} + +@Composable +fun WidgetTextColorSelector(textColorIndex: Int, onTextColorSelected: (Int) -> Unit, modifier: Modifier = Modifier) { + HADropdownMenu( + items = listOf( + HADropdownItem(0, stringResource(commonR.string.widget_text_color_black)), + HADropdownItem(1, stringResource(commonR.string.widget_text_color_white)) + ), + selectedKey = textColorIndex, + onItemSelected = { onTextColorSelected(it) }, + label = stringResource(commonR.string.widget_text_color_title), + modifier = modifier, + ) +} + @Composable @Preview(name = "Light Mode") @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) private fun ButtonWidgetConfigureScreenPreview() { HATheme { ButtonWidgetConfigureView( + action = "", + onActionTextUpdated = {}, servers = listOf( previewServer1, previewServer2, ), selectedServerId = 0, onServerSelected = {}, - entities = listOf( - previewEntity1, - previewEntity2, - ), - selectedEntityId = previewEntity1.entityId, - onEntitySelected = {}, - dynamicFields = listOf(ActionFieldBinder("Test Action", "Testies", 1)), + selectedServerActions = emptyList(), + dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), + icon = CommunityMaterial.Icon2.cmd_flash, + onIconSelected = {}, + label = "", + onLabelUpdated = {}, + textColorIndex = 0, + onTextColorSelected = {}, onAddFieldDialogOkClicked = { i: Int, binder: ActionFieldBinder -> }, - onActionTextUpdated = {}, - selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + selectedBackgroundType = WidgetBackgroundType.TRANSPARENT, onBackgroundTypeSelected = {}, + isRequireAuthenticationChecked = false, + onRequireAuthenticationChecked = {}, ) } } @@ -768,7 +488,7 @@ private fun ButtonWidgetConfigureScreenPreview() { @Composable @Preview(name = "Light Mode") @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) -fun AddFieldDialogPreview() { +private fun AddFieldDialogPreview() { HATheme { AddFieldDialog("", {}, {}, modifier = Modifier) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt old mode 100644 new mode 100755 index 72a52b3995d..e1cf2426693 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -1,26 +1,41 @@ package io.homeassistant.companion.android.widgets.button +import android.appwidget.AppWidgetManager import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.glance.currentState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.material.color.DynamicColors +import com.mikepenz.iconics.typeface.IIcon +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.MapAnySerializer +import io.homeassistant.companion.android.common.util.kotlinJsonMapper +import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.icondialog.getIconByMdiName +import io.homeassistant.companion.android.util.icondialog.mdiName import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import javax.inject.Inject +import kotlin.collections.indexOf +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.any +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import timber.log.Timber @HiltViewModel class ButtonWidgetViewModel @Inject constructor( @@ -28,43 +43,309 @@ class ButtonWidgetViewModel @Inject constructor( val serverManager: ServerManager, ) : ViewModel() { - private var actions = mutableMapOf>() - private var entities = mutableMapOf>() - - val dynamicFields: MutableStateFlow> = - MutableStateFlow(arrayListOf()) - - var selectedBackgroundType by mutableStateOf( - if (DynamicColors.isDynamicColorAvailable()) { + data class ButtonWidgetUiState( + val action: String = "", + val selectedServerId: Int? = ServerManager.SERVER_ID_ACTIVE, + val servers: List = emptyList(), + val selectedServerActions: List = emptyList(), + val dynamicFields: List = emptyList(), + val selectedIcon: IIcon = CommunityMaterial.Icon2.cmd_flash, + val selectedIconId: String? = null, + val label: String = "", + val selectedBackgroundType: WidgetBackgroundType = if (DynamicColors.isDynamicColorAvailable()) { WidgetBackgroundType.DYNAMICCOLOR } else { WidgetBackgroundType.DAYNIGHT }, + val textColorIndex: Int = 0, + val requiresAuthentication: Boolean = false, ) - val servers = serverManager.serversFlow + private val _uiState: MutableStateFlow = MutableStateFlow(ButtonWidgetUiState()) + val uiState: StateFlow = _uiState.asStateFlow() +// val uiState: StateFlow = _uiState.asStateFlow() + + private var supportedTextColors: List = emptyList() + + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + private var requestLauncherSetup: Boolean = false + + private var actions = mutableMapOf>() + private var entities = mutableMapOf>() private val selectedServerMutex = Mutex() - var selectedServerId by mutableIntStateOf(ServerManager.SERVER_ID_ACTIVE) - private set + + private var ongoingJob: Job? = null + + init { + viewModelScope.launch { + _uiState.update { currentState -> + currentState.copy( + servers = serverManager.servers(), + selectedServerId = serverManager.getServer()?.id, + ) + } + for (server in serverManager.servers()) { + launch { + getActionsFromServer(server) + } + launch { + getEntitiesFromServer(server) + } + } + } + } + + fun onSetup(widgetId: Int, requestLauncherSetup: Boolean, supportedTextColors: List) { + this.requestLauncherSetup = requestLauncherSetup + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId + maybeLoadPreviousState(widgetId) + } + + private fun maybeLoadPreviousState(widgetId: Int) { + viewModelScope.launch { + buttonWidgetDao.get(widgetId)?.let { widget -> + _uiState.update { currentState -> + val icon = CommunityMaterial.getIconByMdiName(widget.iconName) + val colorIndex = supportedTextColors.indexOf(widget.textColor) + currentState.copy( + action = "${widget.domain}.${widget.service}", + selectedServerId = widget.serverId, + label = widget.label ?: "", + selectedBackgroundType = widget.backgroundType, + selectedIcon = icon ?: CommunityMaterial.Icon2.cmd_flash, + selectedIconId = widget.iconName, + textColorIndex = if (colorIndex == -1) 0 else colorIndex, + requiresAuthentication = widget.requireAuthentication, + ) + } + } + } + } + + private suspend fun getActionsFromServer(server: Server) { + val selectedServerId = _uiState.value.selectedServerId + try { + actions[server.id] = HashMap() + serverManager.integrationRepository(server.id).getServices()?.forEach { + actions[server.id]?.set(getActionString(it), it) + } + if (server.id == selectedServerId) setAdapterActions(server.id) + } catch (e: Exception) { + // Custom components can cause actions to not load + // Display error text + Timber.e(e, "Unable to load actions from Home Assistant") + } + } + + private suspend fun getEntitiesFromServer(server: Server) { + val selectedServerId = _uiState.value.selectedServerId + try { + entities[server.id] = HashMap() + serverManager.integrationRepository(server.id).getEntities()?.forEach { + entities[server.id]?.set(it.entityId, it) + } + if (server.id == selectedServerId) setAdapterActions(server.id) + } catch (e: Exception) { + // If entities fail to load, it's okay to pass + // an empty map to the dynamicFieldAdapter + } + } + + fun updateActionText(newAction: String) { + _uiState.update { currentState -> + currentState.copy( + action = newAction, + ) + } + updateActionFields(newAction) + } + + private fun getActionString(action: Action): String { + return "${action.domain}.${action.action}" + } + + fun updateLabel(newLabel: String) { + _uiState.update { currentState -> + currentState.copy( + label = newLabel, + ) + } + } fun setServer(serverId: Int) { + val selectedServerId = _uiState.value.selectedServerId if (selectedServerId == serverId) return - selectedServerId = serverId - viewModelScope.launch { selectedServerMutex.withLock { } } + _uiState.update { currentState -> + currentState.copy( + action = "", + selectedServerId = serverId, + ) + } + viewModelScope.launch { + selectedServerMutex.withLock { + setAdapterActions(serverId) + } + } } - fun updateDynamicFields(position: Int, field: ActionFieldBinder) { - val dynamicFields = dynamicFields.value + fun addDynamicField(position: Int, field: ActionFieldBinder) { + _uiState.update { currentState -> + val dynamicFields = currentState.dynamicFields.toMutableList() + dynamicFields.add(position, field) + currentState.copy( + dynamicFields = dynamicFields, + ) + } + } - dynamicFields.add(position, field) + private fun updateDynamicFields(dynamicFields: List) { + _uiState.update { currentState -> + currentState.copy( + dynamicFields = dynamicFields, + ) + } + } - this.dynamicFields.update { dynamicFields } + fun selectIcon(icon: IIcon) { + _uiState.update { currentState -> + currentState.copy( + selectedIcon = icon, + selectedIconId = icon.mdiName, + ) + } } - fun clearDynamicFields() { - dynamicFields.value = arrayListOf() + fun updateSelectedBackgroundType(backgroundType: WidgetBackgroundType) { + _uiState.update { currentState -> + currentState.copy( + selectedBackgroundType = backgroundType, + ) + } } - fun updateActionFields(action: String) { + fun updateTextColorIndex(textColorIndex: Int) { + _uiState.update { currentState -> + currentState.copy( + textColorIndex = textColorIndex, + ) + } } + + fun setRequiresAuthentication(authenticationRequired: Boolean) { + _uiState.update { currentState -> + currentState.copy( + requiresAuthentication = authenticationRequired, + ) + } + } + + fun updateActionFields(actionText: String) { + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + val selectedServerId = _uiState.value.selectedServerId + ongoingJob?.cancel() + ongoingJob = viewModelScope.launch { + if (actions[selectedServerId].orEmpty().keys.contains(actionText)) { + Timber.d("Valid domain and action--processing dynamic fields") + + // Make sure there are not already any dynamic fields created + // This can happen if selecting the drop-down twice or pasting + dynamicFields.clear() + + // We only call this if servicesAvailable was fetched and is not null, + // so we can safely assume that it is not null here + val actionData = actions[selectedServerId]!![actionText]!!.actionData + val target = actionData.target + val fields = actionData.fields + + val fieldKeys = fields.keys + Timber.d("Fields applicable to this action: $fields") + + val existingActionData = mutableMapOf() + val addedFields = mutableListOf() + buttonWidgetDao.get(widgetId)?.let { buttonWidget -> + if ( + buttonWidget.serverId != selectedServerId || + "${buttonWidget.domain}.${buttonWidget.service}" != actionText + ) { + return@let + } + + val dbMap: Map = kotlinJsonMapper.decodeFromString( + MapAnySerializer, + buttonWidget.serviceData, + ) + for (item in dbMap) { + val value = + item.value.toString().replace("[", "").replace("]", "") + + if (item.key == "entity_id") ", " else "" + existingActionData[item.key] = value.ifEmpty { null } + addedFields.add(item.key) + } + } + + if (target != false) { + dynamicFields.add( + 0, + ActionFieldBinder(actionText, "entity_id", existingActionData["entity_id"]), + ) + } + + fieldKeys.sorted().forEach { fieldKey -> + Timber.d("Creating a text input box for $fieldKey") + + // Insert a dynamic layout + // IDs get priority and go at the top, since the other fields + // are usually optional but the ID is required + if (fieldKey.contains("_id")) { + dynamicFields.add( + 0, + ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey]), + ) + } else { + dynamicFields.add(ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey])) + } + } + addedFields.minus("entity_id").minus(fieldKeys).forEach { extraFieldKey -> + Timber.d("Creating a text input box for extra $extraFieldKey") + dynamicFields.add( + ActionFieldBinder(actionText, extraFieldKey, existingActionData[extraFieldKey]), + ) + } + } else { + if (dynamicFields.isNotEmpty()) { + dynamicFields.clear() + } + } + Timber.i(dynamicFields.toString()) + updateDynamicFields(dynamicFields) + } + } + + private fun setAdapterActions(serverId: Int) { + Timber.i("Setting Adapter Actions") + var selectedServerActions: List = emptyList() + if (actions[serverId] != null) { + selectedServerActions = actions[serverId]?.values.orEmpty().toMutableList() + val comparator = Comparator { t1: Action, t2: Action -> + getActionString(t1).compareTo(getActionString(t2)) + } + selectedServerActions = selectedServerActions.sortedWith(comparator) + } + _uiState.update { currentState -> + currentState.copy( + selectedServerActions = selectedServerActions, + ) + } + } + +// private fun filterAdapterActions(constraint: CharSequence) { +// val validItems = ArrayList() +// for (i in 0 until selectedServerActions.size) { +// val item = selectedServerActions[i] +// if (getActionString(item).contains(constraint)) { +// validItems.add(item) +// } +// } +// } } diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 6d7a2293e65..fdbf3268f9b 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -375,7 +375,7 @@ Entity ID(s): Icon: Label: - Action: + Action Label Device language default From b6eca385e230afeae4926a96e1a24be7bc901e21 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 27 Apr 2026 00:52:14 -0700 Subject: [PATCH 05/14] Add Filtering to Action Text Input Field --- .../button/ButtonWidgetConfigureActivity.kt | 42 ++++---------- .../widgets/button/ButtonWidgetViewModel.kt | 58 ++++++++++++++----- 2 files changed, 54 insertions(+), 46 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 55e36285a58..4c03903c75a 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -3,6 +3,8 @@ package io.homeassistant.companion.android.widgets.button import android.appwidget.AppWidgetManager import android.content.res.Configuration import android.os.Bundle +import android.view.View +import android.widget.Spinner import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement @@ -33,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext @@ -53,6 +56,7 @@ import io.homeassistant.companion.android.common.compose.composable.HADropdownMe import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.compose.MdcAlertDialog @@ -62,6 +66,7 @@ import io.homeassistant.companion.android.util.previewServer1 import io.homeassistant.companion.android.util.previewServer2 import io.homeassistant.companion.android.util.safeBottomWindowInsets import io.homeassistant.companion.android.util.safeTopWindowInsets +import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.button.ButtonWidgetViewModel.ButtonWidgetUiState import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import io.homeassistant.companion.android.widgets.common.WidgetUtils @@ -72,8 +77,6 @@ import timber.log.Timber class ButtonWidgetConfigureActivity : BaseActivity() { private val viewModel: ButtonWidgetViewModel by viewModels() - private var requestLauncherSetup = false - private val supportedTextColors: List get() = listOf( application.getHexForColor(commonR.color.colorWidgetButtonLabelBlack), @@ -97,7 +100,7 @@ class ButtonWidgetConfigureActivity : BaseActivity() { val appWidgetId = intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID - requestLauncherSetup = intent.extras?.getBoolean( + val requestLauncherSetup = intent.extras?.getBoolean( ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false, ) ?: false @@ -109,29 +112,6 @@ class ButtonWidgetConfigureActivity : BaseActivity() { finish() return } - -// binding.addButton.setOnClickListener { -// if (requestLauncherSetup) { -// val widgetConfigAction = binding.widgetTextConfigService.text.toString() -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && -// selectedServerId != null && -// ( -// widgetConfigAction in actions[selectedServerId].orEmpty().keys || -// widgetConfigAction.split(".", limit = 2).size == 2 -// ) -// ) { -// lifecycleScope.launch { -// requestWidgetCreation() -// } -// } else { -// showAddWidgetError() -// } -// } else { -// lifecycleScope.launch { -// updateWidget() -// } -// } -// } } } @@ -145,7 +125,7 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { servers = state.servers, selectedServerId = state.selectedServerId, onServerSelected = viewModel::setServer, - selectedServerActions = state.selectedServerActions, + serverActions = state.serverActions, dynamicFields = state.dynamicFields, icon = state.selectedIcon, onIconSelected = viewModel::selectIcon, @@ -169,7 +149,7 @@ private fun ButtonWidgetConfigureView( servers: List, selectedServerId: Int?, onServerSelected: (Int) -> Unit, - selectedServerActions: List, + serverActions: List, dynamicFields: List, icon: IIcon, onIconSelected: (IIcon) -> Unit, @@ -260,7 +240,7 @@ private fun ButtonWidgetConfigureView( isActionDropdownExpanded = state.hasFocus }, ) - if (selectedServerActions.isNotEmpty()) { + if (serverActions.isNotEmpty()) { DropdownMenu( expanded = isActionDropdownExpanded, onDismissRequest = { @@ -268,7 +248,7 @@ private fun ButtonWidgetConfigureView( }, properties = PopupProperties(focusable = false), ) { - selectedServerActions.forEach { action -> + serverActions.forEach { action -> val text = "${action.domain}.${action.action}" DropdownMenuItem( text = { Text(text = text) }, @@ -468,7 +448,7 @@ private fun ButtonWidgetConfigureScreenPreview() { ), selectedServerId = 0, onServerSelected = {}, - selectedServerActions = emptyList(), + serverActions = emptyList(), dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), icon = CommunityMaterial.Icon2.cmd_flash, onIconSelected = {}, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index e1cf2426693..67172f46f28 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -47,7 +47,7 @@ class ButtonWidgetViewModel @Inject constructor( val action: String = "", val selectedServerId: Int? = ServerManager.SERVER_ID_ACTIVE, val servers: List = emptyList(), - val selectedServerActions: List = emptyList(), + val serverActions: List = emptyList(), val dynamicFields: List = emptyList(), val selectedIcon: IIcon = CommunityMaterial.Icon2.cmd_flash, val selectedIconId: String? = null, @@ -63,7 +63,6 @@ class ButtonWidgetViewModel @Inject constructor( private val _uiState: MutableStateFlow = MutableStateFlow(ButtonWidgetUiState()) val uiState: StateFlow = _uiState.asStateFlow() -// val uiState: StateFlow = _uiState.asStateFlow() private var supportedTextColors: List = emptyList() @@ -73,6 +72,7 @@ class ButtonWidgetViewModel @Inject constructor( private var actions = mutableMapOf>() private var entities = mutableMapOf>() private val selectedServerMutex = Mutex() + private var selectedServerActions: List = emptyList() private var ongoingJob: Job? = null @@ -159,6 +159,7 @@ class ButtonWidgetViewModel @Inject constructor( ) } updateActionFields(newAction) + filterAdapterActions(newAction) } private fun getActionString(action: Action): String { @@ -240,6 +241,14 @@ class ButtonWidgetViewModel @Inject constructor( } } + fun setServerActions(actions: List) { + _uiState.update { currentState -> + currentState.copy( + serverActions = actions + ) + } + } + fun updateActionFields(actionText: String) { val dynamicFields = _uiState.value.dynamicFields.toMutableList() val selectedServerId = _uiState.value.selectedServerId @@ -330,22 +339,41 @@ class ButtonWidgetViewModel @Inject constructor( val comparator = Comparator { t1: Action, t2: Action -> getActionString(t1).compareTo(getActionString(t2)) } - selectedServerActions = selectedServerActions.sortedWith(comparator) - } - _uiState.update { currentState -> - currentState.copy( - selectedServerActions = selectedServerActions, - ) + this.selectedServerActions = selectedServerActions.sortedWith(comparator) + setServerActions(this.selectedServerActions) } } -// private fun filterAdapterActions(constraint: CharSequence) { -// val validItems = ArrayList() -// for (i in 0 until selectedServerActions.size) { -// val item = selectedServerActions[i] -// if (getActionString(item).contains(constraint)) { -// validItems.add(item) + private fun filterAdapterActions(constraint: CharSequence) { + val validItems = ArrayList() + for (i in 0 until selectedServerActions.size) { + val item = selectedServerActions[i] + if (getActionString(item).startsWith(constraint)) { + validItems.add(item) + } + } + setServerActions(validItems) + } +// binding.addButton.setOnClickListener { +// if (requestLauncherSetup) { +// val widgetConfigAction = binding.widgetTextConfigService.text.toString() +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && +// selectedServerId != null && +// ( +// widgetConfigAction in actions[selectedServerId].orEmpty().keys || +// widgetConfigAction.split(".", limit = 2).size == 2 +// ) +// ) { +// lifecycleScope.launch { +// requestWidgetCreation() +// } +// } else { +// showAddWidgetError() +// } +// } else { +// lifecycleScope.launch { +// updateWidget() +// } // } // } -// } } From 53e2edcdc71fe0ad688d9042864daefc70edc57a Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 27 Apr 2026 14:06:00 -0700 Subject: [PATCH 06/14] Modify Action Text Field to use TextFieldState --- .../button/ButtonWidgetConfigureActivity.kt | 23 ++++++++++++++----- .../widgets/button/ButtonWidgetViewModel.kt | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 4c03903c75a..271a253cb0f 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -16,6 +16,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.Checkbox import androidx.compose.material.DropdownMenu @@ -29,10 +34,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester @@ -70,6 +77,7 @@ import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.button.ButtonWidgetViewModel.ButtonWidgetUiState import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import io.homeassistant.companion.android.widgets.common.WidgetUtils +import kotlinx.coroutines.flow.collectLatest import timber.log.Timber // TODO Migrate to compose https://github.com/home-assistant/android/issues/6305 @@ -227,13 +235,16 @@ private fun ButtonWidgetConfigureView( } Box { + val actionFieldState = rememberTextFieldState(initialText = "") + LaunchedEffect(Unit) { + snapshotFlow { actionFieldState.text.toString() }.collectLatest { + onActionTextUpdated(it) + } + } OutlinedTextField( label = { Text(text = stringResource(commonR.string.label_action)) }, - value = action, - onValueChange = { - onActionTextUpdated(it) - }, - singleLine = true, + state = actionFieldState, + lineLimits = TextFieldLineLimits.SingleLine, modifier = Modifier .fillMaxWidth() .onFocusChanged { state -> @@ -253,7 +264,7 @@ private fun ButtonWidgetConfigureView( DropdownMenuItem( text = { Text(text = text) }, onClick = { - onActionTextUpdated(text) + actionFieldState.setTextAndPlaceCursorAtEnd(text) isActionDropdownExpanded = false }, contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 67172f46f28..8f9baef5314 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -348,7 +348,7 @@ class ButtonWidgetViewModel @Inject constructor( val validItems = ArrayList() for (i in 0 until selectedServerActions.size) { val item = selectedServerActions[i] - if (getActionString(item).startsWith(constraint)) { + if (getActionString(item).contains(constraint)) { validItems.add(item) } } From 0a9f1e715e69f8990c714a015ee3910df43cc43e Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 27 Apr 2026 14:33:55 -0700 Subject: [PATCH 07/14] Update Dynamic Fields Text Input to track state --- .../button/ButtonWidgetConfigureActivity.kt | 31 +++++++++++-------- .../widgets/button/ButtonWidgetViewModel.kt | 10 ++++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 271a253cb0f..8d88831a9be 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -98,12 +98,6 @@ class ButtonWidgetConfigureActivity : BaseActivity() { // out of the widget placement if the user presses the back button. setResult(RESULT_CANCELED) - setContent { - HATheme { - ButtonWidgetConfigureScreen(viewModel) - } - } - // Find the widget id from the intent. val appWidgetId = intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) @@ -120,6 +114,12 @@ class ButtonWidgetConfigureActivity : BaseActivity() { finish() return } + + setContent { + HATheme { + ButtonWidgetConfigureScreen(viewModel) + } + } } } @@ -135,6 +135,7 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { onServerSelected = viewModel::setServer, serverActions = state.serverActions, dynamicFields = state.dynamicFields, + onDynamicFieldUpdated = viewModel::updateDynamicField, icon = state.selectedIcon, onIconSelected = viewModel::selectIcon, onAddFieldDialogOkClicked = viewModel::addDynamicField, @@ -159,6 +160,7 @@ private fun ButtonWidgetConfigureView( onServerSelected: (Int) -> Unit, serverActions: List, dynamicFields: List, + onDynamicFieldUpdated: (Int, ActionFieldBinder) -> Unit, icon: IIcon, onIconSelected: (IIcon) -> Unit, onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, @@ -275,15 +277,17 @@ private fun ButtonWidgetConfigureView( } if (dynamicFields.isNotEmpty()) { - dynamicFields.forEach { field -> - var fieldInputText by remember { mutableStateOf("") } + dynamicFields.forEachIndexed { index, field -> + val fieldInputState = rememberTextFieldState() + LaunchedEffect(Unit) { + snapshotFlow { fieldInputState.text.toString() }.collectLatest { + onDynamicFieldUpdated(index, field) + } + } OutlinedTextField( label = { Text(text = field.field) }, - value = fieldInputText, - onValueChange = { - fieldInputText = it - }, - singleLine = true, + state = fieldInputState, + lineLimits = TextFieldLineLimits.SingleLine, modifier = Modifier .fillMaxWidth(), ) @@ -461,6 +465,7 @@ private fun ButtonWidgetConfigureScreenPreview() { onServerSelected = {}, serverActions = emptyList(), dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), + onDynamicFieldUpdated = { i: Int, binder: ActionFieldBinder -> }, icon = CommunityMaterial.Icon2.cmd_flash, onIconSelected = {}, label = "", diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 8f9baef5314..29b73c97aab 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -208,6 +208,16 @@ class ButtonWidgetViewModel @Inject constructor( } } + fun updateDynamicField(index: Int, field: ActionFieldBinder) { + _uiState.update { currentState -> + val dynamicFields = currentState.dynamicFields.toMutableList() + dynamicFields[index] = field + currentState.copy( + dynamicFields = dynamicFields + ) + } + } + fun selectIcon(icon: IIcon) { _uiState.update { currentState -> currentState.copy( From 90a69be4b2197d2c021d6c0e05e9d72778841dee Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 27 Apr 2026 19:32:52 -0700 Subject: [PATCH 08/14] Add Widget Creation --- .../widgets/button/ButtonGlanceAppWidget.kt | 16 + .../android/widgets/button/ButtonWidget.kt | 317 +----------------- .../button/ButtonWidgetConfigureActivity.kt | 69 +++- .../widgets/button/ButtonWidgetViewModel.kt | 118 +++++-- 4 files changed, 170 insertions(+), 350 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt new file mode 100644 index 00000000000..6872f8d38fd --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt @@ -0,0 +1,16 @@ +package io.homeassistant.companion.android.widgets.button + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import androidx.glance.text.Text +import io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver + +class ButtonGlanceAppWidget: GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + Text("Hello, pearl") + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt index 0fda6371b9f..161996ddbc3 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt @@ -15,6 +15,7 @@ import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.toColorInt import androidx.core.os.BundleCompat +import androidx.glance.appwidget.GlanceAppWidget import com.google.android.material.color.DynamicColors import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsSize @@ -34,8 +35,10 @@ import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.util.icondialog.getIconByMdiName import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import io.homeassistant.companion.android.widgets.EntitiesPerServer import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity import java.util.regex.Pattern import javax.inject.Inject @@ -50,7 +53,13 @@ import kotlinx.coroutines.withContext import timber.log.Timber @AndroidEntryPoint -class ButtonWidget : AppWidgetProvider() { +class ButtonWidget : BaseGlanceEntityWidgetReceiver() { + override suspend fun getWidgetEntitiesByServer(context: Context): Map { + return dao.getAll().associate { widget -> widget.id to EntitiesPerServer(widget.serverId, listOf()) } + } + + override val glanceAppWidget: GlanceAppWidget = ButtonGlanceAppWidget() + companion object { const val CALL_SERVICE = "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE" @@ -62,311 +71,5 @@ class ButtonWidget : AppWidgetProvider() { private var widgetScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) } - @Inject - lateinit var serverManager: ServerManager - - @Inject - lateinit var buttonWidgetDao: ButtonWidgetDao - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - // There may be multiple widgets active, so update all of them - for (appWidgetId in appWidgetIds) { - widgetScope.launch { - val views = getWidgetRemoteViews(context, appWidgetId) - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } - } - - private suspend fun updateAllWidgets(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) ?: return - val systemWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, ButtonWidget::class.java)) - val dbWidgetList = buttonWidgetDao.getAll() - - val invalidWidgetIds = dbWidgetList - .filter { !systemWidgetIds.contains(it.id) } - .map { it.id } - if (invalidWidgetIds.isNotEmpty()) { - Timber.i("Found widgets $invalidWidgetIds in database, but not in AppWidgetManager - sending onDeleted") - onDeleted(context, invalidWidgetIds.toIntArray()) - } - - val buttonWidgetEntityList = dbWidgetList.filter { systemWidgetIds.contains(it.id) } - if (buttonWidgetEntityList.isNotEmpty()) { - Timber.d("Updating all widgets") - for (item in buttonWidgetEntityList) { - val views = getWidgetRemoteViews(context, item.id) - - setLabelVisibility(views, item) - views.setViewVisibility(R.id.widgetProgressBar, View.INVISIBLE) - views.setViewVisibility(R.id.widgetImageButtonLayout, View.VISIBLE) - appWidgetManager.updateAppWidget(item.id, views) - } - } - } - - override fun onDeleted(context: Context, appWidgetIds: IntArray) { - // When the user deletes the widget, delete the preference associated with it. - widgetScope.launch { - buttonWidgetDao.deleteAll(appWidgetIds) - } - } - - override fun onEnabled(context: Context) { - // Enter relevant functionality for when the first widget is created - } - - override fun onDisabled(context: Context) { - // Enter relevant functionality for when the last widget is disabled - } - - override fun onReceive(context: Context, intent: Intent) { - val action = intent.action - val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) - - Timber.d( - "Broadcast received: " + System.lineSeparator() + - "Broadcast action: " + action + System.lineSeparator() + - "AppWidgetId: " + appWidgetId, - ) - - super.onReceive(context, intent) - when (action) { - CALL_SERVICE_AUTH -> authThenCallConfiguredAction(context, appWidgetId) - CALL_SERVICE -> widgetScope.launch { callConfiguredAction(context, appWidgetId) } - BaseWidgetProvider.UPDATE_WIDGETS, Intent.ACTION_SCREEN_ON -> widgetScope.launch { - updateAllWidgets( - context, - ) - } - - ACTION_APPWIDGET_CREATED -> { - widgetScope.launch { - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - FailFast.fail { "Missing appWidgetId in intent to add widget in DAO" } - } else { - val entity = intent.extras?.let { - BundleCompat.getSerializable( - it, - EXTRA_WIDGET_ENTITY, - ButtonWidgetEntity::class.java, - ) - } - entity?.let { - buttonWidgetDao.add(entity.copyWithWidgetId(appWidgetId)) - } ?: FailFast.fail { "Missing $EXTRA_WIDGET_ENTITY or it's of the wrong type in intent." } - } - updateAllWidgets(context) - } - } - } - } - - private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int) { - Timber.d("Calling authentication, then configured action") - - val intent = Intent(context, WidgetAuthenticationActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - context.startActivity(intent) - } - - private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews { - // Every time AppWidgetManager.updateAppWidget(...) is called, the button listener - // and label need to be re-assigned, or the next time the layout updates - // (e.g home screen rotation) the widget will fall back on its default layout - // without any click listener being applied - - val widget = buttonWidgetDao.get(appWidgetId) - val auth = widget?.requireAuthentication == true - - val intent = Intent(context, ButtonWidget::class.java).apply { - action = if (auth) CALL_SERVICE_AUTH else CALL_SERVICE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - } - - val useDynamicColors = - widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() - return RemoteViews( - context.packageName, - if (useDynamicColors) { - R.layout.widget_button_wrapper_dynamiccolor - } else { - R.layout.widget_button_wrapper_default - }, - ).apply { - // Theming - var textColor = context.getAttribute( - R.attr.colorWidgetOnBackground, - ContextCompat.getColor(context, commonR.color.colorWidgetButtonLabel), - ) - if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) { - widget.textColor?.let { textColor = it.toColorInt() } - setTextColor(R.id.widgetLabel, textColor) - } - setWidgetBackground(this, widget) - - // Label - setLabelVisibility(this, widget) - - // Content - val iconData = widget?.iconName?.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 icon = DrawableCompat.wrap(iconDrawable) - if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) { - setInt(R.id.widgetImageButton, "setColorFilter", textColor) - } - - // Determine reasonable dimensions for drawing vector icon as a bitmap - val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble() - val awo = if (widget != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(widget.id) else null - val maxWidth = ( - awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, DEFAULT_MAX_ICON_SIZE) - ?: DEFAULT_MAX_ICON_SIZE - ).coerceAtLeast(16) - val maxHeight = ( - awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, DEFAULT_MAX_ICON_SIZE) - ?: DEFAULT_MAX_ICON_SIZE - ).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 - } - - // Render the icon into the Button's ImageView - setImageViewBitmap(R.id.widgetImageButton, icon.toBitmap(width, height)) - - setOnClickPendingIntent( - R.id.widgetImageButtonLayout, - PendingIntent.getBroadcast( - context, - appWidgetId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), - ) - setTextViewText( - R.id.widgetLabel, - widget?.label ?: "", - ) - } - } - - private fun setWidgetBackground(views: RemoteViews, widget: ButtonWidgetEntity?) { - when (widget?.backgroundType) { - WidgetBackgroundType.TRANSPARENT -> { - views.setInt(R.id.widgetLayout, "setBackgroundColor", Color.TRANSPARENT) - } - - else -> { - views.setInt(R.id.widgetLayout, "setBackgroundResource", R.drawable.widget_button_background) - } - } - } - - private fun setLabelVisibility(views: RemoteViews, widget: ButtonWidgetEntity?) { - val labelVisibility = if (widget?.label.isNullOrBlank()) View.GONE else View.VISIBLE - views.setViewVisibility(R.id.widgetLabelLayout, labelVisibility) - } - private suspend fun callConfiguredAction(context: Context, appWidgetId: Int) { - Timber.d("Calling widget action") - - // Set up progress bar as immediate feedback to show the click has been received - // Success or failure feedback will come from the mainScope coroutine - val loadingViews = RemoteViews(context.packageName, R.layout.widget_button) - val appWidgetManager = AppWidgetManager.getInstance(context) - - loadingViews.setViewVisibility(R.id.widgetProgressBar, View.VISIBLE) - loadingViews.setViewVisibility(R.id.widgetImageButtonLayout, View.GONE) - appWidgetManager.partiallyUpdateAppWidget(appWidgetId, loadingViews) - - val widget = buttonWidgetDao.get(appWidgetId) - // Set default feedback as negative - var feedbackColor = R.drawable.widget_button_background_red - var feedbackIcon = R.drawable.ic_clear_black - - // Load the action call data from Shared Preferences - val domain = widget?.domain - val action = widget?.service - val actionDataJson = widget?.serviceData - - Timber.d( - "Action Call Data loaded:" + System.lineSeparator() + - "domain: " + domain + System.lineSeparator() + - "action: " + action + System.lineSeparator() + - "action_data: " + actionDataJson, - ) - - if (domain == null || action == null || actionDataJson == null) { - Timber.w("Action Call Data incomplete. Aborting action call") - } else { - // If everything loaded correctly, package the action data and attempt the call - try { - // Convert JSON to HashMap - val actionDataMap = kotlinJsonMapper.decodeFromString>( - MapAnySerializer, - actionDataJson, - ).toMutableMap() - - if (actionDataMap["entity_id"] != null) { - val entityIdWithoutBrackets = Pattern.compile("\\[(.*?)\\]") - .matcher(actionDataMap["entity_id"].toString()) - if (entityIdWithoutBrackets.find()) { - val value = entityIdWithoutBrackets.group(1) - if (value != null) { - if (value == "all" || - value.split(",").contains("all") - ) { - actionDataMap["entity_id"] = "all" - } - } - } - } - - Timber.d("Sending action call to Home Assistant") - serverManager.integrationRepository(widget.serverId).callAction(domain, action, actionDataMap) - Timber.d("Action call sent successfully") - - // If action call does not throw an exception, send positive feedback - feedbackColor = R.drawable.widget_button_background_green - feedbackIcon = R.drawable.ic_check_black_24dp - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "Could not send action call.") - withContext(Dispatchers.Main) { - Toast.makeText(context, commonR.string.action_failure, Toast.LENGTH_LONG).show() - } - } - } - - // Update widget and set visibilities for feedback - val feedbackViews = RemoteViews(context.packageName, R.layout.widget_button) - feedbackViews.setInt(R.id.widgetLayout, "setBackgroundResource", feedbackColor) - feedbackViews.setImageViewResource(R.id.widgetImageButton, feedbackIcon) - feedbackViews.setViewVisibility(R.id.widgetProgressBar, View.INVISIBLE) - feedbackViews.setViewVisibility(R.id.widgetLabelLayout, View.GONE) - feedbackViews.setViewVisibility(R.id.widgetImageButtonLayout, View.VISIBLE) - appWidgetManager.partiallyUpdateAppWidget(appWidgetId, feedbackViews) - - // Reload default views in the coroutine to pass to the post handler - val views = getWidgetRemoteViews(context, appWidgetId) - - // Set a timer to change it back after 1 second - delay(1.seconds) - setLabelVisibility(views, widget) - setWidgetBackground(views, widget) - appWidgetManager.updateAppWidget(appWidgetId, views) - } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index 8d88831a9be..d6e482aa357 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -1,12 +1,14 @@ package io.homeassistant.companion.android.widgets.button +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.res.Configuration +import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.Spinner +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,9 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -42,7 +42,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext @@ -52,6 +51,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.AndroidEntryPoint @@ -63,7 +63,6 @@ import io.homeassistant.companion.android.common.compose.composable.HADropdownMe import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.database.server.Server -import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.compose.MdcAlertDialog @@ -73,11 +72,11 @@ import io.homeassistant.companion.android.util.previewServer1 import io.homeassistant.companion.android.util.previewServer2 import io.homeassistant.companion.android.util.safeBottomWindowInsets import io.homeassistant.companion.android.util.safeTopWindowInsets -import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.button.ButtonWidgetViewModel.ButtonWidgetUiState import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import io.homeassistant.companion.android.widgets.common.WidgetUtils import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import timber.log.Timber // TODO Migrate to compose https://github.com/home-assistant/android/issues/6305 @@ -117,14 +116,59 @@ class ButtonWidgetConfigureActivity : BaseActivity() { setContent { HATheme { - ButtonWidgetConfigureScreen(viewModel) + ButtonWidgetConfigureScreen(viewModel, { onAddWidgetClicked() }) } } } + + private fun onAddWidgetClicked() { + lifecycleScope.launch { + if (intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ) { + requestPinWidget() + } else { + showAddWidgetError() + } + } else { + onUpdateWidget() + } + } + } + + @SuppressLint("ObsoleteSdkInt") + @RequiresApi(Build.VERSION_CODES.O) + private fun requestPinWidget() { + val context = this@ButtonWidgetConfigureActivity + lifecycleScope.launch { + viewModel.requestWidgetCreation(context) + finish() + } + } + + private suspend fun onUpdateWidget() { + try { + viewModel.updateWidgetConfiguration() + setResult(RESULT_OK) + viewModel.updateWidget(this@ButtonWidgetConfigureActivity) + finish() + } catch (_: Exception) { + showUpdateWidgetError() + } + } + + private fun showAddWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() + } + + private fun showUpdateWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_update_error, Toast.LENGTH_LONG).show() + } } @Composable -private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { +private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddWidgetClicked: () -> Unit) { val state by viewModel.uiState.collectAsStateWithLifecycle(ButtonWidgetUiState()) ButtonWidgetConfigureView( @@ -147,6 +191,7 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel) { onBackgroundTypeSelected = viewModel::updateSelectedBackgroundType, isRequireAuthenticationChecked = state.requiresAuthentication, onRequireAuthenticationChecked = viewModel::setRequiresAuthentication, + onAddWidgetClicked = onAddWidgetClicked ) } @@ -172,6 +217,7 @@ private fun ButtonWidgetConfigureView( onTextColorSelected: (Int) -> Unit, isRequireAuthenticationChecked: Boolean, onRequireAuthenticationChecked: (Boolean) -> Unit, + onAddWidgetClicked: () -> Unit ) { var showAddFieldDialog by remember { mutableStateOf(false) } var showIconDialog by remember { mutableStateOf(false) } @@ -344,7 +390,7 @@ private fun ButtonWidgetConfigureView( } HAAccentButton( text = stringResource(commonR.string.add_widget), - onClick = {}, + onClick = onAddWidgetClicked, modifier = Modifier.align(Alignment.End), ) } @@ -440,7 +486,7 @@ fun WidgetTextColorSelector(textColorIndex: Int, onTextColorSelected: (Int) -> U HADropdownMenu( items = listOf( HADropdownItem(0, stringResource(commonR.string.widget_text_color_black)), - HADropdownItem(1, stringResource(commonR.string.widget_text_color_white)) + HADropdownItem(1, stringResource(commonR.string.widget_text_color_white)), ), selectedKey = textColorIndex, onItemSelected = { onTextColorSelected(it) }, @@ -477,6 +523,7 @@ private fun ButtonWidgetConfigureScreenPreview() { onBackgroundTypeSelected = {}, isRequireAuthenticationChecked = false, onRequireAuthenticationChecked = {}, + onAddWidgetClicked = {} ) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 29b73c97aab..40ee32a40fe 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -1,10 +1,10 @@ package io.homeassistant.companion.android.widgets.button +import android.app.PendingIntent import android.appwidget.AppWidgetManager -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.glance.currentState +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.material.color.DynamicColors @@ -18,19 +18,21 @@ import io.homeassistant.companion.android.common.util.MapAnySerializer import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.util.icondialog.getIconByMdiName import io.homeassistant.companion.android.util.icondialog.mdiName +import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import javax.inject.Inject -import kotlin.collections.indexOf import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -102,6 +104,55 @@ class ButtonWidgetViewModel @Inject constructor( maybeLoadPreviousState(widgetId) } + /** + * Return a [ButtonWidgetEntity] with the current selection, but without pushing this to the [buttonWidgetDao] + */ + private suspend fun getPendingDaoEntity(): ButtonWidgetEntity { + val state = _uiState.value + with (state) { + val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } + val actionText = action + val actions = actions[serverId].orEmpty() + val actionTextParts = actionText.split(".", limit = 2) + val domain = actions[actionText]?.domain ?: actionTextParts.getOrElse(0) { "" } + val action = actions[actionText]?.action ?: actionTextParts.getOrElse(1) { "" } + val actionDataMap = HashMap() + + dynamicFields.forEach { + var value = it.value + if (value != null) { + if (it.field == "entity_id" && value is String) { + // Remove trailing commas and spaces + val trailingRegex = "[, ]+$".toRegex() + value = value.replace(trailingRegex, "") + } + actionDataMap[it.field] = value + } + } + + return ButtonWidgetEntity( + id = widgetId, + serverId = serverId, + domain = domain, + service = action, + label = label, + iconName = selectedIcon.mdiName, + serviceData = kotlinJsonMapper.encodeToString(MapAnySerializer, actionDataMap), + backgroundType = selectedBackgroundType, + textColor = supportedTextColors[textColorIndex], + requireAuthentication = requiresAuthentication, + ) + } + } + + fun updateWidget(context: Context) { + val appContext = context.applicationContext + viewModelScope.launch { + val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(widgetId) + ButtonGlanceAppWidget().update(appContext, glanceId) + } + } + private fun maybeLoadPreviousState(widgetId: Int) { viewModelScope.launch { buttonWidgetDao.get(widgetId)?.let { widget -> @@ -213,7 +264,7 @@ class ButtonWidgetViewModel @Inject constructor( val dynamicFields = currentState.dynamicFields.toMutableList() dynamicFields[index] = field currentState.copy( - dynamicFields = dynamicFields + dynamicFields = dynamicFields, ) } } @@ -254,7 +305,7 @@ class ButtonWidgetViewModel @Inject constructor( fun setServerActions(actions: List) { _uiState.update { currentState -> currentState.copy( - serverActions = actions + serverActions = actions, ) } } @@ -364,26 +415,29 @@ class ButtonWidgetViewModel @Inject constructor( } setServerActions(validItems) } -// binding.addButton.setOnClickListener { -// if (requestLauncherSetup) { -// val widgetConfigAction = binding.widgetTextConfigService.text.toString() -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && -// selectedServerId != null && -// ( -// widgetConfigAction in actions[selectedServerId].orEmpty().keys || -// widgetConfigAction.split(".", limit = 2).size == 2 -// ) -// ) { -// lifecycleScope.launch { -// requestWidgetCreation() -// } -// } else { -// showAddWidgetError() -// } -// } else { -// lifecycleScope.launch { -// updateWidget() -// } -// } -// } + + suspend fun requestWidgetCreation(context: Context) { + // We drop the first value since we only care about knowing when the widget is actually added + buttonWidgetDao.getWidgetCountFlow().drop(1).onStart { + GlanceAppWidgetManager(context) + .requestPinGlanceAppWidget( + ButtonWidget::class.java, + successCallback = PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + Intent(context, ButtonWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, getPendingDaoEntity()) + }, + // We need the PendingIntent to be mutable so the system inject the EXTRA_APPWIDGET_ID of the created widget + PendingIntent.FLAG_MUTABLE, + ), + ) + }.first() + } + + suspend fun updateWidgetConfiguration() { + val entity = getPendingDaoEntity() + buttonWidgetDao.add(entity) + } } From 6cd5bee0b243ee37c111c5aa943502163b509ae2 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Mon, 18 May 2026 20:38:22 -0700 Subject: [PATCH 09/14] Move Action Input Field to it's own Composable --- .../button/ButtonWidgetConfigureActivity.kt | 206 +++++++++++------- 1 file changed, 122 insertions(+), 84 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index d6e482aa357..e322c9e59ee 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -60,6 +60,7 @@ import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.compose.composable.HAAccentButton import io.homeassistant.companion.android.common.compose.composable.HADropdownItem import io.homeassistant.companion.android.common.compose.composable.HADropdownMenu +import io.homeassistant.companion.android.common.compose.theme.HATextStyle import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.database.server.Server @@ -191,11 +192,10 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddW onBackgroundTypeSelected = viewModel::updateSelectedBackgroundType, isRequireAuthenticationChecked = state.requiresAuthentication, onRequireAuthenticationChecked = viewModel::setRequiresAuthentication, - onAddWidgetClicked = onAddWidgetClicked + onAddWidgetClicked = onAddWidgetClicked, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ButtonWidgetConfigureView( action: String, @@ -217,11 +217,10 @@ private fun ButtonWidgetConfigureView( onTextColorSelected: (Int) -> Unit, isRequireAuthenticationChecked: Boolean, onRequireAuthenticationChecked: (Boolean) -> Unit, - onAddWidgetClicked: () -> Unit + onAddWidgetClicked: () -> Unit, ) { var showAddFieldDialog by remember { mutableStateOf(false) } var showIconDialog by remember { mutableStateOf(false) } - var isActionDropdownExpanded by remember { mutableStateOf(false) } Timber.i("Selected Server: $selectedServerId") Scaffold( topBar = { @@ -282,61 +281,11 @@ private fun ButtonWidgetConfigureView( ) } - Box { - val actionFieldState = rememberTextFieldState(initialText = "") - LaunchedEffect(Unit) { - snapshotFlow { actionFieldState.text.toString() }.collectLatest { - onActionTextUpdated(it) - } - } - OutlinedTextField( - label = { Text(text = stringResource(commonR.string.label_action)) }, - state = actionFieldState, - lineLimits = TextFieldLineLimits.SingleLine, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { state -> - isActionDropdownExpanded = state.hasFocus - }, - ) - if (serverActions.isNotEmpty()) { - DropdownMenu( - expanded = isActionDropdownExpanded, - onDismissRequest = { - isActionDropdownExpanded = false - }, - properties = PopupProperties(focusable = false), - ) { - serverActions.forEach { action -> - val text = "${action.domain}.${action.action}" - DropdownMenuItem( - text = { Text(text = text) }, - onClick = { - actionFieldState.setTextAndPlaceCursorAtEnd(text) - isActionDropdownExpanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } - } + ActionTextFieldInput(action, serverActions, onActionTextUpdated) if (dynamicFields.isNotEmpty()) { - dynamicFields.forEachIndexed { index, field -> - val fieldInputState = rememberTextFieldState() - LaunchedEffect(Unit) { - snapshotFlow { fieldInputState.text.toString() }.collectLatest { - onDynamicFieldUpdated(index, field) - } - } - OutlinedTextField( - label = { Text(text = field.field) }, - state = fieldInputState, - lineLimits = TextFieldLineLimits.SingleLine, - modifier = Modifier - .fillMaxWidth(), - ) + dynamicFields.forEachIndexed { index, fieldBinder -> + DynamicFieldInput(index, fieldBinder, onDynamicFieldUpdated) } } @@ -346,30 +295,18 @@ private fun ButtonWidgetConfigureView( modifier = Modifier.align(Alignment.End), ) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(commonR.string.label_icon)) - IconButton( - onClick = { - showIconDialog = true - }, - modifier = Modifier.size(24.dp), - ) { - com.mikepenz.iconics.compose.Image( - asset = icon, - contentDescription = null, - colorFilter = ColorFilter.tint(colorResource(commonR.color.colorIcon)), - ) - } - } + IconSelector( + icon, + { + showIconDialog = true + }, + ) OutlinedTextField( label = { Text(text = stringResource(commonR.string.label)) }, value = label, onValueChange = onLabelUpdated, + textStyle = HATextStyle.UserInput, singleLine = true, modifier = Modifier .fillMaxWidth(), @@ -381,13 +318,8 @@ private fun ButtonWidgetConfigureView( WidgetTextColorSelector(textColorIndex, onTextColorSelected) } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = isRequireAuthenticationChecked, - onCheckedChange = { onRequireAuthenticationChecked(it) }, - ) - Text(text = stringResource(commonR.string.widget_checkbox_require_authentication)) - } + RequireAuthCheckbox(isChecked = isRequireAuthenticationChecked, onChecked = onRequireAuthenticationChecked) + HAAccentButton( text = stringResource(commonR.string.add_widget), onClick = onAddWidgetClicked, @@ -397,6 +329,112 @@ private fun ButtonWidgetConfigureView( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionTextFieldInput( + action: String, + serverActions: List, + onActionTextUpdated: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var isActionDropdownExpanded by remember { mutableStateOf(false) } + val actionFieldState = rememberTextFieldState(initialText = action) + Box { + LaunchedEffect(Unit) { + snapshotFlow { actionFieldState.text.toString() }.collectLatest { + onActionTextUpdated(it) + } + } + OutlinedTextField( + label = { Text(text = stringResource(commonR.string.label_action)) }, + state = actionFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = HATextStyle.UserInput, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { state -> + isActionDropdownExpanded = state.hasFocus + }, + ) + if (serverActions.isNotEmpty()) { + DropdownMenu( + expanded = isActionDropdownExpanded, + onDismissRequest = { + isActionDropdownExpanded = false + }, + properties = PopupProperties(focusable = false), + ) { + serverActions.forEach { action -> + val text = "${action.domain}.${action.action}" + DropdownMenuItem( + text = { Text(text = text) }, + onClick = { + actionFieldState.setTextAndPlaceCursorAtEnd(text) + isActionDropdownExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} + +@Composable +fun DynamicFieldInput( + index: Int, + field: ActionFieldBinder, + onDynamicFieldUpdated: (Int, ActionFieldBinder) -> Unit, + modifier: Modifier = Modifier, +) { + val fieldInputState = rememberTextFieldState(initialText = field.value as String) + LaunchedEffect(Unit) { + snapshotFlow { fieldInputState.text.toString() }.collectLatest { + onDynamicFieldUpdated(index, field) + } + } + OutlinedTextField( + label = { Text(text = field.field) }, + state = fieldInputState, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = HATextStyle.UserInput, + modifier = Modifier + .fillMaxWidth(), + ) +} + +@Composable +fun IconSelector(icon: IIcon, onIconSelectorClicked: () -> Unit, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(commonR.string.label_icon)) + IconButton( + onClick = onIconSelectorClicked, + modifier = Modifier.size(24.dp), + ) { + com.mikepenz.iconics.compose.Image( + asset = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(colorResource(commonR.color.colorIcon)), + ) + } + } +} + +@Composable +fun RequireAuthCheckbox(isChecked: Boolean, onChecked: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isChecked, + onCheckedChange = { onChecked(it) }, + ) + Text(text = stringResource(commonR.string.widget_checkbox_require_authentication)) + } +} + @Composable fun AddFieldDialog( action: String, @@ -523,7 +561,7 @@ private fun ButtonWidgetConfigureScreenPreview() { onBackgroundTypeSelected = {}, isRequireAuthenticationChecked = false, onRequireAuthenticationChecked = {}, - onAddWidgetClicked = {} + onAddWidgetClicked = {}, ) } } From fb7af53c7ca97ce914a6bde5074efefe037b1e06 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 22 May 2026 13:48:41 -0700 Subject: [PATCH 10/14] Fix Action Text not being restored when updating widget --- .../button/ButtonWidgetConfigureActivity.kt | 56 +++--- .../widgets/button/ButtonWidgetViewModel.kt | 185 ++++++++---------- 2 files changed, 107 insertions(+), 134 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index e322c9e59ee..d970cf2ffcc 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -47,6 +48,8 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties @@ -107,14 +110,14 @@ class ButtonWidgetConfigureActivity : BaseActivity() { false, ) ?: false - viewModel.onSetup(appWidgetId, requestLauncherSetup, supportedTextColors) - // If this activity was started with an intent without an app widget ID, finish with an error. if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { finish() return } + viewModel.onSetup(appWidgetId, requestLauncherSetup, supportedTextColors) + setContent { HATheme { ButtonWidgetConfigureScreen(viewModel, { onAddWidgetClicked() }) @@ -170,11 +173,15 @@ class ButtonWidgetConfigureActivity : BaseActivity() { @Composable private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddWidgetClicked: () -> Unit) { +// val state = remember (viewModel) { viewModel.uiState.value } val state by viewModel.uiState.collectAsStateWithLifecycle(ButtonWidgetUiState()) - + LaunchedEffect(viewModel.actionFieldState) { + snapshotFlow { viewModel.actionFieldState.text.toString() }.collectLatest { + viewModel.updateActionText(it) + } + } ButtonWidgetConfigureView( - action = state.action, - onActionTextUpdated = viewModel::updateActionText, + actionFieldState = viewModel.actionFieldState, servers = state.servers, selectedServerId = state.selectedServerId, onServerSelected = viewModel::setServer, @@ -198,14 +205,13 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddW @Composable private fun ButtonWidgetConfigureView( - action: String, - onActionTextUpdated: (String) -> Unit, + actionFieldState: TextFieldState, servers: List, selectedServerId: Int?, onServerSelected: (Int) -> Unit, serverActions: List, dynamicFields: List, - onDynamicFieldUpdated: (Int, ActionFieldBinder) -> Unit, + onDynamicFieldUpdated: (Int, String) -> Unit, icon: IIcon, onIconSelected: (IIcon) -> Unit, onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, @@ -241,14 +247,15 @@ private fun ButtonWidgetConfigureView( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (showAddFieldDialog) { + val actionText = actionFieldState.text as String AddFieldDialog( - action = action, + action = actionText, onCancel = { showAddFieldDialog = false }, onOk = { actionField -> if (dynamicFields.any { - it.field == action + it.field == actionText } ) { showAddFieldDialog = false @@ -281,7 +288,7 @@ private fun ButtonWidgetConfigureView( ) } - ActionTextFieldInput(action, serverActions, onActionTextUpdated) + ActionTextFieldInput(actionFieldState, serverActions) if (dynamicFields.isNotEmpty()) { dynamicFields.forEachIndexed { index, fieldBinder -> @@ -332,25 +339,18 @@ private fun ButtonWidgetConfigureView( @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionTextFieldInput( - action: String, + actionFieldState: TextFieldState, serverActions: List, - onActionTextUpdated: (String) -> Unit, modifier: Modifier = Modifier, ) { var isActionDropdownExpanded by remember { mutableStateOf(false) } - val actionFieldState = rememberTextFieldState(initialText = action) Box { - LaunchedEffect(Unit) { - snapshotFlow { actionFieldState.text.toString() }.collectLatest { - onActionTextUpdated(it) - } - } OutlinedTextField( label = { Text(text = stringResource(commonR.string.label_action)) }, state = actionFieldState, lineLimits = TextFieldLineLimits.SingleLine, textStyle = HATextStyle.UserInput, - modifier = Modifier + modifier = modifier .fillMaxWidth() .onFocusChanged { state -> isActionDropdownExpanded = state.hasFocus @@ -384,13 +384,14 @@ fun ActionTextFieldInput( fun DynamicFieldInput( index: Int, field: ActionFieldBinder, - onDynamicFieldUpdated: (Int, ActionFieldBinder) -> Unit, + onDynamicFieldUpdated: (Int, String) -> Unit, modifier: Modifier = Modifier, ) { - val fieldInputState = rememberTextFieldState(initialText = field.value as String) - LaunchedEffect(Unit) { + val initialText = field.value as? String + val fieldInputState = rememberTextFieldState(initialText = initialText ?: "") + LaunchedEffect(fieldInputState) { snapshotFlow { fieldInputState.text.toString() }.collectLatest { - onDynamicFieldUpdated(index, field) + onDynamicFieldUpdated(index, it) } } OutlinedTextField( @@ -398,7 +399,7 @@ fun DynamicFieldInput( state = fieldInputState, lineLimits = TextFieldLineLimits.SingleLine, textStyle = HATextStyle.UserInput, - modifier = Modifier + modifier = modifier .fillMaxWidth(), ) } @@ -539,8 +540,7 @@ fun WidgetTextColorSelector(textColorIndex: Int, onTextColorSelected: (Int) -> U private fun ButtonWidgetConfigureScreenPreview() { HATheme { ButtonWidgetConfigureView( - action = "", - onActionTextUpdated = {}, + actionFieldState = rememberTextFieldState(), servers = listOf( previewServer1, previewServer2, @@ -549,7 +549,7 @@ private fun ButtonWidgetConfigureScreenPreview() { onServerSelected = {}, serverActions = emptyList(), dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), - onDynamicFieldUpdated = { i: Int, binder: ActionFieldBinder -> }, + onDynamicFieldUpdated = { i: Int, value: String -> }, icon = CommunityMaterial.Icon2.cmd_flash, onIconSelected = {}, label = "", diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 40ee32a40fe..6f77644b647 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -4,7 +4,11 @@ import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.currentState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.material.color.DynamicColors @@ -46,7 +50,6 @@ class ButtonWidgetViewModel @Inject constructor( ) : ViewModel() { data class ButtonWidgetUiState( - val action: String = "", val selectedServerId: Int? = ServerManager.SERVER_ID_ACTIVE, val servers: List = emptyList(), val serverActions: List = emptyList(), @@ -63,6 +66,8 @@ class ButtonWidgetViewModel @Inject constructor( val requiresAuthentication: Boolean = false, ) + val actionFieldState: TextFieldState = TextFieldState() + private val _uiState: MutableStateFlow = MutableStateFlow(ButtonWidgetUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -78,40 +83,28 @@ class ButtonWidgetViewModel @Inject constructor( private var ongoingJob: Job? = null - init { + fun onSetup(widgetId: Int, requestLauncherSetup: Boolean, supportedTextColors: List) { + this.requestLauncherSetup = requestLauncherSetup + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId viewModelScope.launch { - _uiState.update { currentState -> - currentState.copy( - servers = serverManager.servers(), - selectedServerId = serverManager.getServer()?.id, - ) - } + updateUiState(servers = serverManager.servers(), selectedServerId = serverManager.getServer()?.id) for (server in serverManager.servers()) { - launch { getActionsFromServer(server) - } - launch { getEntitiesFromServer(server) - } } + maybeLoadPreviousState(widgetId) } } - fun onSetup(widgetId: Int, requestLauncherSetup: Boolean, supportedTextColors: List) { - this.requestLauncherSetup = requestLauncherSetup - this.supportedTextColors = supportedTextColors - this.widgetId = widgetId - maybeLoadPreviousState(widgetId) - } - /** * Return a [ButtonWidgetEntity] with the current selection, but without pushing this to the [buttonWidgetDao] */ - private suspend fun getPendingDaoEntity(): ButtonWidgetEntity { + private fun getPendingDaoEntity(): ButtonWidgetEntity { val state = _uiState.value - with (state) { + with(state) { val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } - val actionText = action + val actionText = actionFieldState.text val actions = actions[serverId].orEmpty() val actionTextParts = actionText.split(".", limit = 2) val domain = actions[actionText]?.domain ?: actionTextParts.getOrElse(0) { "" } @@ -153,24 +146,46 @@ class ButtonWidgetViewModel @Inject constructor( } } - private fun maybeLoadPreviousState(widgetId: Int) { - viewModelScope.launch { - buttonWidgetDao.get(widgetId)?.let { widget -> - _uiState.update { currentState -> - val icon = CommunityMaterial.getIconByMdiName(widget.iconName) - val colorIndex = supportedTextColors.indexOf(widget.textColor) - currentState.copy( - action = "${widget.domain}.${widget.service}", - selectedServerId = widget.serverId, - label = widget.label ?: "", - selectedBackgroundType = widget.backgroundType, - selectedIcon = icon ?: CommunityMaterial.Icon2.cmd_flash, - selectedIconId = widget.iconName, - textColorIndex = if (colorIndex == -1) 0 else colorIndex, - requiresAuthentication = widget.requireAuthentication, - ) - } - } + private suspend fun maybeLoadPreviousState(widgetId: Int) { + buttonWidgetDao.get(widgetId)?.let { widget -> + val icon = CommunityMaterial.getIconByMdiName(widget.iconName) + val colorIndex = supportedTextColors.indexOf(widget.textColor) + val action = "${widget.domain}.${widget.service}" + updateActionText(action) + setServer(widget.serverId) + updateLabel(widget.label) + updateSelectedBackgroundType(widget.backgroundType) + selectIcon(icon) + updateTextColorIndex(if (colorIndex == -1) 0 else colorIndex) + setRequiresAuthentication(widget.requireAuthentication) + } + } + + private fun updateUiState( + selectedServerId: Int? = null, + servers: List? = null, + serverActions: List? = null, + dynamicFields: List? = null, + selectedIcon: IIcon? = null, + selectedIconId: String? = null, + label: String? = null, + selectedBackgroundType: WidgetBackgroundType? = null, + textColorIndex: Int? = null, + requiresAuthentication: Boolean? = null, + ) { + _uiState.update { currentState -> + currentState.copy( + selectedServerId = selectedServerId ?: currentState.selectedServerId, + servers = servers ?: currentState.servers, + serverActions = serverActions ?: currentState.serverActions, + dynamicFields = dynamicFields ?: currentState.dynamicFields, + selectedIcon = selectedIcon ?: currentState.selectedIcon, + selectedIconId = selectedIconId ?: currentState.selectedIconId, + label = label ?: currentState.label, + selectedBackgroundType = selectedBackgroundType ?: currentState.selectedBackgroundType, + textColorIndex = textColorIndex ?: currentState.textColorIndex, + requiresAuthentication = requiresAuthentication ?: currentState.requiresAuthentication, + ) } } @@ -204,11 +219,7 @@ class ButtonWidgetViewModel @Inject constructor( } fun updateActionText(newAction: String) { - _uiState.update { currentState -> - currentState.copy( - action = newAction, - ) - } + actionFieldState.setTextAndPlaceCursorAtEnd(newAction) updateActionFields(newAction) filterAdapterActions(newAction) } @@ -217,23 +228,15 @@ class ButtonWidgetViewModel @Inject constructor( return "${action.domain}.${action.action}" } - fun updateLabel(newLabel: String) { - _uiState.update { currentState -> - currentState.copy( - label = newLabel, - ) - } + fun updateLabel(newLabel: String?) { + updateUiState(label = newLabel ?: "") } fun setServer(serverId: Int) { val selectedServerId = _uiState.value.selectedServerId if (selectedServerId == serverId) return - _uiState.update { currentState -> - currentState.copy( - action = "", - selectedServerId = serverId, - ) - } + actionFieldState.clearText() + updateUiState(selectedServerId = serverId) viewModelScope.launch { selectedServerMutex.withLock { setAdapterActions(serverId) @@ -242,72 +245,41 @@ class ButtonWidgetViewModel @Inject constructor( } fun addDynamicField(position: Int, field: ActionFieldBinder) { - _uiState.update { currentState -> - val dynamicFields = currentState.dynamicFields.toMutableList() - dynamicFields.add(position, field) - currentState.copy( - dynamicFields = dynamicFields, - ) - } + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + dynamicFields.add(position, field) + updateDynamicFields(dynamicFields = dynamicFields) } private fun updateDynamicFields(dynamicFields: List) { - _uiState.update { currentState -> - currentState.copy( - dynamicFields = dynamicFields, - ) - } + updateUiState(dynamicFields = dynamicFields) } - fun updateDynamicField(index: Int, field: ActionFieldBinder) { - _uiState.update { currentState -> - val dynamicFields = currentState.dynamicFields.toMutableList() - dynamicFields[index] = field - currentState.copy( - dynamicFields = dynamicFields, - ) - } + fun updateDynamicField(index: Int, value: String) { + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + val field = dynamicFields[index] + field.value = value + dynamicFields[index] = field + updateDynamicFields(dynamicFields = dynamicFields) } - fun selectIcon(icon: IIcon) { - _uiState.update { currentState -> - currentState.copy( - selectedIcon = icon, - selectedIconId = icon.mdiName, - ) - } + fun selectIcon(icon: IIcon?) { + updateUiState(selectedIcon = icon, selectedIconId = icon?.mdiName) } fun updateSelectedBackgroundType(backgroundType: WidgetBackgroundType) { - _uiState.update { currentState -> - currentState.copy( - selectedBackgroundType = backgroundType, - ) - } + updateUiState(selectedBackgroundType = backgroundType) } fun updateTextColorIndex(textColorIndex: Int) { - _uiState.update { currentState -> - currentState.copy( - textColorIndex = textColorIndex, - ) - } + updateUiState(textColorIndex = textColorIndex) } fun setRequiresAuthentication(authenticationRequired: Boolean) { - _uiState.update { currentState -> - currentState.copy( - requiresAuthentication = authenticationRequired, - ) - } + updateUiState(requiresAuthentication = authenticationRequired) } fun setServerActions(actions: List) { - _uiState.update { currentState -> - currentState.copy( - serverActions = actions, - ) - } + updateUiState(serverActions = actions) } fun updateActionFields(actionText: String) { @@ -387,7 +359,6 @@ class ButtonWidgetViewModel @Inject constructor( dynamicFields.clear() } } - Timber.i(dynamicFields.toString()) updateDynamicFields(dynamicFields) } } @@ -406,10 +377,12 @@ class ButtonWidgetViewModel @Inject constructor( } private fun filterAdapterActions(constraint: CharSequence) { + // Split Domain from String + val domain = constraint.split(".")[0] val validItems = ArrayList() for (i in 0 until selectedServerActions.size) { val item = selectedServerActions[i] - if (getActionString(item).contains(constraint)) { + if (getActionString(item).contains(domain)) { validItems.add(item) } } From 8dbd02519c064af0a9880650c3395aaadef688bd Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 22 May 2026 14:43:38 -0700 Subject: [PATCH 11/14] Add Entity Selection to Entity id dynamic field also update Add Button Text if a prior state was loaded --- .../button/ButtonWidgetConfigureActivity.kt | 61 +++++++++++++++---- .../widgets/button/ButtonWidgetViewModel.kt | 34 ++++++++++- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index d970cf2ffcc..94dc5d5f05b 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -48,8 +48,6 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties @@ -66,6 +64,7 @@ import io.homeassistant.companion.android.common.compose.composable.HADropdownMe import io.homeassistant.companion.android.common.compose.theme.HATextStyle import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action +import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel @@ -188,6 +187,7 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddW serverActions = state.serverActions, dynamicFields = state.dynamicFields, onDynamicFieldUpdated = viewModel::updateDynamicField, + domainEntities = state.domainEntities, icon = state.selectedIcon, onIconSelected = viewModel::selectIcon, onAddFieldDialogOkClicked = viewModel::addDynamicField, @@ -199,6 +199,7 @@ private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddW onBackgroundTypeSelected = viewModel::updateSelectedBackgroundType, isRequireAuthenticationChecked = state.requiresAuthentication, onRequireAuthenticationChecked = viewModel::setRequiresAuthentication, + isUpdatingWidget = state.isUpdating ?: false, onAddWidgetClicked = onAddWidgetClicked, ) } @@ -212,6 +213,7 @@ private fun ButtonWidgetConfigureView( serverActions: List, dynamicFields: List, onDynamicFieldUpdated: (Int, String) -> Unit, + domainEntities: List, icon: IIcon, onIconSelected: (IIcon) -> Unit, onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, @@ -223,6 +225,7 @@ private fun ButtonWidgetConfigureView( onTextColorSelected: (Int) -> Unit, isRequireAuthenticationChecked: Boolean, onRequireAuthenticationChecked: (Boolean) -> Unit, + isUpdatingWidget: Boolean, onAddWidgetClicked: () -> Unit, ) { var showAddFieldDialog by remember { mutableStateOf(false) } @@ -292,7 +295,7 @@ private fun ButtonWidgetConfigureView( if (dynamicFields.isNotEmpty()) { dynamicFields.forEachIndexed { index, fieldBinder -> - DynamicFieldInput(index, fieldBinder, onDynamicFieldUpdated) + DynamicFieldInput(index, fieldBinder, domainEntities, onDynamicFieldUpdated) } } @@ -327,8 +330,10 @@ private fun ButtonWidgetConfigureView( RequireAuthCheckbox(isChecked = isRequireAuthenticationChecked, onChecked = onRequireAuthenticationChecked) + val addButtonStringResource = + if (isUpdatingWidget) commonR.string.update_widget else commonR.string.add_widget HAAccentButton( - text = stringResource(commonR.string.add_widget), + text = stringResource(addButtonStringResource), onClick = onAddWidgetClicked, modifier = Modifier.align(Alignment.End), ) @@ -380,13 +385,16 @@ fun ActionTextFieldInput( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DynamicFieldInput( index: Int, field: ActionFieldBinder, + domainEntities: List, onDynamicFieldUpdated: (Int, String) -> Unit, modifier: Modifier = Modifier, ) { + var isEntityDropdownExpanded by remember { mutableStateOf(false) } val initialText = field.value as? String val fieldInputState = rememberTextFieldState(initialText = initialText ?: "") LaunchedEffect(fieldInputState) { @@ -394,14 +402,41 @@ fun DynamicFieldInput( onDynamicFieldUpdated(index, it) } } - OutlinedTextField( - label = { Text(text = field.field) }, - state = fieldInputState, - lineLimits = TextFieldLineLimits.SingleLine, - textStyle = HATextStyle.UserInput, - modifier = modifier - .fillMaxWidth(), - ) + Box { + OutlinedTextField( + label = { Text(text = field.field) }, + state = fieldInputState, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = HATextStyle.UserInput, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { state -> + if (field.field == "entity_id") { + isEntityDropdownExpanded = state.hasFocus + } + }, + ) + if (field.field == "entity_id" && domainEntities.isNotEmpty()) { + DropdownMenu( + expanded = isEntityDropdownExpanded, + onDismissRequest = { + isEntityDropdownExpanded = false + }, + properties = PopupProperties(focusable = false), + ) { + domainEntities.forEach { entity -> + DropdownMenuItem( + text = { Text(text = entity) }, + onClick = { + fieldInputState.setTextAndPlaceCursorAtEnd("${fieldInputState.text} $entity, ") + isEntityDropdownExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } } @Composable @@ -550,6 +585,7 @@ private fun ButtonWidgetConfigureScreenPreview() { serverActions = emptyList(), dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), onDynamicFieldUpdated = { i: Int, value: String -> }, + domainEntities = emptyList(), icon = CommunityMaterial.Icon2.cmd_flash, onIconSelected = {}, label = "", @@ -561,6 +597,7 @@ private fun ButtonWidgetConfigureScreenPreview() { onBackgroundTypeSelected = {}, isRequireAuthenticationChecked = false, onRequireAuthenticationChecked = {}, + isUpdatingWidget = false, onAddWidgetClicked = {}, ) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt index 6f77644b647..1adb4b3a499 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -4,9 +4,12 @@ import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent +import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.ui.unit.min +import androidx.glance.action.action import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.currentState import androidx.lifecycle.ViewModel @@ -54,6 +57,7 @@ class ButtonWidgetViewModel @Inject constructor( val servers: List = emptyList(), val serverActions: List = emptyList(), val dynamicFields: List = emptyList(), + val domainEntities: List = emptyList(), val selectedIcon: IIcon = CommunityMaterial.Icon2.cmd_flash, val selectedIconId: String? = null, val label: String = "", @@ -64,6 +68,7 @@ class ButtonWidgetViewModel @Inject constructor( }, val textColorIndex: Int = 0, val requiresAuthentication: Boolean = false, + val isUpdating: Boolean? = false, ) val actionFieldState: TextFieldState = TextFieldState() @@ -158,6 +163,7 @@ class ButtonWidgetViewModel @Inject constructor( selectIcon(icon) updateTextColorIndex(if (colorIndex == -1) 0 else colorIndex) setRequiresAuthentication(widget.requireAuthentication) + updateUiState(isUpdating = true) } } @@ -166,12 +172,14 @@ class ButtonWidgetViewModel @Inject constructor( servers: List? = null, serverActions: List? = null, dynamicFields: List? = null, + domainEntities: List? = null, selectedIcon: IIcon? = null, selectedIconId: String? = null, label: String? = null, selectedBackgroundType: WidgetBackgroundType? = null, textColorIndex: Int? = null, requiresAuthentication: Boolean? = null, + isUpdating: Boolean? = null ) { _uiState.update { currentState -> currentState.copy( @@ -179,12 +187,14 @@ class ButtonWidgetViewModel @Inject constructor( servers = servers ?: currentState.servers, serverActions = serverActions ?: currentState.serverActions, dynamicFields = dynamicFields ?: currentState.dynamicFields, + domainEntities = domainEntities ?: currentState.domainEntities, selectedIcon = selectedIcon ?: currentState.selectedIcon, selectedIconId = selectedIconId ?: currentState.selectedIconId, label = label ?: currentState.label, selectedBackgroundType = selectedBackgroundType ?: currentState.selectedBackgroundType, textColorIndex = textColorIndex ?: currentState.textColorIndex, requiresAuthentication = requiresAuthentication ?: currentState.requiresAuthentication, + isUpdating = isUpdating ?: currentState.isUpdating ) } } @@ -321,7 +331,8 @@ class ButtonWidgetViewModel @Inject constructor( val value = item.value.toString().replace("[", "").replace("]", "") + if (item.key == "entity_id") ", " else "" - existingActionData[item.key] = value.ifEmpty { null } + // Ignore if value is just a comma + existingActionData[item.key] = if (value.isEmpty() || value.trim() == ",") { null } else value addedFields.add(item.key) } } @@ -331,6 +342,7 @@ class ButtonWidgetViewModel @Inject constructor( 0, ActionFieldBinder(actionText, "entity_id", existingActionData["entity_id"]), ) + setEntitiesForAction() } fieldKeys.sorted().forEach { fieldKey -> @@ -389,6 +401,26 @@ class ButtonWidgetViewModel @Inject constructor( setServerActions(validItems) } + private fun setEntitiesForAction() { + val actionText = actionFieldState.text + val selectedServerId = _uiState.value.selectedServerId + val entityMap = entities[selectedServerId] ?: return + val domain = actions[selectedServerId]?.get(actionText)?.domain + + val domainEntities = mutableListOf("all") + + if (domain == ("homeassistant") || domain == null) { + domainEntities.addAll(entityMap.keys) + } else { + entityMap.keys.forEach { + if (it.startsWith(domain) || it.startsWith("group")) { + domainEntities.add(it) + } + } + } + updateUiState(domainEntities = domainEntities) + } + suspend fun requestWidgetCreation(context: Context) { // We drop the first value since we only care about knowing when the widget is actually added buttonWidgetDao.getWidgetCountFlow().drop(1).onStart { From 97b104c9c5fcd7f09bdeb19790a1e581767da70f Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 22 May 2026 14:44:24 -0700 Subject: [PATCH 12/14] Update UI and add Tap action to Button Widget --- .../android/HomeAssistantApplication.kt | 3 +- .../widgets/button/ButtonGlanceAppWidget.kt | 178 +++++++++++++++++- .../android/widgets/button/ButtonWidget.kt | 90 ++++++++- .../button/ButtonWidgetStateUpdater.kt | 34 ++++ .../database/widget/ButtonWidgetDao.kt | 3 + 5 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt index c19123d38d4..3c612057b34 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -345,7 +345,7 @@ open class HomeAssistantApplication : 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() @@ -355,7 +355,6 @@ open class HomeAssistantApplication : 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, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt index 6872f8d38fd..6d03b03e919 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt @@ -1,16 +1,188 @@ package io.homeassistant.companion.android.widgets.button +import android.appwidget.AppWidgetManager import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +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.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.toColorInt import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionSendBroadcast +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.color.ColorProviders +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.material.ColorProviders import androidx.glance.text.Text -import io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver +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.database.widget.ButtonWidgetEntity +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.util.icondialog.getIconByMdiName +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 timber.log.Timber + +private val authKey = ActionParameters.Key("auth") +private val widgetIdKey = ActionParameters.Key("widgetId") +class ButtonGlanceAppWidget : GlanceAppWidget() { + @EntryPoint + @InstallIn(SingletonComponent::class) + internal interface ButtonGlanceWidgetEntryPoint { + fun buttonStateUpdater(): ButtonWidgetStateUpdater + } -class ButtonGlanceAppWidget: GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { + val manager = GlanceAppWidgetManager(context) + val widgetId = manager.getAppWidgetId(id) + provideContent { - Text("Hello, pearl") + val entryPoints = remember { EntryPoints.get(context, ButtonGlanceWidgetEntryPoint::class.java) } + val flow = remember { entryPoints.buttonStateUpdater().getButtonEntityFlow(widgetId) } + + val state by flow.collectAsState(null) + + Timber.i("GlanceAppWidget $state") + HomeAssistantGlanceTheme(colors = getWidgetColors(state?.backgroundType, state?.textColor)) { + ButtonScreen(context, state, DEFAULT_MAX_ICON_SIZE) + } + } + } +} + +@Composable +private fun GlanceModifier.buttonWidgetBackground(): GlanceModifier { + return this.appWidgetBackground().fillMaxSize().background( + GlanceTheme + .colors.widgetBackground, + ) +} + +@Composable +fun ButtonScreen(context: Context, state: ButtonWidgetEntity?, maxIconSize: Int, modifier: Modifier = Modifier) { + val iconData = state?.iconName?.let { CommunityMaterial.getIconByMdiName(it) } + ?: CommunityMaterial.Icon2.cmd_flash // Lightning Bolt + val iconDrawable = IconicsDrawable(context, iconData).apply { + padding = IconicsSize.dp(2) + size = IconicsSize.dp(24) + } + + // 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 ?: 0) 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, height) + + val requiresAuth = state?.requireAuthentication + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxSize().buttonWidgetBackground() + .clickable(onClick = actionRunCallback( + actionParametersOf( + authKey to (requiresAuth == true), + widgetIdKey to (state?.id ?: -1) + ) + )), + ) { + Image( + provider = ImageProvider(icon), + contentDescription = null, + ) + state?.label?.let { + Text(text = it, style = HomeAssistantGlanceTypography.bodySmall) + } + } +} + +@Composable +fun getWidgetColors( + backgroundType: WidgetBackgroundType?, + textColor: String?, + modifier: Modifier = Modifier, +): 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, + ) { + 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) } + Timber.i("Action Running, Id: $widgetId, auth: $auth") + context.sendBroadcast(intent) + ButtonGlanceAppWidget().update(context, glanceId) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt index 161996ddbc3..5afde9cd1af 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt @@ -6,6 +6,7 @@ import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.Color import android.view.View import android.widget.RemoteViews @@ -60,14 +61,99 @@ class ButtonWidget : BaseGlanceEntityWidgetReceiver authThenCallConfiguredAction(context, appWidgetId) + CALL_SERVICE -> widgetScope.launch { callConfiguredAction(context, appWidgetId) } + } + } + + + private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int) { + Timber.d("Calling authentication, then configured action") + + val intent = Intent(context, WidgetAuthenticationActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + + private suspend fun callConfiguredAction(context: Context, appWidgetId: Int) { + Timber.d("Calling widget action") + val widget = dao.get(appWidgetId) + + val domain = widget?.domain + val action = widget?.service + val actionDataJson = widget?.serviceData + + + Timber.d( + "Action Call Data loaded:" + System.lineSeparator() + + "domain: " + domain + System.lineSeparator() + + "action: " + action + System.lineSeparator() + + "action_data: " + actionDataJson, + ) + + if (domain == null || action == null || actionDataJson == null) { + Timber.w("Action Call Data incomplete. Aborting action call") + } else { + // If everything loaded correctly, package the action data and attempt the call + try { + // Convert JSON to HashMap + val actionDataMap = kotlinJsonMapper.decodeFromString>( + MapAnySerializer, + actionDataJson, + ).toMutableMap() + + if (actionDataMap["entity_id"] != null) { + val entityIdWithoutBrackets = Pattern.compile("\\[(.*?)\\]") + .matcher(actionDataMap["entity_id"].toString()) + if (entityIdWithoutBrackets.find()) { + val value = entityIdWithoutBrackets.group(1) + if (value != null) { + if (value == "all" || + value.split(",").contains("all") + ) { + actionDataMap["entity_id"] = "all" + } + } + } + } + + Timber.d("Sending action call to Home Assistant") + serverManager.integrationRepository(widget.serverId).callAction(domain, action, actionDataMap) + Timber.d("Action call sent successfully") + + // If action call does not throw an exception, send positive feedback +// feedbackColor = R.drawable.widget_button_background_green +// feedbackIcon = R.drawable.ic_check_black_24dp + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Could not send action call.") + withContext(Dispatchers.Main) { + Toast.makeText(context, commonR.string.action_failure, Toast.LENGTH_LONG).show() + } + } + } + } + companion object { const val CALL_SERVICE = "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE" - private const val CALL_SERVICE_AUTH = + const val CALL_SERVICE_AUTH = "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE_AUTH" // Vector icon rendering resolution fallback (if we can't infer via AppWidgetManager for some reason) - private const val DEFAULT_MAX_ICON_SIZE = 512 + const val DEFAULT_MAX_ICON_SIZE = 512 private var widgetScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt new file mode 100644 index 00000000000..64315238739 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt @@ -0,0 +1,34 @@ +package io.homeassistant.companion.android.widgets.button + +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity +import io.homeassistant.companion.android.database.widget.TodoWidgetEntity +import io.homeassistant.companion.android.widgets.todo.EmptyTodoState +import io.homeassistant.companion.android.widgets.todo.TodoState +import io.homeassistant.companion.android.widgets.todo.TodoStateWithData +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import timber.log.Timber + +class ButtonWidgetStateUpdater @Inject constructor( + val buttonWidgetDao: ButtonWidgetDao, +) { + + fun getButtonEntityFlow(widgetId: Int): Flow { + return buttonWidgetDao.getFlow(widgetId) + } +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt index f64f8ab43c4..feb1bed38fe 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt @@ -11,6 +11,9 @@ interface ButtonWidgetDao : WidgetDao { @Query("SELECT * FROM button_widgets WHERE id = :id") suspend fun get(id: Int): ButtonWidgetEntity? + @Query("SELECT * FROM button_widgets WHERE id = :id") + fun getFlow(id: Int): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun add(entity: ButtonWidgetEntity) From 3802f244f7a2d7db5d8cca6eebce8ad7caf778f4 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 12 Jun 2026 15:08:54 -0700 Subject: [PATCH 13/14] Update App Widget State on tap --- .../widgets/button/ButtonGlanceAppWidget.kt | 114 ++++++++++++++---- .../android/widgets/button/ButtonWidget.kt | 66 +++++----- .../button/ButtonWidgetConfigureActivity.kt | 1 - .../widgets/button/ButtonWidgetState.kt | 75 ++++++++++++ .../button/ButtonWidgetStateUpdater.kt | 49 +++++--- 5 files changed, 229 insertions(+), 76 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt index 6d03b03e919..da01490fd5b 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt @@ -4,37 +4,49 @@ 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue 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.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.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.appwidget.action.actionSendBroadcast -import androidx.glance.appwidget.action.actionStartActivity 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.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.size import androidx.glance.material.ColorProviders +import androidx.glance.semantics.semantics +import androidx.glance.semantics.testTag import androidx.glance.text.Text import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsSize @@ -45,7 +57,6 @@ import dagger.hilt.EntryPoint import dagger.hilt.EntryPoints import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTypography @@ -54,35 +65,86 @@ import io.homeassistant.companion.android.util.icondialog.getIconByMdiName 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 kotlinx.coroutines.launch import timber.log.Timber private val authKey = ActionParameters.Key("auth") private val widgetIdKey = ActionParameters.Key("widgetId") -class ButtonGlanceAppWidget : GlanceAppWidget() { + +class ButtonGlanceAppWidget() : GlanceAppWidget() { @EntryPoint @InstallIn(SingletonComponent::class) internal interface ButtonGlanceWidgetEntryPoint { fun buttonStateUpdater(): ButtonWidgetStateUpdater } + internal val isActionRunningKey = booleanPreferencesKey(IS_LOADING_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 entryPoints = remember { EntryPoints.get(context, ButtonGlanceWidgetEntryPoint::class.java) } - val flow = remember { entryPoints.buttonStateUpdater().getButtonEntityFlow(widgetId) } + val updater = remember { entryPoints.buttonStateUpdater() } + + LaunchedEffect(widgetId, isActionRunning) { + Timber.i("Running Launched Effect") + updater.updateIsActionRunning(widgetId, isActionRunning) + } - val state by flow.collectAsState(null) + 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)) { - ButtonScreen(context, state, DEFAULT_MAX_ICON_SIZE) + ScreenForState( + context = context, + state = state, + maxIconSize = DEFAULT_MAX_ICON_SIZE, + ) } } } } +@Composable +fun ScreenForState( + context: Context, + state: ButtonWidgetState?, + maxIconSize: Int, + modifier: Modifier = Modifier, +) { + when (state) { + Loading -> LoadingScreen() + is ButtonStateWithData -> ButtonScreen( + context = context, + state = state, + maxIconSize = maxIconSize, + ) + + else -> {} + } +} + +@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), + ) + } +} + @Composable private fun GlanceModifier.buttonWidgetBackground(): GlanceModifier { return this.appWidgetBackground().fillMaxSize().background( @@ -92,17 +154,23 @@ private fun GlanceModifier.buttonWidgetBackground(): GlanceModifier { } @Composable -fun ButtonScreen(context: Context, state: ButtonWidgetEntity?, maxIconSize: Int, modifier: Modifier = Modifier) { - val iconData = state?.iconName?.let { CommunityMaterial.getIconByMdiName(it) } +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 ?: 0) else null + val awo = if (state != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(state.id) else null val maxWidth = ( awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxIconSize) ?: maxIconSize @@ -121,24 +189,27 @@ fun ButtonScreen(context: Context, state: ButtonWidgetEntity?, maxIconSize: Int, height = maxHeight } - val icon = DrawableCompat.wrap(iconDrawable).toBitmap(width, height) + val icon = DrawableCompat.wrap(iconDrawable).toBitmap(width = width, height = height) - val requiresAuth = state?.requireAuthentication + val requiresAuth = state?.requiresAuthentication Column( horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxSize().buttonWidgetBackground() - .clickable(onClick = actionRunCallback( - actionParametersOf( - authKey to (requiresAuth == true), - widgetIdKey to (state?.id ?: -1) - ) - )), + .clickable( + onClick = actionRunCallback( + actionParametersOf( + authKey to (requiresAuth == true), + widgetIdKey to (state?.id ?: -1), + ), + ), + ), ) { Image( provider = ImageProvider(icon), contentDescription = null, + modifier = GlanceModifier.height(20.dp), ) state?.label?.let { Text(text = it, style = HomeAssistantGlanceTypography.bodySmall) @@ -150,7 +221,6 @@ fun ButtonScreen(context: Context, state: ButtonWidgetEntity?, maxIconSize: Int, fun getWidgetColors( backgroundType: WidgetBackgroundType?, textColor: String?, - modifier: Modifier = Modifier, ): ColorProviders { return when (backgroundType) { WidgetBackgroundType.DYNAMICCOLOR -> GlanceTheme.colors @@ -175,14 +245,16 @@ class TapAction : ActionCallback { 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) } - Timber.i("Action Running, Id: $widgetId, auth: $auth") context.sendBroadcast(intent) - ButtonGlanceAppWidget().update(context, glanceId) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt index 5afde9cd1af..12698aa30b6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt @@ -1,48 +1,24 @@ package io.homeassistant.companion.android.widgets.button -import android.app.PendingIntent import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.graphics.Color -import android.view.View -import android.widget.RemoteViews import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.toColorInt -import androidx.core.os.BundleCompat +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey import androidx.glance.appwidget.GlanceAppWidget -import com.google.android.material.color.DynamicColors -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 androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.common.util.FailFast import io.homeassistant.companion.android.common.util.MapAnySerializer import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity -import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.util.getAttribute -import io.homeassistant.companion.android.util.icondialog.getIconByMdiName -import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED import io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver -import io.homeassistant.companion.android.widgets.BaseWidgetProvider -import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY import io.homeassistant.companion.android.widgets.EntitiesPerServer import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity import java.util.regex.Pattern -import javax.inject.Inject import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -64,11 +40,12 @@ class ButtonWidget : BaseGlanceEntityWidgetReceiver Unit) { -// val state = remember (viewModel) { viewModel.uiState.value } val state by viewModel.uiState.collectAsStateWithLifecycle(ButtonWidgetUiState()) LaunchedEffect(viewModel.actionFieldState) { snapshotFlow { viewModel.actionFieldState.text.toString() }.collectLatest { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt new file mode 100644 index 00000000000..20118bb96df --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt @@ -0,0 +1,75 @@ +package io.homeassistant.companion.android.widgets.button + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.toColorInt +import androidx.glance.GlanceTheme +import androidx.glance.color.ColorProviders +import androidx.glance.material.ColorProviders +import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme +import io.homeassistant.companion.android.util.compose.glanceHaLightColors + +sealed interface ButtonWidgetState { + val backgroundType: WidgetBackgroundType + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + } + val textColor: String? + get() = null + + companion object { + @Composable + fun ButtonWidgetState.getColors(): 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(), + ), + ), + ) + } + } + } +} + +internal object Loading : ButtonWidgetState +internal object Error : ButtonWidgetState + +internal data class ButtonStateWithData( + override val backgroundType: WidgetBackgroundType, + override val textColor: String?, + val id: Int, + val label: String?, + val icon: String, + val requiresAuthentication: Boolean +) : ButtonWidgetState { + + companion object { + /** + * Create a complete [ButtonStateWithData] from the DB + */ + fun from( + buttonEntity: ButtonWidgetEntity, + ): ButtonStateWithData { + return ButtonStateWithData( + id = buttonEntity.id, + backgroundType = buttonEntity.backgroundType, + textColor = buttonEntity.textColor, + label = buttonEntity.label, + icon = buttonEntity.iconName, + requiresAuthentication = buttonEntity.requireAuthentication + ) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt index 64315238739..9ba2f751dd4 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt @@ -1,34 +1,47 @@ package io.homeassistant.companion.android.widgets.button -import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository import io.homeassistant.companion.android.database.widget.ButtonWidgetDao -import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity -import io.homeassistant.companion.android.database.widget.TodoWidgetEntity -import io.homeassistant.companion.android.widgets.todo.EmptyTodoState -import io.homeassistant.companion.android.widgets.todo.TodoState -import io.homeassistant.companion.android.widgets.todo.TodoStateWithData import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.mapLatest import timber.log.Timber +@Singleton class ButtonWidgetStateUpdater @Inject constructor( val buttonWidgetDao: ButtonWidgetDao, ) { - fun getButtonEntityFlow(widgetId: Int): Flow { - return buttonWidgetDao.getFlow(widgetId) + private val isActionRunning = MutableStateFlow>(emptyMap()) + + @OptIn(ExperimentalCoroutinesApi::class) + fun getButtonEntityFlow(widgetId: Int): Flow { + val runningFlow = isActionRunning + .map { it[widgetId] ?: false } + .distinctUntilChanged() + + return combine(runningFlow, buttonWidgetDao.getFlow(widgetId)) { running, entity -> + running to entity + }.mapLatest { (running, entity) -> + Timber.i("Widget $widgetId is running: $running") + if (running) { + Loading + } else if (entity != null) { + ButtonStateWithData.from(entity) + } else { + null + } + } + } + + fun updateIsActionRunning(widgetId: Int, isRunning: Boolean) { + isActionRunning.value = isActionRunning.value.toMutableMap().apply { + put(widgetId, isRunning) + } } } From a2bceaf72a1e2c81020977357c5ae61b3b308564 Mon Sep 17 00:00:00 2001 From: Brandon Fadairo Date: Fri, 12 Jun 2026 15:39:40 -0700 Subject: [PATCH 14/14] Add States for Error & Success --- .../widgets/button/ButtonGlanceAppWidget.kt | 78 +++++++++++++++++-- .../android/widgets/button/ButtonWidget.kt | 27 +++---- .../widgets/button/ButtonWidgetState.kt | 1 + .../button/ButtonWidgetStateUpdater.kt | 38 ++++++++- 4 files changed, 122 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt index da01490fd5b..a66911b17b4 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt @@ -7,10 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -24,6 +21,8 @@ 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 @@ -45,6 +44,8 @@ import androidx.glance.layout.fillMaxSize import androidx.glance.layout.height 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 @@ -57,16 +58,19 @@ 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.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.util.icondialog.getIconByMdiName +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.SENT_SUCCESSFUL_KEY 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 kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay import timber.log.Timber private val authKey = ActionParameters.Key("auth") @@ -80,6 +84,8 @@ class ButtonGlanceAppWidget() : GlanceAppWidget() { } 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) @@ -87,13 +93,17 @@ class ButtonGlanceAppWidget() : GlanceAppWidget() { 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) { + LaunchedEffect(widgetId, isActionRunning, wasActionSentSuccessful, isActionSuccess) { Timber.i("Running Launched Effect") updater.updateIsActionRunning(widgetId, isActionRunning) + updater.updateIsActionError(widgetId, !wasActionSentSuccessful) + updater.updateIsActionSuccess(widgetId, isActionSuccess) } val flow = remember(widgetId) { updater.getButtonEntityFlow(widgetId) } @@ -103,6 +113,7 @@ class ButtonGlanceAppWidget() : GlanceAppWidget() { Timber.i("GlanceAppWidget $state") HomeAssistantGlanceTheme(colors = getWidgetColors(state?.backgroundType, state?.textColor)) { ScreenForState( + id = id, context = context, state = state, maxIconSize = DEFAULT_MAX_ICON_SIZE, @@ -114,6 +125,7 @@ class ButtonGlanceAppWidget() : GlanceAppWidget() { @Composable fun ScreenForState( + id: GlanceId, context: Context, state: ButtonWidgetState?, maxIconSize: Int, @@ -127,10 +139,57 @@ fun ScreenForState( 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( @@ -258,3 +317,12 @@ class TapAction : ActionCallback { context.sendBroadcast(intent) } } + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview +@Composable +private fun ErrorScreenPreview() { + HomeAssistantGlanceTheme { + ErrorScreen(LocalContext.current, LocalGlanceId.current) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt index 12698aa30b6..4a064e29869 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.widget.Toast import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey +import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState @@ -112,22 +113,13 @@ class ButtonWidget : BaseGlanceEntityWidgetReceiver>(emptyMap()) + private val isActionError = MutableStateFlow>(emptyMap()) + private val isActionSuccess = MutableStateFlow>(emptyMap()) @OptIn(ExperimentalCoroutinesApi::class) fun getButtonEntityFlow(widgetId: Int): Flow { val runningFlow = isActionRunning .map { it[widgetId] ?: false } .distinctUntilChanged() + val errorFlow = isActionError + .map { it[widgetId] ?: false } + .distinctUntilChanged() + val successFlow = isActionSuccess + .map { it[widgetId] ?: false } + .distinctUntilChanged() - return combine(runningFlow, buttonWidgetDao.getFlow(widgetId)) { running, entity -> - running to entity - }.mapLatest { (running, entity) -> - Timber.i("Widget $widgetId is running: $running") + return combine(runningFlow, errorFlow, successFlow, buttonWidgetDao.getFlow(widgetId)) { running, error, success, entity -> + Quartet(running, error, success, entity) + }.mapLatest { (running, error, success, entity) -> + Timber.i("Widget $widgetId is running: $running, error: $error, success: $success") if (running) { Loading + } else if (error) { + Error + } else if (success) { + Success } else if (entity != null) { ButtonStateWithData.from(entity) } else { @@ -44,4 +56,22 @@ class ButtonWidgetStateUpdater @Inject constructor( put(widgetId, isRunning) } } + + fun updateIsActionError(widgetId: Int, isError: Boolean) { + isActionError.value = isActionError.value.toMutableMap().apply { + put(widgetId, isError) + } + } + + fun updateIsActionSuccess(widgetId: Int, isSuccess: Boolean) { + isActionSuccess.value = isActionSuccess.value.toMutableMap().apply { + put(widgetId, isSuccess) + } + } } +data class Quartet( + val first: A, + val second: B, + val third: C, + val fourth: D +)