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..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
@@ -1,244 +1,97 @@
package io.homeassistant.companion.android.widgets.template
import android.appwidget.AppWidgetManager
-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 androidx.activity.compose.setContent
+import androidx.activity.viewModels
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.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.HomeAssistantAppTheme
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 kotlinx.coroutines.CancellationException
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?) {
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,
- )
-
- lifecycleScope.launch {
- val templateWidget = dao.get(appWidgetId)
-
- if (templateWidget?.serverId != null) {
- // Set server ID early for template rendering
- selectedServerId = templateWidget.serverId
- }
- setupServerSelect(templateWidget?.serverId)
+ viewModel.onSetup(widgetId = widgetId, supportedTextColors = supportedTextColors)
- 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() }
-
- binding.backgroundType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- binding.textColor.isVisible =
- parent?.adapter?.getItem(position) == getString(commonR.string.widget_background_type_transparent)
- }
-
- override fun onNothingSelected(parent: AdapterView<*>?) {
- binding.textColor.visibility = View.GONE
}
}
+ }
- binding.addButton.setOnClickListener {
+ private fun onActionClick(requestLauncherSetup: Boolean) {
+ lifecycleScope.launch {
if (requestLauncherSetup) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- lifecycleScope.launch {
- requestWidgetCreation()
- }
- } else {
- showAddWidgetError() // this shouldn't be possible
- }
+ requestPinWidget()
} 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",
- )
- }
-
- 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 requestPinWidget() {
+ try {
+ viewModel.requestWidgetCreation(this@TemplateWidgetConfigureActivity)
+ finish()
+ } catch (e: IllegalStateException) {
+ if (e is CancellationException) throw e
+ showAddWidgetError()
}
}
- private fun renderTemplateText(template: String) {
- val serverId = selectedServerId
- if (serverId == null) {
- Timber.w("Not rendering template because server is not set")
- return
+ private suspend fun onUpdateWidget() {
+ try {
+ viewModel.updateWidgetConfiguration(this@TemplateWidgetConfigureActivity)
+ setResult(RESULT_OK)
+ finish()
+ } catch (e: IllegalStateException) {
+ if (e is CancellationException) throw e
+ showAddWidgetError()
}
+ }
- 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
- }
- }
- binding.renderedTemplate.text =
- templateText?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) }
- binding.addButton.isEnabled = enabled && isValidServerId()
- }
+ private fun showAddWidgetError() {
+ viewModel.showError(commonR.string.widget_creation_error)
}
}
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..5a7305f5414
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt
@@ -0,0 +1,285 @@
+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.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
+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.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
+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()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ viewModel.errorMessage.collect { resId ->
+ snackbarHostState.showSnackbar(context.getString(resId))
+ }
+ }
+
+ 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,
+ snackbarHostState = snackbarHostState,
+ )
+}
+
+@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,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+) {
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ 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 = HADimens.SPACE4),
+ verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2),
+ ) {
+ ServerSelectionSection(
+ servers = servers,
+ selectedServerId = selectedServerId,
+ onServerSelected = onServerSelected,
+ )
+
+ TemplateInputSection(
+ templateText = templateText,
+ onTemplateTextChanged = onTemplateTextChanged,
+ renderedTemplate = renderedTemplate,
+ templateRenderError = templateRenderError,
+ )
+
+ WidgetAppearanceSection(
+ textSize = textSize,
+ onTextSizeChanged = onTextSizeChanged,
+ selectedBackgroundType = selectedBackgroundType,
+ onBackgroundTypeSelected = onBackgroundTypeSelected,
+ textColorIndex = textColorIndex,
+ onTextColorSelected = onTextColorSelected,
+ )
+
+ HAAccentButton(
+ text = stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget),
+ onClick = onActionClick,
+ enabled = isTemplateValid,
+ 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() {
+ 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 = {},
+ )
+ }
+}
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
new file mode 100644
index 00000000000..dc45c1731b3
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt
@@ -0,0 +1,294 @@
+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.core.text.HtmlCompat
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+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 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 kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.CoroutineDispatcher
+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
+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 = 500
+private const val DEFAULT_TEXT_SIZE = 12.0f
+
+@HiltViewModel
+class TemplateWidgetConfigureViewModel @Inject constructor(
+ private val templateWidgetDao: TemplateWidgetDao,
+ private val serverManager: ServerManager,
+) : ViewModel() {
+
+ private var supportedTextColors: List = emptyList()
+ private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
+
+ 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
+
+ /**
+ * 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) {
+ 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
+ },
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * 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() {
+ templateRenderingJob?.cancel()
+ templateRenderingJob = viewModelScope.launch {
+ combine(
+ _uiState.mapField { it.templateText },
+ _uiState.mapField { it.selectedServerId },
+ ) { template, serverId -> template to serverId }
+ .debounce(RENDER_DEBOUNCE_MS.milliseconds)
+ .collect { (template, serverId) ->
+ renderTemplate(template, serverId)
+ }
+ }
+ }
+
+ /**
+ * 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()) {
+ _uiState.update { it.copy(renderedTemplate = null, isTemplateValid = false) }
+ return
+ }
+ if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) {
+ Timber.w("Not rendering template because server is not set")
+ return
+ }
+ withContext(Dispatchers.Default) {
+ try {
+ val result = serverManager.integrationRepository(serverId)
+ .renderTemplate(template, mapOf())
+ .toString()
+ @Suppress("UNNECESSARY_SAFE_CALL")
+ val rendered = HtmlCompat.fromHtml(result, HtmlCompat.FROM_HTML_MODE_LEGACY)
+ ?.toString()
+ ?.trimEnd()
+ ?: result
+ _uiState.update {
+ 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 {
+ 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,
+ )
+ }
+ }
+ }
+ }
+
+ private fun getPendingDaoEntity(): TemplateWidgetEntity {
+ val state = _uiState.value
+ val textColor = if (state.selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) {
+ supportedTextColors.getOrNull(state.textColorIndex) ?: supportedTextColors.first()
+ } else {
+ null
+ }
+ return TemplateWidgetEntity(
+ id = widgetId,
+ serverId = state.selectedServerId,
+ template = state.templateText,
+ textSize = state.textSize.toFloatOrNull() ?: DEFAULT_TEXT_SIZE,
+ lastUpdate = "Loading",
+ backgroundType = state.selectedBackgroundType,
+ textColor = textColor,
+ )
+ }
+
+ /**
+ * Save the widget configuration to the database and update the widget.
+ *
+ * @param context the context used to send a broadcast to trigger widget update
+ * @throws IllegalStateException if the widget ID is invalid
+ */
+ suspend fun updateWidgetConfiguration(context: Context) {
+ if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ throw IllegalStateException("Widget ID is invalid")
+ }
+
+ val entity = getPendingDaoEntity()
+ templateWidgetDao.add(entity)
+
+ val intent = Intent(context, TemplateWidget::class.java)
+ intent.action = UPDATE_WIDGETS
+ context.sendBroadcast(intent)
+ }
+
+ /**
+ * 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(context: Context) {
+ val pendingEntity = getPendingDaoEntity()
+ templateWidgetDao.getWidgetCountFlow().drop(1).onStart {
+ 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()
+ }
+
+ /**
+ * 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) }
+ }
+}
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..59192f75cd6
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt
@@ -0,0 +1,283 @@
+package io.homeassistant.companion.android.widgets.template
+
+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.uiState.value.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()
+
+ 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
+ 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.uiState.value.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.onTemplateTextChanged("modified")
+ viewModel.onSetup(widgetId = 99, supportedTextColors = supportedTextColors)
+ advanceUntilIdle()
+
+ assertEquals("modified", viewModel.uiState.value.templateText)
+ }
+ }
+
+ @Nested
+ inner class ServerSelectionTest {
+ @Test
+ fun `Given different server when setServer then selectedServerId is updated`() {
+ viewModel.setServer(serverId = 5)
+
+ assertEquals(5, viewModel.uiState.value.selectedServerId)
+ }
+
+ @Test
+ fun `Given same server when setServer then no change`() {
+ viewModel.setServer(serverId = 5)
+ viewModel.setServer(serverId = 5)
+
+ assertEquals(5, viewModel.uiState.value.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.onTemplateTextChanged("{{ states('sensor.temp') }}")
+ advanceUntilIdle()
+
+ 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.onTemplateTextChanged("")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertNull(state.renderedTemplate)
+ assertFalse(state.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.onTemplateTextChanged("{{ invalid }}")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertNull(state.renderedTemplate)
+ assertFalse(state.isTemplateValid)
+ assertEquals(TemplateRenderError.RENDER_ERROR, state.templateRenderError)
+ }
+
+ @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.onTemplateTextChanged("{{ states('sensor.temp') }}")
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertNull(state.renderedTemplate)
+ assertFalse(state.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
+ val context: android.content.Context = mockk(relaxed = true)
+
+ viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors)
+ advanceUntilIdle()
+ viewModel.onTemplateTextChanged("test template")
+ viewModel.onTextSizeChanged("16")
+ viewModel.onBackgroundTypeSelected(WidgetBackgroundType.DAYNIGHT)
+
+ viewModel.updateWidgetConfiguration(context)
+
+ 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
+ val context: android.content.Context = mockk(relaxed = true)
+
+ viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors)
+ advanceUntilIdle()
+ viewModel.onTemplateTextChanged("test")
+ viewModel.onBackgroundTypeSelected(WidgetBackgroundType.TRANSPARENT)
+ viewModel.onTextColorSelected(1)
+
+ viewModel.updateWidgetConfiguration(context)
+
+ coVerify {
+ templateWidgetDao.add(
+ match {
+ it.textColor == "#FFFFFF" &&
+ it.backgroundType == WidgetBackgroundType.TRANSPARENT
+ },
+ )
+ }
+ }
+
+ @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(context)
+ } 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
+ val context: android.content.Context = mockk(relaxed = true)
+
+ viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors)
+ advanceUntilIdle()
+ viewModel.onTemplateTextChanged("test")
+ viewModel.onTextSizeChanged("invalid")
+
+ viewModel.updateWidgetConfiguration(context)
+
+ coVerify {
+ templateWidgetDao.add(match { it.textSize == 12.0f })
+ }
+ }
+ }
+}