From 424be49a4577acf5130314c2f60f96633dd354a4 Mon Sep 17 00:00:00 2001 From: amlwin Date: Mon, 16 Feb 2026 11:19:01 +0800 Subject: [PATCH 1/7] Migrate TemplateWidgetConfigure to Compose Migrates the Template Widget configuration screen from XML and Views to Jetpack Compose. This change replaces `TemplateWidgetConfigureActivity`'s XML layout with a new Composable screen, `TemplateWidgetConfigureView`. A new `TemplateWidgetConfigureViewModel` is introduced to manage the screen's state and business logic, including template rendering and widget creation/updating. This also adds unit tests for the new ViewModel to ensure its correctness. --- .idea/kotlinc.xml | 4 - .../TemplateWidgetConfigureActivity.kt | 474 +++++++++++------- .../TemplateWidgetConfigureViewModel.kt | 205 ++++++++ .../res/layout/widget_template_configure.xml | 146 ------ .../TemplateWidgetConfigureViewModelTest.kt | 278 ++++++++++ 5 files changed, 773 insertions(+), 334 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt delete mode 100644 app/src/main/res/layout/widget_template_configure.xml create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index f4c48c298db..172e74f694d 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -6,10 +6,6 @@ - - diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 5e4a746f7cc..a6d2022d8bb 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -1,244 +1,350 @@ package io.homeassistant.companion.android.widgets.template +import android.annotation.SuppressLint +import android.app.PendingIntent import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Spinner -import androidx.core.content.ContextCompat -import androidx.core.graphics.toColorInt -import androidx.core.text.HtmlCompat -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope 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.database.widget.TemplateWidgetDao +import io.homeassistant.companion.android.common.compose.composable.HATextField +import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.databinding.WidgetTemplateConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.applySafeDrawingInsets +import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu +import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat import io.homeassistant.companion.android.util.getHexForColor -import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.common.WidgetUtils -import kotlinx.coroutines.Dispatchers +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.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.UPDATE_WIDGETS +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerializationException -import timber.log.Timber -// TODO Migrate to compose https://github.com/home-assistant/android/issues/6304 @AndroidEntryPoint -class TemplateWidgetConfigureActivity : BaseWidgetConfigureActivity() { - private lateinit var binding: WidgetTemplateConfigureBinding +class TemplateWidgetConfigureActivity : BaseActivity() { - override val serverSelect: View - get() = binding.serverSelect + private val viewModel: TemplateWidgetConfigureViewModel by viewModels() - override val serverSelectList: Spinner - get() = binding.serverSelectList - - private var requestLauncherSetup = false + private val supportedTextColors: List + get() = listOf( + application.getHexForColor(commonR.color.colorWidgetButtonLabelBlack), + application.getHexForColor(android.R.color.white), + ) - public override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdgeCompat() super.onCreate(savedInstanceState) // Set the result to CANCELED. This will cause the widget host to cancel // out of the widget placement if the user presses the back button. setResult(RESULT_CANCELED) - binding = WidgetTemplateConfigureBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.root.applySafeDrawingInsets() - - // Find the widget id from the intent. - val intent = intent val extras = intent.extras - if (extras != null) { - appWidgetId = extras.getInt( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - requestLauncherSetup = extras.getBoolean( - ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, - false, - ) - } + val widgetId = extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + val requestLauncherSetup = extras?.getBoolean( + ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, + false, + ) ?: false - // If this activity was started with an intent without an app widget ID, finish with an error. - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { finish() return } - val backgroundTypeValues = WidgetUtils.getBackgroundOptionList(this) - binding.backgroundType.adapter = - ArrayAdapter( - this, - android.R.layout.simple_spinner_dropdown_item, - backgroundTypeValues, - ) + viewModel.onSetup(widgetId = widgetId, supportedTextColors = supportedTextColors) - lifecycleScope.launch { - val templateWidget = dao.get(appWidgetId) + observeActions() - if (templateWidget?.serverId != null) { - // Set server ID early for template rendering - selectedServerId = templateWidget.serverId - } - setupServerSelect(templateWidget?.serverId) - - if (templateWidget != null) { - binding.templateText.setText(templateWidget.template) - binding.textSize.setText(templateWidget.textSize.toInt().toString()) - binding.addButton.setText(commonR.string.update_widget) - if (templateWidget.template.isNotEmpty()) { - renderTemplateText(templateWidget.template) - } else { - binding.renderedTemplate.text = getString(commonR.string.empty_template) - binding.addButton.isEnabled = false - } - binding.backgroundType.setSelection( - WidgetUtils.getSelectedBackgroundOption( - this@TemplateWidgetConfigureActivity, - templateWidget.backgroundType, - backgroundTypeValues, - ), + setContent { + HomeAssistantAppTheme { + TemplateWidgetConfigureScreen( + viewModel = viewModel, + onActionClick = { onActionClick(requestLauncherSetup) }, ) - binding.textColor.isVisible = templateWidget.backgroundType == WidgetBackgroundType.TRANSPARENT - binding.textColorWhite.isChecked = - templateWidget.textColor?.let { - it.toColorInt() == ContextCompat.getColor( - this@TemplateWidgetConfigureActivity, - android.R.color.white, - ) - } - ?: true - binding.textColorBlack.isChecked = - templateWidget.textColor?.let { - it.toColorInt() == - ContextCompat.getColor( - this@TemplateWidgetConfigureActivity, - commonR.color.colorWidgetButtonLabelBlack, - ) - } - ?: false - } else { - binding.backgroundType.setSelection(0) } } + } - binding.templateText.doAfterTextChanged { renderTemplateText() } + private fun observeActions() = viewModel.action.onEach(::handleActions).launchIn(lifecycleScope) - 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) + private fun handleActions(action: Action) { + when (action) { + is Action.RequestWidgetCreationAction -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + handleRequestWidgetCreationAction(action.pendingEntity) + } } - override fun onNothingSelected(parent: AdapterView<*>?) { - binding.textColor.visibility = View.GONE - } + Action.UpdateWidgetAction -> handleUpdateWidgetAction() + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun handleRequestWidgetCreationAction(pendingEntity: TemplateWidgetEntity) { + val appWidgetManager = getSystemService(AppWidgetManager::class.java) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT + } else { + PendingIntent.FLAG_MUTABLE } + appWidgetManager?.requestPinAppWidget( + ComponentName(this, TemplateWidget::class.java), + null, + PendingIntent.getBroadcast( + this, + System.currentTimeMillis().toInt(), + Intent(this, TemplateWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, pendingEntity) + }, + flags, + ), + ) + } + + private fun handleUpdateWidgetAction() { + val intent = Intent(this, TemplateWidget::class.java) + intent.action = UPDATE_WIDGETS + sendBroadcast(intent) + } - binding.addButton.setOnClickListener { + @SuppressLint("ObsoleteSdkInt") + private fun onActionClick(requestLauncherSetup: Boolean) { + lifecycleScope.launch { if (requestLauncherSetup) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - lifecycleScope.launch { - requestWidgetCreation() - } + requestPinWidget() } else { - showAddWidgetError() // this shouldn't be possible + showAddWidgetError() } } else { - lifecycleScope.launch { - updateWidget() - } + onUpdateWidget() } } } - override fun onServerSelected(serverId: Int) = renderTemplateText() - - override suspend fun getPendingDaoEntity(): TemplateWidgetEntity { - val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } - val template = checkNotNull(binding.templateText.text?.toString()) { "Template text is null" } - - return TemplateWidgetEntity( - id = appWidgetId, - serverId = serverId, - template = template, - textSize = binding.textSize.text.toString().toFloat(), - 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 - }, - lastUpdate = dao.get(appWidgetId)?.lastUpdate ?: "Loading", - ) + @RequiresApi(Build.VERSION_CODES.O) + private suspend fun requestPinWidget() { + try { + viewModel.requestWidgetCreation() + finish() + } catch (_: IllegalStateException) { + showAddWidgetError() + } } - override val widgetClass: Class<*> = TemplateWidget::class.java - - private fun renderTemplateText() { - val editableText = binding.templateText.text ?: return - if (editableText.isNotEmpty()) { - renderTemplateText(editableText.toString()) - } else { - binding.renderedTemplate.text = getString(commonR.string.empty_template) - binding.addButton.isEnabled = false + private suspend fun onUpdateWidget() { + try { + viewModel.updateWidgetConfiguration() + setResult(RESULT_OK) + finish() + } catch (_: IllegalStateException) { + showAddWidgetError() } } - private fun renderTemplateText(template: String) { - val serverId = selectedServerId - if (serverId == null) { - Timber.w("Not rendering template because server is not set") - return - } + private fun showAddWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() + } +} - lifecycleScope.launch { - var templateText: String? - var enabled: Boolean - withContext(Dispatchers.IO) { - try { - templateText = - serverManager.integrationRepository(serverId) - .renderTemplate(template, mapOf()) - .toString() - enabled = true - } catch (e: Exception) { - Timber.e(e, "Exception while rendering template") - // SerializationException suggests that template is not a String (= error) - templateText = getString( - if (e.cause is SerializationException) { - commonR.string.template_error - } else { - commonR.string.template_render_error - }, - ) - enabled = false - } +@Composable +private fun TemplateWidgetConfigureScreen( + viewModel: TemplateWidgetConfigureViewModel, + onActionClick: () -> Unit, +) { + val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) + + TemplateWidgetConfigureView( + servers = servers, + selectedServerId = viewModel.selectedServerId, + onServerSelected = viewModel::setServer, + templateText = viewModel.templateText, + onTemplateTextChanged = { viewModel.templateText = it }, + renderedTemplate = viewModel.renderedTemplate, + isTemplateValid = viewModel.isTemplateValid, + textSize = viewModel.textSize, + onTextSizeChanged = { viewModel.textSize = it }, + selectedBackgroundType = viewModel.selectedBackgroundType, + onBackgroundTypeSelected = { viewModel.selectedBackgroundType = it }, + textColorIndex = viewModel.textColorIndex, + onTextColorSelected = { viewModel.textColorIndex = it }, + isUpdateWidget = viewModel.isUpdateWidget, + onActionClick = onActionClick, + ) +} + +@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature; same as TodoWidgetConfigureActivity +@Composable +private fun TemplateWidgetConfigureView( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + templateText: String, + onTemplateTextChanged: (String) -> Unit, + renderedTemplate: String?, + isTemplateValid: Boolean, + textSize: String, + onTextSizeChanged: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, + isUpdateWidget: Boolean, + onActionClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.create_template)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding) + .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 = 8.dp), + ) + } + + HATextField( + value = templateText, + onValueChange = onTemplateTextChanged, + placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = Int.MAX_VALUE, + singleLine = false, + ) + + if (renderedTemplate != null) { + Text( + text = renderedTemplate, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } else if (templateText.isEmpty()) { + Text( + text = stringResource(commonR.string.empty_template), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + + HATextField( + value = textSize, + onValueChange = onTextSizeChanged, + label = { Text(stringResource(commonR.string.widget_text_size_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { onBackgroundTypeSelected(it) }, + ) + + AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + ExposedDropdownMenu( + label = stringResource(commonR.string.widget_text_color_label), + keys = listOf( + stringResource(commonR.string.widget_text_color_black), + stringResource(commonR.string.widget_text_color_white), + ), + currentIndex = textColorIndex, + onSelected = { onTextColorSelected(it) }, + ) + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onActionClick, + enabled = isTemplateValid, + ) { + Text(stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget)) } - binding.renderedTemplate.text = - templateText?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) } - binding.addButton.isEnabled = enabled && isValidServerId() } } } + +@Preview +@Composable +private fun TemplateWidgetConfigureViewPreview() { + HomeAssistantAppTheme { + TemplateWidgetConfigureView( + servers = listOf(previewServer1, previewServer2), + selectedServerId = 0, + onServerSelected = {}, + templateText = "Hello world", + renderedTemplate = "Hello world", + isTemplateValid = true, + textSize = "14", + onTextSizeChanged = {}, + selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + onBackgroundTypeSelected = {}, + textColorIndex = 0, + onTextColorSelected = {}, + isUpdateWidget = false, + onActionClick = {}, + onTemplateTextChanged = {}, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt new file mode 100644 index 00000000000..1d4b8bd11f8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -0,0 +1,205 @@ +package io.homeassistant.companion.android.widgets.template + +import android.appwidget.AppWidgetManager +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.core.text.HtmlCompat +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.servers.ServerManager +import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@HiltViewModel +class TemplateWidgetConfigureViewModel @Inject constructor( + private val templateWidgetDao: TemplateWidgetDao, + private val serverManager: ServerManager, +) : ViewModel() { + + @VisibleForTesting + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + private var supportedTextColors: List = emptyList() + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + + private val _action = MutableSharedFlow() + val action = _action.asSharedFlow() + + val servers = serverManager.serversFlow + + var selectedServerId by mutableIntStateOf(ServerManager.SERVER_ID_ACTIVE) + private set + + var templateText by mutableStateOf("") + var textSize by mutableStateOf("14") + var selectedBackgroundType by mutableStateOf( + if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + ) + var textColorIndex by mutableIntStateOf(0) + var isUpdateWidget by mutableStateOf(false) + private set + + var renderedTemplate by mutableStateOf(null) + private set + var isTemplateValid by mutableStateOf(false) + private set + + @OptIn(FlowPreview::class) + private fun startTemplateRendering() { + viewModelScope.launch { + combine( + snapshotFlow { templateText }, + snapshotFlow { selectedServerId }, + ) { template, serverId -> template to serverId } + .debounce(RENDER_DEBOUNCE_MS) + .collect { (template, serverId) -> + renderTemplate(template, serverId) + } + } + } + + private suspend fun renderTemplate(template: String, serverId: Int) { + if (template.isEmpty()) { + renderedTemplate = null + isTemplateValid = false + return + } + if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) { + Timber.w("Not rendering template because server is not set") + return + } + withContext(ioDispatcher) { + try { + val result = serverManager.integrationRepository(serverId) + .renderTemplate(template, mapOf()) + .toString() + @Suppress("UNNECESSARY_SAFE_CALL") + renderedTemplate = HtmlCompat.fromHtml(result, HtmlCompat.FROM_HTML_MODE_LEGACY) + ?.toString() + ?.trimEnd() + ?: result + isTemplateValid = true + } catch (e: Exception) { + Timber.e(e, "Exception while rendering template") + renderedTemplate = null + isTemplateValid = false + } + } + } + + /** + * Initialize the ViewModel with the widget ID and supported text colors. + * Loads existing widget configuration if editing an existing widget. + */ + fun onSetup(widgetId: Int, supportedTextColors: List) { + if (this.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) return + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId + maybeLoadPreviousState(widgetId) + startTemplateRendering() + } + + private fun maybeLoadPreviousState(widgetId: Int) { + viewModelScope.launch { + templateWidgetDao.get(widgetId)?.let { widget -> + isUpdateWidget = true + selectedServerId = widget.serverId + templateText = widget.template + textSize = widget.textSize.toInt().toString() + selectedBackgroundType = widget.backgroundType + if (widget.textColor != null) { + val colorIndex = supportedTextColors.indexOf(widget.textColor) + textColorIndex = if (colorIndex == -1) 0 else colorIndex + } + } + } + } + + /** + * Update the selected server and trigger template re-rendering. + */ + fun setServer(serverId: Int) { + if (selectedServerId == serverId) return + selectedServerId = serverId + } + + private fun getPendingDaoEntity(): TemplateWidgetEntity { + val textColor = if (selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + supportedTextColors.getOrNull(textColorIndex) ?: supportedTextColors.first() + } else { + null + } + return TemplateWidgetEntity( + id = widgetId, + serverId = selectedServerId, + template = templateText, + textSize = textSize.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, + lastUpdate = "Loading", + backgroundType = selectedBackgroundType, + textColor = textColor, + ) + } + + /** + * Save the widget configuration to the database and send a broadcast to update the widget. + * + * @throws IllegalStateException if the widget ID is invalid or server is not valid + */ + suspend fun updateWidgetConfiguration() { + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + throw IllegalStateException("Widget ID is invalid") + } + + val entity = getPendingDaoEntity() + templateWidgetDao.add(entity) + _action.emit(Action.UpdateWidgetAction) + } + + /** + * Requests the widget to be created and waits until it has been saved to the DAO. + * + * **WARNING**: This function does not handle user cancellation. If a user cancels the widget creation, + * this function will not return. If this function is called again and the user does not cancel, + * both calls to the function will return. While this behavior could be avoided, + * it does not cause issues in the current implementation as returning multiple times has no adverse effects. + */ + suspend fun requestWidgetCreation() { + val pendingEntity = getPendingDaoEntity() + templateWidgetDao.getWidgetCountFlow().drop(1).onStart { + _action.emit(Action.RequestWidgetCreationAction(pendingEntity)) + }.first() + } +} + +private const val RENDER_DEBOUNCE_MS = 500L +private const val DEFAULT_TEXT_SIZE = 12.0f + +sealed class Action { + object UpdateWidgetAction : Action() + data class RequestWidgetCreationAction(val pendingEntity: TemplateWidgetEntity) : Action() +} diff --git a/app/src/main/res/layout/widget_template_configure.xml b/app/src/main/res/layout/widget_template_configure.xml deleted file mode 100644 index 68b9d22f966..00000000000 --- a/app/src/main/res/layout/widget_template_configure.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt new file mode 100644 index 00000000000..80047372e1a --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt @@ -0,0 +1,278 @@ +package io.homeassistant.companion.android.widgets.template + +import androidx.compose.runtime.snapshots.Snapshot +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MainDispatcherJUnit5Extension::class, ConsoleLogExtension::class) +class TemplateWidgetConfigureViewModelTest { + + private val templateWidgetDao: TemplateWidgetDao = mockk(relaxed = true) + private val serverManager: ServerManager = mockk(relaxed = true) + private val integrationRepository: IntegrationRepository = mockk(relaxed = true) + + private lateinit var viewModel: TemplateWidgetConfigureViewModel + + private val supportedTextColors = listOf("#000000", "#FFFFFF") + + @BeforeEach + fun setup() { + coEvery { serverManager.isRegistered() } returns true + val server: Server = mockk(relaxed = true) + coEvery { serverManager.getServer(any()) } returns server + coEvery { serverManager.integrationRepository(any()) } returns integrationRepository + viewModel = TemplateWidgetConfigureViewModel( + templateWidgetDao = templateWidgetDao, + serverManager = serverManager, + ) + viewModel.ioDispatcher = UnconfinedTestDispatcher() + } + + @Nested + inner class SetupTest { + @Test + fun `Given no existing widget when onSetup then isUpdateWidget is false`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertFalse(viewModel.isUpdateWidget) + } + + @Test + fun `Given existing widget when onSetup then loads widget state`() = runTest { + val existingWidget = TemplateWidgetEntity( + id = 42, + serverId = 1, + template = "{{ states('sensor.temp') }}", + textSize = 16f, + lastUpdate = "2024-01-01", + backgroundType = WidgetBackgroundType.TRANSPARENT, + textColor = "#FFFFFF", + ) + coEvery { templateWidgetDao.get(42) } returns existingWidget + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertTrue(viewModel.isUpdateWidget) + assertEquals(1, viewModel.selectedServerId) + assertEquals("{{ states('sensor.temp') }}", viewModel.templateText) + assertEquals("16", viewModel.textSize) + assertEquals(WidgetBackgroundType.TRANSPARENT, viewModel.selectedBackgroundType) + assertEquals(1, viewModel.textColorIndex) + } + + @Test + fun `Given existing widget with unknown text color when onSetup then defaults to index 0`() = runTest { + val existingWidget = TemplateWidgetEntity( + id = 42, + serverId = 1, + template = "test", + textSize = 12f, + lastUpdate = "2024-01-01", + backgroundType = WidgetBackgroundType.TRANSPARENT, + textColor = "#FF0000", + ) + coEvery { templateWidgetDao.get(42) } returns existingWidget + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertEquals(0, viewModel.textColorIndex) + } + + @Test + fun `Given onSetup called twice then second call is ignored`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + viewModel.templateText = "modified" + viewModel.onSetup(widgetId = 99, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertEquals("modified", viewModel.templateText) + } + } + + @Nested + inner class ServerSelectionTest { + @Test + fun `Given different server when setServer then selectedServerId is updated`() { + viewModel.setServer(serverId = 5) + + assertEquals(5, viewModel.selectedServerId) + } + + @Test + fun `Given same server when setServer then no change`() { + viewModel.setServer(serverId = 5) + viewModel.setServer(serverId = 5) + + assertEquals(5, viewModel.selectedServerId) + } + } + + @Nested + inner class TemplateRenderingTest { + @Test + fun `Given valid template when text changes then template is rendered`() = runTest { + coEvery { + integrationRepository.renderTemplate(any(), any()) + } returns "25.0" + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "{{ states('sensor.temp') }}" + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + assertEquals("25.0", viewModel.renderedTemplate) + assertTrue(viewModel.isTemplateValid) + } + + @Test + fun `Given empty template when text changes then template is not valid`() = runTest { + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "" + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + assertNull(viewModel.renderedTemplate) + assertFalse(viewModel.isTemplateValid) + } + + @Test + fun `Given render error when rendering template then template is not valid`() = runTest { + coEvery { + integrationRepository.renderTemplate(any(), any()) + } throws RuntimeException("Render failed") + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "{{ invalid }}" + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + assertNull(viewModel.renderedTemplate) + assertFalse(viewModel.isTemplateValid) + } + + @Test + fun `Given server not registered when rendering template then template state is unchanged`() = runTest { + coEvery { serverManager.isRegistered() } returns false + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "{{ states('sensor.temp') }}" + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + assertNull(viewModel.renderedTemplate) + assertFalse(viewModel.isTemplateValid) + } + } + + @Nested + inner class UpdateWidgetTest { + @Test + fun `Given valid widget ID when updateWidgetConfiguration then entity is saved to DAO`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "test template" + viewModel.textSize = "16" + viewModel.selectedBackgroundType = WidgetBackgroundType.DAYNIGHT + + viewModel.updateWidgetConfiguration() + + coVerify { + templateWidgetDao.add( + match { + it.id == 42 && + it.template == "test template" && + it.textSize == 16f && + it.backgroundType == WidgetBackgroundType.DAYNIGHT && + it.textColor == null + }, + ) + } + } + + @Test + fun `Given transparent background when updateWidgetConfiguration then text color is set`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "test" + viewModel.selectedBackgroundType = WidgetBackgroundType.TRANSPARENT + viewModel.textColorIndex = 1 + + viewModel.updateWidgetConfiguration() + + coVerify { + templateWidgetDao.add( + match { + it.textColor == "#FFFFFF" && + it.backgroundType == WidgetBackgroundType.TRANSPARENT + }, + ) + } + } + + @Test + fun `Given invalid widget ID when updateWidgetConfiguration then throws IllegalStateException`() = runTest { + var thrown = false + try { + viewModel.updateWidgetConfiguration() + } catch (_: IllegalStateException) { + thrown = true + } + assertTrue(thrown) + } + + @Test + fun `Given invalid text size when updateWidgetConfiguration then uses default text size`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.templateText = "test" + viewModel.textSize = "invalid" + + viewModel.updateWidgetConfiguration() + + coVerify { + templateWidgetDao.add(match { it.textSize == 12.0f }) + } + } + } +} From 9edff4eac6ca82e7cb0dcb4d41de87820d3ef408 Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 15:29:53 +0800 Subject: [PATCH 2/7] Refactor Template widget configuration to use a unified `UIState` and move logic to the ViewModel (#6454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract `TemplateWidgetConfigureScreen` and `TemplateWidgetConfigureView` to a separate file * Migrate configuration state (server, template text, text size, background type) to a single `TemplateWidgetConfigureUiState` managed by `MutableStateFlow` * Move widget pinning and update logic from the Activity to the ViewModel * Improve template rendering error handling with a new `TemplateRenderError` enum and better exception catching * Update tests to reflect state management and logic changes * Remove obsolete SDK checks and `Action` sealed class --------- Co-authored-by: Joris Pelgröm --- .../TemplateWidgetConfigureActivity.kt | 263 +--------------- .../template/TemplateWidgetConfigureScreen.kt | 210 +++++++++++++ .../TemplateWidgetConfigureViewModel.kt | 284 ++++++++++++------ .../TemplateWidgetConfigureViewModelTest.kt | 87 +++--- 4 files changed, 453 insertions(+), 391 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index a6d2022d8bb..59804bf9cc8 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -1,62 +1,17 @@ package io.homeassistant.companion.android.widgets.template -import android.annotation.SuppressLint -import android.app.PendingIntent import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Intent -import android.os.Build import android.os.Bundle import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope 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.HATextField -import io.homeassistant.companion.android.database.server.Server -import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity -import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme -import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu -import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu -import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat import io.homeassistant.companion.android.util.getHexForColor -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.ACTION_APPWIDGET_CREATED -import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.UPDATE_WIDGETS -import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @AndroidEntryPoint @@ -71,7 +26,6 @@ class TemplateWidgetConfigureActivity : BaseActivity() { ) override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdgeCompat() super.onCreate(savedInstanceState) // Set the result to CANCELED. This will cause the widget host to cancel @@ -96,8 +50,6 @@ class TemplateWidgetConfigureActivity : BaseActivity() { viewModel.onSetup(widgetId = widgetId, supportedTextColors = supportedTextColors) - observeActions() - setContent { HomeAssistantAppTheme { TemplateWidgetConfigureScreen( @@ -108,68 +60,19 @@ class TemplateWidgetConfigureActivity : BaseActivity() { } } - private fun observeActions() = viewModel.action.onEach(::handleActions).launchIn(lifecycleScope) - - private fun handleActions(action: Action) { - when (action) { - is Action.RequestWidgetCreationAction -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - handleRequestWidgetCreationAction(action.pendingEntity) - } - } - - Action.UpdateWidgetAction -> handleUpdateWidgetAction() - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun handleRequestWidgetCreationAction(pendingEntity: TemplateWidgetEntity) { - val appWidgetManager = getSystemService(AppWidgetManager::class.java) - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT - } else { - PendingIntent.FLAG_MUTABLE - } - appWidgetManager?.requestPinAppWidget( - ComponentName(this, TemplateWidget::class.java), - null, - PendingIntent.getBroadcast( - this, - System.currentTimeMillis().toInt(), - Intent(this, TemplateWidget::class.java).apply { - action = ACTION_APPWIDGET_CREATED - putExtra(EXTRA_WIDGET_ENTITY, pendingEntity) - }, - flags, - ), - ) - } - - private fun handleUpdateWidgetAction() { - val intent = Intent(this, TemplateWidget::class.java) - intent.action = UPDATE_WIDGETS - sendBroadcast(intent) - } - - @SuppressLint("ObsoleteSdkInt") private fun onActionClick(requestLauncherSetup: Boolean) { lifecycleScope.launch { if (requestLauncherSetup) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - requestPinWidget() - } else { - showAddWidgetError() - } + requestPinWidget() } else { onUpdateWidget() } } } - @RequiresApi(Build.VERSION_CODES.O) private suspend fun requestPinWidget() { try { - viewModel.requestWidgetCreation() + viewModel.requestWidgetCreation(this@TemplateWidgetConfigureActivity) finish() } catch (_: IllegalStateException) { showAddWidgetError() @@ -178,7 +81,7 @@ class TemplateWidgetConfigureActivity : BaseActivity() { private suspend fun onUpdateWidget() { try { - viewModel.updateWidgetConfiguration() + viewModel.updateWidgetConfiguration(this@TemplateWidgetConfigureActivity) setResult(RESULT_OK) finish() } catch (_: IllegalStateException) { @@ -189,162 +92,4 @@ class TemplateWidgetConfigureActivity : BaseActivity() { private fun showAddWidgetError() { Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() } -} - -@Composable -private fun TemplateWidgetConfigureScreen( - viewModel: TemplateWidgetConfigureViewModel, - onActionClick: () -> Unit, -) { - val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) - - TemplateWidgetConfigureView( - servers = servers, - selectedServerId = viewModel.selectedServerId, - onServerSelected = viewModel::setServer, - templateText = viewModel.templateText, - onTemplateTextChanged = { viewModel.templateText = it }, - renderedTemplate = viewModel.renderedTemplate, - isTemplateValid = viewModel.isTemplateValid, - textSize = viewModel.textSize, - onTextSizeChanged = { viewModel.textSize = it }, - selectedBackgroundType = viewModel.selectedBackgroundType, - onBackgroundTypeSelected = { viewModel.selectedBackgroundType = it }, - textColorIndex = viewModel.textColorIndex, - onTextColorSelected = { viewModel.textColorIndex = it }, - isUpdateWidget = viewModel.isUpdateWidget, - onActionClick = onActionClick, - ) -} - -@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature; same as TodoWidgetConfigureActivity -@Composable -private fun TemplateWidgetConfigureView( - servers: List, - selectedServerId: Int, - onServerSelected: (Int) -> Unit, - templateText: String, - onTemplateTextChanged: (String) -> Unit, - renderedTemplate: String?, - isTemplateValid: Boolean, - textSize: String, - onTextSizeChanged: (String) -> Unit, - selectedBackgroundType: WidgetBackgroundType, - onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, - textColorIndex: Int, - onTextColorSelected: (Int) -> Unit, - isUpdateWidget: Boolean, - onActionClick: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(commonR.string.create_template)) }, - windowInsets = safeTopWindowInsets(), - backgroundColor = colorResource(commonR.color.colorBackground), - contentColor = colorResource(commonR.color.colorOnBackground), - ) - }, - ) { padding -> - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .windowInsetsPadding(safeBottomWindowInsets()) - .padding(padding) - .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 = 8.dp), - ) - } - - HATextField( - value = templateText, - onValueChange = onTemplateTextChanged, - placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, - modifier = Modifier.fillMaxWidth(), - maxLines = Int.MAX_VALUE, - singleLine = false, - ) - - if (renderedTemplate != null) { - Text( - text = renderedTemplate, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) - } else if (templateText.isEmpty()) { - Text( - text = stringResource(commonR.string.empty_template), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) - } - - HATextField( - value = textSize, - onValueChange = onTextSizeChanged, - label = { Text(stringResource(commonR.string.widget_text_size_label)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - WidgetBackgroundTypeExposedDropdownMenu( - current = selectedBackgroundType, - onSelected = { onBackgroundTypeSelected(it) }, - ) - - AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { - ExposedDropdownMenu( - label = stringResource(commonR.string.widget_text_color_label), - keys = listOf( - stringResource(commonR.string.widget_text_color_black), - stringResource(commonR.string.widget_text_color_white), - ), - currentIndex = textColorIndex, - onSelected = { onTextColorSelected(it) }, - ) - } - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onActionClick, - enabled = isTemplateValid, - ) { - Text(stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget)) - } - } - } -} - -@Preview -@Composable -private fun TemplateWidgetConfigureViewPreview() { - HomeAssistantAppTheme { - TemplateWidgetConfigureView( - servers = listOf(previewServer1, previewServer2), - selectedServerId = 0, - onServerSelected = {}, - templateText = "Hello world", - renderedTemplate = "Hello world", - isTemplateValid = true, - textSize = "14", - onTextSizeChanged = {}, - selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, - onBackgroundTypeSelected = {}, - textColorIndex = 0, - onTextColorSelected = {}, - isUpdateWidget = false, - onActionClick = {}, - onTemplateTextChanged = {}, - ) - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt new file mode 100644 index 00000000000..7bfaae66f17 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt @@ -0,0 +1,210 @@ +package io.homeassistant.companion.android.widgets.template + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.composable.HATextField +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu +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 + +@Composable +internal fun TemplateWidgetConfigureScreen( + viewModel: TemplateWidgetConfigureViewModel, + onActionClick: () -> Unit, +) { + val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + TemplateWidgetConfigureView( + servers = servers, + selectedServerId = uiState.selectedServerId, + onServerSelected = viewModel::setServer, + templateText = uiState.templateText, + onTemplateTextChanged = viewModel::onTemplateTextChanged, + renderedTemplate = uiState.renderedTemplate, + isTemplateValid = uiState.isTemplateValid, + templateRenderError = uiState.templateRenderError, + textSize = uiState.textSize, + onTextSizeChanged = viewModel::onTextSizeChanged, + selectedBackgroundType = uiState.selectedBackgroundType, + onBackgroundTypeSelected = viewModel::onBackgroundTypeSelected, + textColorIndex = uiState.textColorIndex, + onTextColorSelected = viewModel::onTextColorSelected, + isUpdateWidget = uiState.isUpdateWidget, + onActionClick = onActionClick, + ) +} + +@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature; same as TodoWidgetConfigureActivity +@Composable +private fun TemplateWidgetConfigureView( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + templateText: String, + onTemplateTextChanged: (String) -> Unit, + renderedTemplate: String?, + isTemplateValid: Boolean, + templateRenderError: TemplateRenderError?, + textSize: String, + onTextSizeChanged: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, + isUpdateWidget: Boolean, + onActionClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.create_template)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding) + .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 = 8.dp), + ) + } + + HATextField( + value = templateText, + onValueChange = onTemplateTextChanged, + placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = Int.MAX_VALUE, + singleLine = false, + ) + + if (renderedTemplate != null) { + Text( + text = renderedTemplate, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } else if (templateRenderError != null) { + Text( + text = stringResource( + when (templateRenderError) { + TemplateRenderError.TEMPLATE_ERROR -> commonR.string.template_error + TemplateRenderError.RENDER_ERROR -> commonR.string.template_render_error + }, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } else if (templateText.isEmpty()) { + Text( + text = stringResource(commonR.string.empty_template), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + + HATextField( + value = textSize, + onValueChange = onTextSizeChanged, + label = { Text(stringResource(commonR.string.widget_text_size_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { onBackgroundTypeSelected(it) }, + ) + + AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + ExposedDropdownMenu( + label = stringResource(commonR.string.widget_text_color_label), + keys = listOf( + stringResource(commonR.string.widget_text_color_black), + stringResource(commonR.string.widget_text_color_white), + ), + currentIndex = textColorIndex, + onSelected = { onTextColorSelected(it) }, + ) + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onActionClick, + enabled = isTemplateValid, + ) { + Text(stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget)) + } + } + } +} + +@Preview +@Composable +private fun TemplateWidgetConfigureViewPreview() { + HomeAssistantAppTheme { + TemplateWidgetConfigureView( + servers = listOf(previewServer1, previewServer2), + selectedServerId = 0, + onServerSelected = {}, + templateText = "Hello world", + renderedTemplate = "Hello world", + isTemplateValid = true, + templateRenderError = null, + textSize = "14", + onTextSizeChanged = {}, + selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + onBackgroundTypeSelected = {}, + textColorIndex = 0, + onTextColorSelected = {}, + isUpdateWidget = false, + onActionClick = {}, + onTemplateTextChanged = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt index 1d4b8bd11f8..c25019a5b02 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -1,12 +1,11 @@ package io.homeassistant.companion.android.widgets.template +import android.app.PendingIntent import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.core.text.HtmlCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -16,21 +15,54 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.widget.TemplateWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.UPDATE_WIDGETS +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException import timber.log.Timber +private const val RENDER_DEBOUNCE_MS = 500L +private const val DEFAULT_TEXT_SIZE = 12.0f + +/** + * Represents the UI state of the Template Widget configuration screen. + */ +data class TemplateWidgetConfigureUiState( + val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE, + val templateText: String = "", + val renderedTemplate: String? = null, + val isTemplateValid: Boolean = false, + val textSize: String = "14", + val selectedBackgroundType: WidgetBackgroundType = + if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + val textColorIndex: Int = 0, + val isUpdateWidget: Boolean = false, + val templateRenderError: TemplateRenderError? = null, +) + @HiltViewModel class TemplateWidgetConfigureViewModel @Inject constructor( private val templateWidgetDao: TemplateWidgetDao, @@ -43,38 +75,94 @@ class TemplateWidgetConfigureViewModel @Inject constructor( private var supportedTextColors: List = emptyList() private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID - private val _action = MutableSharedFlow() - val action = _action.asSharedFlow() + private val _uiState = MutableStateFlow(TemplateWidgetConfigureUiState()) + val uiState: StateFlow = _uiState.asStateFlow() val servers = serverManager.serversFlow - var selectedServerId by mutableIntStateOf(ServerManager.SERVER_ID_ACTIVE) - private set + private var templateRenderingJob: Job? = null - var templateText by mutableStateOf("") - var textSize by mutableStateOf("14") - var selectedBackgroundType by mutableStateOf( - if (DynamicColors.isDynamicColorAvailable()) { - WidgetBackgroundType.DYNAMICCOLOR - } else { - WidgetBackgroundType.DAYNIGHT - }, - ) - var textColorIndex by mutableIntStateOf(0) - var isUpdateWidget by mutableStateOf(false) - private set + /** + * Initialize the ViewModel with the widget ID and supported text colors. + * Loads existing widget configuration if editing an existing widget. + * + * This guard prevents re-initialization when the Activity is recreated due to + * configuration changes, since the ViewModel survives those changes. + */ + fun onSetup(widgetId: Int, supportedTextColors: List) { + if (this.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) return + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId + maybeLoadPreviousState(widgetId) + startTemplateRendering() + } + + private fun maybeLoadPreviousState(widgetId: Int) { + viewModelScope.launch { + templateWidgetDao.get(widgetId)?.let { widget -> + _uiState.update { state -> + state.copy( + isUpdateWidget = true, + selectedServerId = widget.serverId, + templateText = widget.template, + textSize = widget.textSize.toInt().toString(), + selectedBackgroundType = widget.backgroundType, + textColorIndex = if (widget.textColor != null) { + val colorIndex = supportedTextColors.indexOf(widget.textColor) + if (colorIndex == -1) 0 else colorIndex + } else { + state.textColorIndex + }, + ) + } + } + } + } - var renderedTemplate by mutableStateOf(null) - private set - var isTemplateValid by mutableStateOf(false) - private set + /** + * Update the selected server. + */ + fun setServer(serverId: Int) { + _uiState.update { + if (it.selectedServerId == serverId) it else it.copy(selectedServerId = serverId) + } + } + + /** + * Update the template text. + */ + fun onTemplateTextChanged(text: String) { + _uiState.update { it.copy(templateText = text) } + } + + /** + * Update the text size. + */ + fun onTextSizeChanged(size: String) { + _uiState.update { it.copy(textSize = size) } + } + + /** + * Update the selected background type. + */ + fun onBackgroundTypeSelected(type: WidgetBackgroundType) { + _uiState.update { it.copy(selectedBackgroundType = type) } + } + + /** + * Update the selected text color index. + */ + fun onTextColorSelected(index: Int) { + _uiState.update { it.copy(textColorIndex = index) } + } @OptIn(FlowPreview::class) private fun startTemplateRendering() { - viewModelScope.launch { + templateRenderingJob?.cancel() + templateRenderingJob = viewModelScope.launch { combine( - snapshotFlow { templateText }, - snapshotFlow { selectedServerId }, + _uiState.mapField { it.templateText }, + _uiState.mapField { it.selectedServerId }, ) { template, serverId -> template to serverId } .debounce(RENDER_DEBOUNCE_MS) .collect { (template, serverId) -> @@ -83,10 +171,18 @@ class TemplateWidgetConfigureViewModel @Inject constructor( } } + /** + * Extracts a distinct field from the StateFlow to avoid unnecessary emissions. + */ + private fun StateFlow.mapField( + selector: (TemplateWidgetConfigureUiState) -> T, + ): Flow { + return map(selector).distinctUntilChanged() + } + private suspend fun renderTemplate(template: String, serverId: Int) { if (template.isEmpty()) { - renderedTemplate = null - isTemplateValid = false + _uiState.update { it.copy(renderedTemplate = null, isTemplateValid = false) } return } if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) { @@ -99,107 +195,113 @@ class TemplateWidgetConfigureViewModel @Inject constructor( .renderTemplate(template, mapOf()) .toString() @Suppress("UNNECESSARY_SAFE_CALL") - renderedTemplate = HtmlCompat.fromHtml(result, HtmlCompat.FROM_HTML_MODE_LEGACY) + val rendered = HtmlCompat.fromHtml(result, HtmlCompat.FROM_HTML_MODE_LEGACY) ?.toString() ?.trimEnd() ?: result - isTemplateValid = true + _uiState.update { + it.copy(renderedTemplate = rendered, isTemplateValid = true, templateRenderError = null) + } } catch (e: Exception) { - Timber.e(e, "Exception while rendering template") - renderedTemplate = null - isTemplateValid = false - } - } - } - - /** - * Initialize the ViewModel with the widget ID and supported text colors. - * Loads existing widget configuration if editing an existing widget. - */ - fun onSetup(widgetId: Int, supportedTextColors: List) { - if (this.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) return - this.supportedTextColors = supportedTextColors - this.widgetId = widgetId - maybeLoadPreviousState(widgetId) - startTemplateRendering() - } - - private fun maybeLoadPreviousState(widgetId: Int) { - viewModelScope.launch { - templateWidgetDao.get(widgetId)?.let { widget -> - isUpdateWidget = true - selectedServerId = widget.serverId - templateText = widget.template - textSize = widget.textSize.toInt().toString() - selectedBackgroundType = widget.backgroundType - if (widget.textColor != null) { - val colorIndex = supportedTextColors.indexOf(widget.textColor) - textColorIndex = if (colorIndex == -1) 0 else colorIndex + if (e is SerializationException) { + Timber.e(e, "Template syntax error") + } else { + Timber.e(e, "Error rendering template") + } + val errorRendered = if (e.cause is SerializationException) { + TemplateRenderError.TEMPLATE_ERROR + } else { + TemplateRenderError.RENDER_ERROR + } + _uiState.update { + it.copy( + renderedTemplate = null, + isTemplateValid = false, + templateRenderError = errorRendered, + ) } } } } - /** - * Update the selected server and trigger template re-rendering. - */ - fun setServer(serverId: Int) { - if (selectedServerId == serverId) return - selectedServerId = serverId - } - private fun getPendingDaoEntity(): TemplateWidgetEntity { - val textColor = if (selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { - supportedTextColors.getOrNull(textColorIndex) ?: supportedTextColors.first() + val state = _uiState.value + val textColor = if (state.selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + supportedTextColors.getOrNull(state.textColorIndex) ?: supportedTextColors.first() } else { null } return TemplateWidgetEntity( id = widgetId, - serverId = selectedServerId, - template = templateText, - textSize = textSize.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, + serverId = state.selectedServerId, + template = state.templateText, + textSize = state.textSize.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, lastUpdate = "Loading", - backgroundType = selectedBackgroundType, + backgroundType = state.selectedBackgroundType, textColor = textColor, ) } /** - * Save the widget configuration to the database and send a broadcast to update the widget. + * Save the widget configuration to the database and update the widget. * - * @throws IllegalStateException if the widget ID is invalid or server is not valid + * @param context the context used to send a broadcast to trigger widget update + * @throws IllegalStateException if the widget ID is invalid */ - suspend fun updateWidgetConfiguration() { + suspend fun updateWidgetConfiguration(context: Context) { if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { throw IllegalStateException("Widget ID is invalid") } val entity = getPendingDaoEntity() templateWidgetDao.add(entity) - _action.emit(Action.UpdateWidgetAction) + + val intent = Intent(context, TemplateWidget::class.java) + intent.action = UPDATE_WIDGETS + context.sendBroadcast(intent) } /** - * Requests the widget to be created and waits until it has been saved to the DAO. + * Requests the system to pin a new Template widget. + * + * Saves a pending entity and waits until the widget has been created by monitoring the DAO. * * **WARNING**: This function does not handle user cancellation. If a user cancels the widget creation, * this function will not return. If this function is called again and the user does not cancel, * both calls to the function will return. While this behavior could be avoided, * it does not cause issues in the current implementation as returning multiple times has no adverse effects. + * + * @param context the context used to request the pin widget */ - suspend fun requestWidgetCreation() { + suspend fun requestWidgetCreation(context: Context) { val pendingEntity = getPendingDaoEntity() templateWidgetDao.getWidgetCountFlow().drop(1).onStart { - _action.emit(Action.RequestWidgetCreationAction(pendingEntity)) + val appWidgetManager = context.getSystemService(AppWidgetManager::class.java) + val flags = PendingIntent.FLAG_MUTABLE + appWidgetManager?.requestPinAppWidget( + ComponentName(context, TemplateWidget::class.java), + null, + PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + Intent(context, TemplateWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, pendingEntity) + }, + flags, + ), + ) }.first() } } -private const val RENDER_DEBOUNCE_MS = 500L -private const val DEFAULT_TEXT_SIZE = 12.0f +/** + * Represents the type of error encountered when rendering a template. + */ +enum class TemplateRenderError { + /** Error in the template syntax itself. */ + TEMPLATE_ERROR, -sealed class Action { - object UpdateWidgetAction : Action() - data class RequestWidgetCreationAction(val pendingEntity: TemplateWidgetEntity) : Action() -} + /** Error communicating with the server or rendering the template. */ + RENDER_ERROR, +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt index 80047372e1a..59192f75cd6 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt @@ -1,6 +1,5 @@ package io.homeassistant.companion.android.widgets.template -import androidx.compose.runtime.snapshots.Snapshot import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.server.Server @@ -59,7 +58,7 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - assertFalse(viewModel.isUpdateWidget) + assertFalse(viewModel.uiState.value.isUpdateWidget) } @Test @@ -78,12 +77,13 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - assertTrue(viewModel.isUpdateWidget) - assertEquals(1, viewModel.selectedServerId) - assertEquals("{{ states('sensor.temp') }}", viewModel.templateText) - assertEquals("16", viewModel.textSize) - assertEquals(WidgetBackgroundType.TRANSPARENT, viewModel.selectedBackgroundType) - assertEquals(1, viewModel.textColorIndex) + val state = viewModel.uiState.value + assertTrue(state.isUpdateWidget) + assertEquals(1, state.selectedServerId) + assertEquals("{{ states('sensor.temp') }}", state.templateText) + assertEquals("16", state.textSize) + assertEquals(WidgetBackgroundType.TRANSPARENT, state.selectedBackgroundType) + assertEquals(1, state.textColorIndex) } @Test @@ -102,7 +102,7 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - assertEquals(0, viewModel.textColorIndex) + assertEquals(0, viewModel.uiState.value.textColorIndex) } @Test @@ -112,11 +112,11 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "modified" + viewModel.onTemplateTextChanged("modified") viewModel.onSetup(widgetId = 99, supportedTextColors = supportedTextColors) advanceUntilIdle() - assertEquals("modified", viewModel.templateText) + assertEquals("modified", viewModel.uiState.value.templateText) } } @@ -126,7 +126,7 @@ class TemplateWidgetConfigureViewModelTest { fun `Given different server when setServer then selectedServerId is updated`() { viewModel.setServer(serverId = 5) - assertEquals(5, viewModel.selectedServerId) + assertEquals(5, viewModel.uiState.value.selectedServerId) } @Test @@ -134,7 +134,7 @@ class TemplateWidgetConfigureViewModelTest { viewModel.setServer(serverId = 5) viewModel.setServer(serverId = 5) - assertEquals(5, viewModel.selectedServerId) + assertEquals(5, viewModel.uiState.value.selectedServerId) } } @@ -148,24 +148,24 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "{{ states('sensor.temp') }}" - Snapshot.sendApplyNotifications() + viewModel.onTemplateTextChanged("{{ states('sensor.temp') }}") advanceUntilIdle() - assertEquals("25.0", viewModel.renderedTemplate) - assertTrue(viewModel.isTemplateValid) + val state = viewModel.uiState.value + assertEquals("25.0", state.renderedTemplate) + assertTrue(state.isTemplateValid) } @Test fun `Given empty template when text changes then template is not valid`() = runTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "" - Snapshot.sendApplyNotifications() + viewModel.onTemplateTextChanged("") advanceUntilIdle() - assertNull(viewModel.renderedTemplate) - assertFalse(viewModel.isTemplateValid) + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) } @Test @@ -176,12 +176,13 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "{{ invalid }}" - Snapshot.sendApplyNotifications() + viewModel.onTemplateTextChanged("{{ invalid }}") advanceUntilIdle() - assertNull(viewModel.renderedTemplate) - assertFalse(viewModel.isTemplateValid) + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) + assertEquals(TemplateRenderError.RENDER_ERROR, state.templateRenderError) } @Test @@ -190,12 +191,12 @@ class TemplateWidgetConfigureViewModelTest { viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "{{ states('sensor.temp') }}" - Snapshot.sendApplyNotifications() + viewModel.onTemplateTextChanged("{{ states('sensor.temp') }}") advanceUntilIdle() - assertNull(viewModel.renderedTemplate) - assertFalse(viewModel.isTemplateValid) + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) } } @@ -204,14 +205,15 @@ class TemplateWidgetConfigureViewModelTest { @Test fun `Given valid widget ID when updateWidgetConfiguration then entity is saved to DAO`() = runTest { coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "test template" - viewModel.textSize = "16" - viewModel.selectedBackgroundType = WidgetBackgroundType.DAYNIGHT + viewModel.onTemplateTextChanged("test template") + viewModel.onTextSizeChanged("16") + viewModel.onBackgroundTypeSelected(WidgetBackgroundType.DAYNIGHT) - viewModel.updateWidgetConfiguration() + viewModel.updateWidgetConfiguration(context) coVerify { templateWidgetDao.add( @@ -229,14 +231,15 @@ class TemplateWidgetConfigureViewModelTest { @Test fun `Given transparent background when updateWidgetConfiguration then text color is set`() = runTest { coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "test" - viewModel.selectedBackgroundType = WidgetBackgroundType.TRANSPARENT - viewModel.textColorIndex = 1 + viewModel.onTemplateTextChanged("test") + viewModel.onBackgroundTypeSelected(WidgetBackgroundType.TRANSPARENT) + viewModel.onTextColorSelected(1) - viewModel.updateWidgetConfiguration() + viewModel.updateWidgetConfiguration(context) coVerify { templateWidgetDao.add( @@ -250,9 +253,10 @@ class TemplateWidgetConfigureViewModelTest { @Test fun `Given invalid widget ID when updateWidgetConfiguration then throws IllegalStateException`() = runTest { + val context: android.content.Context = mockk(relaxed = true) var thrown = false try { - viewModel.updateWidgetConfiguration() + viewModel.updateWidgetConfiguration(context) } catch (_: IllegalStateException) { thrown = true } @@ -262,13 +266,14 @@ class TemplateWidgetConfigureViewModelTest { @Test fun `Given invalid text size when updateWidgetConfiguration then uses default text size`() = runTest { coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) advanceUntilIdle() - viewModel.templateText = "test" - viewModel.textSize = "invalid" + viewModel.onTemplateTextChanged("test") + viewModel.onTextSizeChanged("invalid") - viewModel.updateWidgetConfiguration() + viewModel.updateWidgetConfiguration(context) coVerify { templateWidgetDao.add(match { it.textSize == 12.0f }) From 990fc94523c679751f1600546f08fe0e96f86247 Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 15:46:21 +0800 Subject: [PATCH 3/7] Replace Toast with Snackbar for Template Widget configuration errors * Implement `errorMessage` flow in `TemplateWidgetConfigureViewModel` * Add `SnackbarHost` to `TemplateWidgetConfigureScreen` to display one-shot error events * Update `TemplateWidgetConfigureActivity` to use the ViewModel for error reporting instead of manual Toast calls --- .../TemplateWidgetConfigureActivity.kt | 3 +-- .../template/TemplateWidgetConfigureScreen.kt | 19 +++++++++++++++++-- .../TemplateWidgetConfigureViewModel.kt | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 59804bf9cc8..6ddee8191b1 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -2,7 +2,6 @@ package io.homeassistant.companion.android.widgets.template import android.appwidget.AppWidgetManager import android.os.Bundle -import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope @@ -90,6 +89,6 @@ class TemplateWidgetConfigureActivity : BaseActivity() { } private fun showAddWidgetError() { - Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() + viewModel.showError(commonR.string.widget_creation_error) } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt index 7bfaae66f17..d80507a77f0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt @@ -11,11 +11,16 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType @@ -42,6 +47,14 @@ internal fun TemplateWidgetConfigureScreen( ) { val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.errorMessage.collect { resId -> + snackbarHostState.showSnackbar(context.getString(resId)) + } + } TemplateWidgetConfigureView( servers = servers, @@ -60,10 +73,10 @@ internal fun TemplateWidgetConfigureScreen( onTextColorSelected = viewModel::onTextColorSelected, isUpdateWidget = uiState.isUpdateWidget, onActionClick = onActionClick, + snackbarHostState = snackbarHostState, ) } -@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature; same as TodoWidgetConfigureActivity @Composable private fun TemplateWidgetConfigureView( servers: List, @@ -82,8 +95,10 @@ private fun TemplateWidgetConfigureView( onTextColorSelected: (Int) -> Unit, isUpdateWidget: Boolean, onActionClick: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(stringResource(commonR.string.create_template)) }, @@ -207,4 +222,4 @@ private fun TemplateWidgetConfigureViewPreview() { onTemplateTextChanged = {}, ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt index c25019a5b02..2339689f68a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -24,8 +24,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -78,6 +81,11 @@ class TemplateWidgetConfigureViewModel @Inject constructor( private val _uiState = MutableStateFlow(TemplateWidgetConfigureUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _errorMessage = MutableSharedFlow() + + /** One-shot error events carrying a string resource ID to display in a Snackbar. */ + val errorMessage: SharedFlow = _errorMessage.asSharedFlow() + val servers = serverManager.serversFlow private var templateRenderingJob: Job? = null @@ -293,6 +301,15 @@ class TemplateWidgetConfigureViewModel @Inject constructor( ) }.first() } + + /** + * Emit a one-shot error event to be displayed as a Snackbar. + * + * @param messageResId the string resource ID for the error message + */ + fun showError(messageResId: Int) { + viewModelScope.launch { _errorMessage.emit(messageResId) } + } } /** From d8bd871e39299fe9db27ef62c998740fbe20c5fc Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 15:54:04 +0800 Subject: [PATCH 4/7] Use HADimens for spacing in TemplateWidgetConfigureScreen (#6455) --- .../template/TemplateWidgetConfigureScreen.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt index d80507a77f0..628ab5b7801 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.theme.HADimens import io.homeassistant.companion.android.common.compose.composable.HATextField import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.WidgetBackgroundType @@ -113,15 +113,15 @@ private fun TemplateWidgetConfigureView( .verticalScroll(rememberScrollState()) .windowInsetsPadding(safeBottomWindowInsets()) .padding(padding) - .padding(all = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(all = HADimens.SPACE4), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), ) { if (servers.size > 1) { ServerExposedDropdownMenu( servers = servers, current = selectedServerId, onSelected = { onServerSelected(it) }, - modifier = Modifier.padding(bottom = 8.dp), + modifier = Modifier.padding(bottom = HADimens.SPACE2), ) } @@ -139,7 +139,7 @@ private fun TemplateWidgetConfigureView( text = renderedTemplate, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = HADimens.SPACE1), ) } else if (templateRenderError != null) { Text( @@ -151,14 +151,14 @@ private fun TemplateWidgetConfigureView( ), modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = HADimens.SPACE1), ) } else if (templateText.isEmpty()) { Text( text = stringResource(commonR.string.empty_template), modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = HADimens.SPACE1), ) } From 8325a6bbe3e803812033551cbfda28b990aada9f Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 16:21:18 +0800 Subject: [PATCH 5/7] Move TemplateWidgetConfigureUiState and TemplateRenderError to separate files * Extract TemplateWidgetConfigureUiState and TemplateRenderError to a new file * Handle CancellationException during template rendering in TemplateWidgetConfigureViewModel --- .../TemplateWidgetConfigureUiState.kt | 36 +++++++++++++++++++ .../TemplateWidgetConfigureViewModel.kt | 36 +++---------------- 2 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt new file mode 100644 index 00000000000..a81129fd482 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt @@ -0,0 +1,36 @@ +package io.homeassistant.companion.android.widgets.template + +import com.google.android.material.color.DynamicColors +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType + +/** + * Represents the UI state of the Template Widget configuration screen. + */ +data class TemplateWidgetConfigureUiState( + val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE, + val templateText: String = "", + val renderedTemplate: String? = null, + val isTemplateValid: Boolean = false, + val textSize: String = "14", + val selectedBackgroundType: WidgetBackgroundType = + if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + val textColorIndex: Int = 0, + val isUpdateWidget: Boolean = false, + val templateRenderError: TemplateRenderError? = null, +) + +/** + * Represents the type of error encountered when rendering a template. + */ +enum class TemplateRenderError { + /** Error in the template syntax itself. */ + TEMPLATE_ERROR, + + /** Error communicating with the server or rendering the template. */ + RENDER_ERROR, +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt index 2339689f68a..8c5f403c3a1 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -9,7 +9,6 @@ import androidx.annotation.VisibleForTesting import androidx.core.text.HtmlCompat 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.servers.ServerManager import io.homeassistant.companion.android.database.widget.TemplateWidgetDao @@ -19,6 +18,7 @@ import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.UPDATE_WIDGETS import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -46,26 +46,6 @@ import timber.log.Timber private const val RENDER_DEBOUNCE_MS = 500L private const val DEFAULT_TEXT_SIZE = 12.0f -/** - * Represents the UI state of the Template Widget configuration screen. - */ -data class TemplateWidgetConfigureUiState( - val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE, - val templateText: String = "", - val renderedTemplate: String? = null, - val isTemplateValid: Boolean = false, - val textSize: String = "14", - val selectedBackgroundType: WidgetBackgroundType = - if (DynamicColors.isDynamicColorAvailable()) { - WidgetBackgroundType.DYNAMICCOLOR - } else { - WidgetBackgroundType.DAYNIGHT - }, - val textColorIndex: Int = 0, - val isUpdateWidget: Boolean = false, - val templateRenderError: TemplateRenderError? = null, -) - @HiltViewModel class TemplateWidgetConfigureViewModel @Inject constructor( private val templateWidgetDao: TemplateWidgetDao, @@ -211,6 +191,9 @@ class TemplateWidgetConfigureViewModel @Inject constructor( it.copy(renderedTemplate = rendered, isTemplateValid = true, templateRenderError = null) } } catch (e: Exception) { + if (e is CancellationException) { + throw e + } if (e is SerializationException) { Timber.e(e, "Template syntax error") } else { @@ -311,14 +294,3 @@ class TemplateWidgetConfigureViewModel @Inject constructor( viewModelScope.launch { _errorMessage.emit(messageResId) } } } - -/** - * Represents the type of error encountered when rendering a template. - */ -enum class TemplateRenderError { - /** Error in the template syntax itself. */ - TEMPLATE_ERROR, - - /** Error communicating with the server or rendering the template. */ - RENDER_ERROR, -} \ No newline at end of file From bbce8d6613f7b598003c4a9b0caef88ce2685c05 Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 17:24:00 +0800 Subject: [PATCH 6/7] Improve template widget configuration and error handling * Do not catch `CancellationException` in `TemplateWidgetConfigureActivity` to ensure coroutines are cancelled properly * Update `TemplateWidgetConfigureViewModel` to use `Duration` for debounce and switch template rendering to `Dispatchers.Default` * Ensure widget configuration is updated even if a `widgetId` was already set in `onSetup` --- .../template/TemplateWidgetConfigureActivity.kt | 7 +++++-- .../template/TemplateWidgetConfigureViewModel.kt | 12 +++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 6ddee8191b1..9471007b5be 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -11,6 +11,7 @@ import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme import io.homeassistant.companion.android.util.getHexForColor +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @AndroidEntryPoint @@ -73,7 +74,8 @@ class TemplateWidgetConfigureActivity : BaseActivity() { try { viewModel.requestWidgetCreation(this@TemplateWidgetConfigureActivity) finish() - } catch (_: IllegalStateException) { + } catch (e: IllegalStateException) { + if (e is CancellationException) throw e showAddWidgetError() } } @@ -84,6 +86,7 @@ class TemplateWidgetConfigureActivity : BaseActivity() { setResult(RESULT_OK) finish() } catch (_: IllegalStateException) { + if (e is CancellationException) throw e showAddWidgetError() } } @@ -91,4 +94,4 @@ class TemplateWidgetConfigureActivity : BaseActivity() { private fun showAddWidgetError() { viewModel.showError(commonR.string.widget_creation_error) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt index 8c5f403c3a1..dc45c1731b3 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -19,6 +19,8 @@ import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.U import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -43,7 +45,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import timber.log.Timber -private const val RENDER_DEBOUNCE_MS = 500L +private const val RENDER_DEBOUNCE_MS = 500 private const val DEFAULT_TEXT_SIZE = 12.0f @HiltViewModel @@ -52,9 +54,6 @@ class TemplateWidgetConfigureViewModel @Inject constructor( private val serverManager: ServerManager, ) : ViewModel() { - @VisibleForTesting - internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO - private var supportedTextColors: List = emptyList() private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID @@ -78,7 +77,6 @@ class TemplateWidgetConfigureViewModel @Inject constructor( * configuration changes, since the ViewModel survives those changes. */ fun onSetup(widgetId: Int, supportedTextColors: List) { - if (this.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) return this.supportedTextColors = supportedTextColors this.widgetId = widgetId maybeLoadPreviousState(widgetId) @@ -152,7 +150,7 @@ class TemplateWidgetConfigureViewModel @Inject constructor( _uiState.mapField { it.templateText }, _uiState.mapField { it.selectedServerId }, ) { template, serverId -> template to serverId } - .debounce(RENDER_DEBOUNCE_MS) + .debounce(RENDER_DEBOUNCE_MS.milliseconds) .collect { (template, serverId) -> renderTemplate(template, serverId) } @@ -177,7 +175,7 @@ class TemplateWidgetConfigureViewModel @Inject constructor( Timber.w("Not rendering template because server is not set") return } - withContext(ioDispatcher) { + withContext(Dispatchers.Default) { try { val result = serverManager.integrationRepository(serverId) .renderTemplate(template, mapOf()) From 7a611cf5de516aa3618518d9a8ae1f3a4d11e458 Mon Sep 17 00:00:00 2001 From: amlwin Date: Wed, 18 Feb 2026 23:03:22 +0800 Subject: [PATCH 7/7] Refactor TemplateWidgetConfigureScreen and update UI components * Refactor `TemplateWidgetConfigureScreen` into smaller, more maintainable composables: `ServerSelectionSection`, `TemplateInputSection`, `TemplateRenderResult`, and `WidgetAppearanceSection`. * Replace standard `Button` with `HAAccentButton`. * Improve error handling in `TemplateWidgetConfigureActivity` when updating widget configurations. --- .../TemplateWidgetConfigureActivity.kt | 2 +- .../template/TemplateWidgetConfigureScreen.kt | 206 +++++++++++------- 2 files changed, 134 insertions(+), 74 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 9471007b5be..904ae47c926 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -85,7 +85,7 @@ class TemplateWidgetConfigureActivity : BaseActivity() { viewModel.updateWidgetConfiguration(this@TemplateWidgetConfigureActivity) setResult(RESULT_OK) finish() - } catch (_: IllegalStateException) { + } catch (e: IllegalStateException) { if (e is CancellationException) throw e showAddWidgetError() } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt index 628ab5b7801..5a7305f5414 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState @@ -27,8 +26,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.compose.composable.HAAccentButton import io.homeassistant.companion.android.common.compose.composable.HATextField +import io.homeassistant.companion.android.common.compose.theme.HADimens import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu @@ -116,89 +116,149 @@ private fun TemplateWidgetConfigureView( .padding(all = HADimens.SPACE4), verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), ) { - if (servers.size > 1) { - ServerExposedDropdownMenu( - servers = servers, - current = selectedServerId, - onSelected = { onServerSelected(it) }, - modifier = Modifier.padding(bottom = HADimens.SPACE2), - ) - } - - HATextField( - value = templateText, - onValueChange = onTemplateTextChanged, - placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, - modifier = Modifier.fillMaxWidth(), - maxLines = Int.MAX_VALUE, - singleLine = false, + ServerSelectionSection( + servers = servers, + selectedServerId = selectedServerId, + onServerSelected = onServerSelected, ) - if (renderedTemplate != null) { - Text( - text = renderedTemplate, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = HADimens.SPACE1), - ) - } else if (templateRenderError != null) { - Text( - text = stringResource( - when (templateRenderError) { - TemplateRenderError.TEMPLATE_ERROR -> commonR.string.template_error - TemplateRenderError.RENDER_ERROR -> commonR.string.template_render_error - }, - ), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = HADimens.SPACE1), - ) - } else if (templateText.isEmpty()) { - Text( - text = stringResource(commonR.string.empty_template), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = HADimens.SPACE1), - ) - } - - HATextField( - value = textSize, - onValueChange = onTextSizeChanged, - label = { Text(stringResource(commonR.string.widget_text_size_label)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true, + TemplateInputSection( + templateText = templateText, + onTemplateTextChanged = onTemplateTextChanged, + renderedTemplate = renderedTemplate, + templateRenderError = templateRenderError, ) - WidgetBackgroundTypeExposedDropdownMenu( - current = selectedBackgroundType, - onSelected = { onBackgroundTypeSelected(it) }, + WidgetAppearanceSection( + textSize = textSize, + onTextSizeChanged = onTextSizeChanged, + selectedBackgroundType = selectedBackgroundType, + onBackgroundTypeSelected = onBackgroundTypeSelected, + textColorIndex = textColorIndex, + onTextColorSelected = onTextColorSelected, ) - AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { - ExposedDropdownMenu( - label = stringResource(commonR.string.widget_text_color_label), - keys = listOf( - stringResource(commonR.string.widget_text_color_black), - stringResource(commonR.string.widget_text_color_white), - ), - currentIndex = textColorIndex, - onSelected = { onTextColorSelected(it) }, - ) - } - - Button( - modifier = Modifier.fillMaxWidth(), + HAAccentButton( + text = stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget), onClick = onActionClick, enabled = isTemplateValid, - ) { - Text(stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget)) - } + modifier = Modifier.fillMaxWidth(), + ) } } } +@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature +@Composable +private fun ServerSelectionSection( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, +) { + if (servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = selectedServerId, + onSelected = { onServerSelected(it) }, + modifier = Modifier.padding(bottom = HADimens.SPACE2), + ) + } +} + +@Composable +private fun TemplateInputSection( + templateText: String, + onTemplateTextChanged: (String) -> Unit, + renderedTemplate: String?, + templateRenderError: TemplateRenderError?, +) { + HATextField( + value = templateText, + onValueChange = onTemplateTextChanged, + placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = Int.MAX_VALUE, + singleLine = false, + ) + + TemplateRenderResult( + templateText = templateText, + renderedTemplate = renderedTemplate, + templateRenderError = templateRenderError, + ) +} + +@Composable +private fun TemplateRenderResult( + templateText: String, + renderedTemplate: String?, + templateRenderError: TemplateRenderError?, +) { + if (renderedTemplate != null) { + Text( + text = renderedTemplate, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } else if (templateRenderError != null) { + Text( + text = stringResource( + when (templateRenderError) { + TemplateRenderError.TEMPLATE_ERROR -> commonR.string.template_error + TemplateRenderError.RENDER_ERROR -> commonR.string.template_render_error + }, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } else if (templateText.isEmpty()) { + Text( + text = stringResource(commonR.string.empty_template), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } +} + +@Composable +private fun WidgetAppearanceSection( + textSize: String, + onTextSizeChanged: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, +) { + HATextField( + value = textSize, + onValueChange = onTextSizeChanged, + label = { Text(stringResource(commonR.string.widget_text_size_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { onBackgroundTypeSelected(it) }, + ) + + AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + ExposedDropdownMenu( + label = stringResource(commonR.string.widget_text_color_label), + keys = listOf( + stringResource(commonR.string.widget_text_color_black), + stringResource(commonR.string.widget_text_color_white), + ), + currentIndex = textColorIndex, + onSelected = { onTextColorSelected(it) }, + ) + } +} + @Preview @Composable private fun TemplateWidgetConfigureViewPreview() {