diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt b/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt
index 1e80006a450..8b812154c32 100644
--- a/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt
+++ b/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt
@@ -1,12 +1,5 @@
package io.homeassistant.companion.android.settings.wear.views
-import android.graphics.Typeface
-import android.text.style.AbsoluteSizeSpan
-import android.text.style.CharacterStyle
-import android.text.style.ForegroundColorSpan
-import android.text.style.RelativeSizeSpan
-import android.text.style.StyleSpan
-import android.text.style.UnderlineSpan
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -26,24 +19,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
-import androidx.core.text.HtmlCompat.fromHtml
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.util.compose.parseHtml
import io.homeassistant.companion.android.util.intervalToString
import io.homeassistant.companion.android.util.safeBottomPaddingValues
@@ -130,35 +116,6 @@ fun SettingsWearTemplateTile(
}
}
-private fun parseHtml(renderedText: String) = buildAnnotatedString {
- // Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to
- val renderedSpanned =
- fromHtml(renderedText.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "
"), FROM_HTML_MODE_LEGACY)
- append(renderedSpanned.toString())
- renderedSpanned.getSpans(0, renderedSpanned.length, CharacterStyle::class.java).forEach { span ->
- val start = renderedSpanned.getSpanStart(span)
- val end = renderedSpanned.getSpanEnd(span)
- when (span) {
- is AbsoluteSizeSpan -> addStyle(SpanStyle(fontSize = span.size.sp), start, end)
- is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
- is RelativeSizeSpan -> {
- val defaultSize = 12
- addStyle(SpanStyle(fontSize = (span.sizeChange * defaultSize).sp), start, end)
- }
- is StyleSpan -> when (span.style) {
- Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
- Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
- Typeface.BOLD_ITALIC -> addStyle(
- SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
- start,
- end,
- )
- }
- is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
- }
- }
-}
-
@Preview
@Composable
private fun PreviewSettingsWearTemplateTile() {
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HtmlText.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HtmlText.kt
new file mode 100644
index 00000000000..8680b231b59
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HtmlText.kt
@@ -0,0 +1,56 @@
+package io.homeassistant.companion.android.util.compose
+
+import android.graphics.Typeface
+import android.text.style.AbsoluteSizeSpan
+import android.text.style.CharacterStyle
+import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.StyleSpan
+import android.text.style.UnderlineSpan
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.sp
+import androidx.core.text.HtmlCompat
+
+private val LINE_BREAK_REGEX = "(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex()
+private const val DEFAULT_RELATIVE_SIZE_SP = 12
+
+/**
+ * Converts HTML, as returned by a rendered Home Assistant template, into an [AnnotatedString] by
+ * translating the [android.text.Spanned] styles produced by [HtmlCompat.fromHtml] into Compose
+ * [SpanStyle]s. Shared by every screen that displays a rendered template.
+ */
+fun parseHtml(renderedText: String): AnnotatedString = buildAnnotatedString {
+ // Replace both actual and literal (escaped) line break characters with
+ val renderedSpanned = HtmlCompat.fromHtml(
+ renderedText.replace(LINE_BREAK_REGEX, "
"),
+ HtmlCompat.FROM_HTML_MODE_LEGACY,
+ )
+ append(renderedSpanned.toString())
+ renderedSpanned.getSpans(0, renderedSpanned.length, CharacterStyle::class.java).forEach { span ->
+ val start = renderedSpanned.getSpanStart(span)
+ val end = renderedSpanned.getSpanEnd(span)
+ when (span) {
+ is AbsoluteSizeSpan -> addStyle(SpanStyle(fontSize = span.size.sp), start, end)
+ is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
+ is RelativeSizeSpan -> {
+ addStyle(SpanStyle(fontSize = (span.sizeChange * DEFAULT_RELATIVE_SIZE_SP).sp), start, end)
+ }
+ is StyleSpan -> when (span.style) {
+ Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
+ Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
+ Typeface.BOLD_ITALIC -> addStyle(
+ SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
+ start,
+ end,
+ )
+ }
+ is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
+ }
+ }
+}
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 e6ef79c927c..f3f37ea8b18 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,245 +1,79 @@
package io.homeassistant.companion.android.widgets.template
import android.appwidget.AppWidgetManager
-import android.os.Build
+import android.content.Intent
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.common.R as commonR
-import io.homeassistant.companion.android.common.util.SdkVersion
-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 dagger.hilt.android.lifecycle.withCreationCallback
+import io.homeassistant.companion.android.BaseActivity
+import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel
-import io.homeassistant.companion.android.util.applySafeDrawingInsets
-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.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
-
- override val serverSelect: View
- get() = binding.serverSelect
-
- override val serverSelectList: Spinner
- get() = binding.serverSelectList
+class TemplateWidgetConfigureActivity : BaseActivity() {
+
+ private val widgetId: Int
+ get() = intent.extras?.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID,
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ private val viewModel: TemplateWidgetConfigureViewModel by viewModels(
+ extrasProducer = {
+ defaultViewModelCreationExtras.withCreationCallback { factory ->
+ factory.create(widgetId)
+ }
+ },
+ )
- private var requestLauncherSetup = false
+ private val requestLauncherSetup: Boolean
+ get() = intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true
- 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,
- )
- }
-
- // 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)
-
- 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 {
+ HATheme {
+ TemplateWidgetConfigureScreen(
+ viewModel = viewModel,
+ // The app sets the extra when it opens this screen itself, so there is
+ // something to go back to. The launcher opens it through the
+ // APPWIDGET_CONFIGURE filter instead, leaving nothing behind us.
+ canNavigateBack = requestLauncherSetup,
+ onNavigate = ::finish,
+ onActionClick = ::onActionClick,
)
- 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() {
+ lifecycleScope.launch {
if (requestLauncherSetup) {
- if (SdkVersion.isAtLeast(Build.VERSION_CODES.O)) {
- lifecycleScope.launch {
- requestWidgetCreation()
- }
- } else {
- showAddWidgetError() // this shouldn't be possible
- }
- } else {
- lifecycleScope.launch {
- updateWidget()
+ if (viewModel.requestWidgetCreation(this@TemplateWidgetConfigureActivity)) {
+ finish()
}
- }
- }
- }
-
- 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 fun renderTemplateText(template: String) {
- val serverId = selectedServerId
- if (serverId == null) {
- Timber.w("Not rendering template because server is not set")
- return
- }
-
- 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
- },
+ if (viewModel.updateWidgetConfiguration()) {
+ viewModel.updateWidget(this@TemplateWidgetConfigureActivity)
+ setResult(
+ RESULT_OK,
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId),
)
- enabled = false
+ finish()
}
}
- binding.renderedTemplate.text =
- templateText?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) }
- binding.addButton.isEnabled = enabled && isValidServerId()
}
}
}
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..43aa23d16cc
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt
@@ -0,0 +1,310 @@
+package io.homeassistant.companion.android.widgets.template
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+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.HADropdownItem
+import io.homeassistant.companion.android.common.compose.composable.HADropdownMenu
+import io.homeassistant.companion.android.common.compose.composable.HASettingsCard
+import io.homeassistant.companion.android.common.compose.composable.HATextField
+import io.homeassistant.companion.android.common.compose.composable.HATopBar
+import io.homeassistant.companion.android.common.compose.theme.HADimens
+import io.homeassistant.companion.android.common.compose.theme.HASize
+import io.homeassistant.companion.android.common.compose.theme.HATextStyle
+import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
+import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
+import io.homeassistant.companion.android.common.compose.theme.MaxButtonWidth
+import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
+import io.homeassistant.companion.android.util.compose.parseHtml
+import io.homeassistant.companion.android.util.previewServer1
+import io.homeassistant.companion.android.util.previewServer2
+import io.homeassistant.companion.android.widgets.WidgetBackgroundTypeDropdown
+import io.homeassistant.companion.android.widgets.WidgetTextColor
+import io.homeassistant.companion.android.widgets.WidgetTextColorDropdown
+
+private const val TEMPLATE_FIELD_MIN_LINES = 3
+
+/**
+ * Configuration screen of the template widget, bound to its [TemplateWidgetConfigureViewModel].
+ *
+ * @param canNavigateBack Whether leaving goes back to a previous screen, offering a back arrow
+ * instead of a close button.
+ */
+@Composable
+internal fun TemplateWidgetConfigureScreen(
+ viewModel: TemplateWidgetConfigureViewModel,
+ canNavigateBack: Boolean,
+ onNavigate: () -> Unit,
+ onActionClick: () -> Unit,
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val resources = LocalResources.current
+
+ LaunchedEffect(Unit) {
+ viewModel.errors.collect { resId ->
+ snackbarHostState.showSnackbar(resources.getString(resId))
+ }
+ }
+
+ TemplateWidgetConfigureContent(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ canNavigateBack = canNavigateBack,
+ onNavigate = onNavigate,
+ onServerSelected = viewModel::onServerSelected,
+ onTemplateChanged = viewModel::onTemplateChanged,
+ onTextSizeChanged = viewModel::onTextSizeChanged,
+ onBackgroundTypeSelected = viewModel::onBackgroundTypeSelected,
+ onTextColorSelected = viewModel::onTextColorSelected,
+ onActionClick = onActionClick,
+ )
+}
+
+/** Stateless configuration screen for the template widget. */
+@Composable
+internal fun TemplateWidgetConfigureContent(
+ state: TemplateWidgetConfigureState,
+ snackbarHostState: SnackbarHostState,
+ canNavigateBack: Boolean,
+ onNavigate: () -> Unit,
+ onServerSelected: (Int) -> Unit,
+ onTemplateChanged: (String) -> Unit,
+ onTextSizeChanged: (String) -> Unit,
+ onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit,
+ onTextColorSelected: (colorHex: String) -> Unit,
+ onActionClick: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ HATopBar(
+ title = { Text(stringResource(commonR.string.create_template)) },
+ onBackClick = onNavigate.takeIf { canNavigateBack },
+ onCloseClick = onNavigate.takeIf { !canNavigateBack },
+ )
+ },
+ snackbarHost = {
+ SnackbarHost(hostState = snackbarHostState)
+ },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ ) { contentPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(contentPadding)
+ .padding(HADimens.SPACE4)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(HADimens.SPACE4),
+ ) {
+ ServerSelector(
+ items = state.serversDropdownItems,
+ selectedServerId = state.selectedServerId,
+ showServerSelector = state.showServerSelector,
+ onServerSelected = onServerSelected,
+ )
+ TemplateSection(
+ template = state.template,
+ preview = state.preview,
+ onTemplateChanged = onTemplateChanged,
+ )
+ AppearanceSection(
+ textSize = state.textSize,
+ selectedBackgroundType = state.selectedBackgroundType,
+ dynamicColorAvailable = state.dynamicColorAvailable,
+ textColorHex = state.textColorHex,
+ onTextSizeChanged = onTextSizeChanged,
+ onBackgroundTypeSelected = onBackgroundTypeSelected,
+ onTextColorSelected = onTextColorSelected,
+ )
+ HAAccentButton(
+ text = stringResource(state.actionButtonLabel),
+ onClick = onActionClick,
+ modifier = Modifier.formControlWidth(),
+ enabled = state.isActionEnabled,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ServerSelector(
+ items: List>,
+ selectedServerId: Int,
+ showServerSelector: Boolean,
+ onServerSelected: (Int) -> Unit,
+) {
+ if (!showServerSelector) return
+
+ HADropdownMenu(
+ items = items,
+ selectedKey = selectedServerId,
+ onItemSelected = onServerSelected,
+ label = stringResource(commonR.string.server_select),
+ placeholder = stringResource(commonR.string.server_select),
+ modifier = Modifier.formControlWidth(),
+ )
+}
+
+@Composable
+private fun ColumnScope.TemplateSection(
+ template: String,
+ preview: TemplatePreview,
+ onTemplateChanged: (String) -> Unit,
+) {
+ HATextField(
+ value = template,
+ onValueChange = onTemplateChanged,
+ label = { Text(stringResource(commonR.string.template)) },
+ placeholder = { Text(stringResource(commonR.string.template_widget_default)) },
+ minLines = TEMPLATE_FIELD_MIN_LINES,
+ modifier = Modifier.formControlWidth(),
+ )
+
+ TemplatePreviewCard(preview = preview)
+}
+
+/** Shows [preview] labelled as such, so it doesn't get mistaken for another input field. */
+@Composable
+private fun TemplatePreviewCard(preview: TemplatePreview) {
+ val colorScheme = LocalHAColorScheme.current
+
+ HASettingsCard(modifier = Modifier.formControlWidth()) {
+ Column(verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(HADimens.SPACE2),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Visibility,
+ contentDescription = null,
+ tint = colorScheme.colorTextSecondary,
+ modifier = Modifier.size(HASize.M),
+ )
+ Text(
+ text = stringResource(commonR.string.template_preview_label),
+ style = HATextStyle.BodyMedium,
+ color = colorScheme.colorTextSecondary,
+ )
+ }
+ Text(
+ text = preview.toAnnotatedString(),
+ style = HATextStyle.BodyMedium,
+ color = colorScheme.colorTextPrimary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TemplatePreview.toAnnotatedString(): AnnotatedString = when (this) {
+ is TemplatePreview.Empty -> AnnotatedString(stringResource(commonR.string.empty_template))
+ is TemplatePreview.Error -> AnnotatedString(stringResource(messageRes))
+ // Only the parsing itself is remembered: stringResource() above can't be called from within
+ // remember's calculation lambda since it disallows composable calls.
+ is TemplatePreview.Rendered -> remember(text) { parseHtml(text) }
+}
+
+@Composable
+private fun ColumnScope.AppearanceSection(
+ textSize: String,
+ selectedBackgroundType: WidgetBackgroundType,
+ dynamicColorAvailable: Boolean,
+ textColorHex: String?,
+ onTextSizeChanged: (String) -> Unit,
+ onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit,
+ onTextColorSelected: (colorHex: String) -> Unit,
+) {
+ HATextField(
+ value = textSize,
+ onValueChange = onTextSizeChanged,
+ label = { Text(stringResource(commonR.string.widget_text_size_label)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ maxLines = 1,
+ modifier = Modifier.formControlWidth(),
+ )
+ WidgetBackgroundTypeDropdown(
+ selected = selectedBackgroundType,
+ dynamicColorAvailable = dynamicColorAvailable,
+ onSelected = onBackgroundTypeSelected,
+ modifier = Modifier.formControlWidth(),
+ )
+
+ if (selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) {
+ // Widgets persist the resolved hex, so the Context needed to convert stays in the UI layer.
+ val context = LocalContext.current
+ val selected = remember(context, textColorHex) { WidgetTextColor.fromHex(context, textColorHex) }
+
+ WidgetTextColorDropdown(
+ selected = selected,
+ onSelected = { onTextColorSelected(it.resolve(context)) },
+ modifier = Modifier.formControlWidth(),
+ )
+ }
+}
+
+private fun Modifier.formControlWidth(): Modifier = this
+ .widthIn(max = MaxButtonWidth)
+ .fillMaxWidth()
+
+@Preview
+@Composable
+private fun TemplateWidgetConfigureContentPreview() {
+ HAThemeForPreview {
+ TemplateWidgetConfigureContent(
+ state = previewTemplateWidgetConfigureState,
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = false,
+ onNavigate = {},
+ onServerSelected = {},
+ onTemplateChanged = {},
+ onTextSizeChanged = {},
+ onBackgroundTypeSelected = {},
+ onTextColorSelected = {},
+ onActionClick = {},
+ )
+ }
+}
+
+private val previewTemplateWidgetConfigureState = TemplateWidgetConfigureState(
+ selectedServerId = previewServer1.id,
+ serversDropdownItems = listOf(previewServer1, previewServer2).map {
+ HADropdownItem(key = it.id, label = it.friendlyName)
+ },
+ template = "{{ states('sensor.example') }}",
+ preview = TemplatePreview.Rendered("42"),
+ selectedBackgroundType = WidgetBackgroundType.TRANSPARENT,
+ dynamicColorAvailable = true,
+)
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureState.kt
new file mode 100644
index 00000000000..b824e91fb80
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureState.kt
@@ -0,0 +1,54 @@
+package io.homeassistant.companion.android.widgets.template
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Stable
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
+
+/** What the template preview area shows. */
+internal sealed interface TemplatePreview {
+ /** Nothing to render: the template is blank. */
+ data object Empty : TemplatePreview
+
+ /** The template was rendered successfully. */
+ data class Rendered(val text: String) : TemplatePreview
+
+ /** Rendering failed; [messageRes] explains why. */
+ data class Error(@StringRes val messageRes: Int) : TemplatePreview
+}
+
+@Stable
+internal data class TemplateWidgetConfigureState(
+ val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE,
+ val serversDropdownItems: List> = emptyList(),
+ val template: String = "",
+ val preview: TemplatePreview = TemplatePreview.Empty,
+ /** Whether a render for the current [template] is still in flight. */
+ val isRenderingPreview: Boolean = false,
+ val textSize: String = DEFAULT_TEXT_SIZE,
+ val selectedBackgroundType: WidgetBackgroundType = WidgetBackgroundType.DAYNIGHT,
+ val textColorHex: String? = null,
+ val dynamicColorAvailable: Boolean = false,
+ val isUpdateWidget: Boolean = false,
+) {
+ val showServerSelector = serversDropdownItems.size > 1 ||
+ serversDropdownItems.none { it.key == selectedServerId }
+
+ // Guards against saving a template that hasn't been (re-)validated yet: without
+ // `!isRenderingPreview`, editing an already-valid template would keep the action enabled
+ // using the *previous* render's result while the new one is still in flight.
+ val isActionEnabled = preview is TemplatePreview.Rendered && !isRenderingPreview && validTextSize != null
+
+ @StringRes
+ val actionButtonLabel = if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget
+
+ val textSizeOrDefault: Float
+ get() = validTextSize ?: DEFAULT_TEXT_SIZE.toFloat()
+
+ private val validTextSize: Float?
+ get() = textSize.toFloatOrNull()?.takeIf { it.isFinite() && it > 0 }
+}
+
+private const val DEFAULT_TEXT_SIZE = "12"
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..133add9f7c5
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt
@@ -0,0 +1,284 @@
+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.Context
+import android.content.Intent
+import android.os.Build
+import android.os.RemoteException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.material.color.DynamicColors
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.common.util.SdkVersion
+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
+import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.serialization.SerializationException
+import timber.log.Timber
+
+@HiltViewModel(assistedFactory = TemplateWidgetConfigureViewModel.Factory::class)
+class TemplateWidgetConfigureViewModel @AssistedInject constructor(
+ private val templateWidgetDao: TemplateWidgetDao,
+ private val serverManager: ServerManager,
+ @Assisted private val widgetId: Int,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(
+ TemplateWidgetConfigureState(
+ dynamicColorAvailable = DynamicColors.isDynamicColorAvailable(),
+ selectedBackgroundType = if (DynamicColors.isDynamicColorAvailable()) {
+ WidgetBackgroundType.DYNAMICCOLOR
+ } else {
+ WidgetBackgroundType.DAYNIGHT
+ },
+ ),
+ )
+ internal val state: StateFlow = _state.asStateFlow()
+
+ private val _errors = MutableSharedFlow(replay = 1)
+
+ /** Errors to surface to the user, as string resources. */
+ val errors = _errors.asSharedFlow()
+
+ private var renderJob: Job? = null
+
+ init {
+ viewModelScope.launch { restoreConfiguration() }
+
+ viewModelScope.launch {
+ serverManager.serversFlow.collect { servers ->
+ _state.update { current ->
+ current.copy(
+ serversDropdownItems = servers.map { server ->
+ HADropdownItem(key = server.id, label = server.friendlyName)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ fun onServerSelected(serverId: Int) {
+ if (serverId == _state.value.selectedServerId) return
+
+ _state.update { it.copy(selectedServerId = serverId) }
+ val template = _state.value.template
+ if (template.isNotBlank()) renderTemplate(template, serverId)
+ }
+
+ fun onTemplateChanged(value: String) {
+ _state.update { it.copy(template = value) }
+
+ // A whitespace-only template isn't worth rendering either
+ if (value.isBlank()) {
+ renderJob?.cancel()
+ _state.update { it.copy(preview = TemplatePreview.Empty, isRenderingPreview = false) }
+ } else {
+ renderTemplate(value, _state.value.selectedServerId)
+ }
+ }
+
+ fun onTextSizeChanged(value: String) {
+ _state.update { it.copy(textSize = value.filter(Char::isDigit)) }
+ }
+
+ fun onBackgroundTypeSelected(backgroundType: WidgetBackgroundType) {
+ _state.update { it.copy(selectedBackgroundType = backgroundType) }
+ }
+
+ fun onTextColorSelected(colorHex: String) {
+ _state.update { it.copy(textColorHex = colorHex) }
+ }
+
+ /**
+ * Persists the current configuration, reporting through [errors] and returning false when it
+ * cannot be saved.
+ */
+ suspend fun updateWidgetConfiguration(): Boolean {
+ val widget = getPendingDaoEntity()
+ if (widget == null) {
+ _errors.emit(commonR.string.widget_update_error)
+ return false
+ }
+
+ templateWidgetDao.add(widget)
+ return true
+ }
+
+ /** Asks the already placed widgets to redraw with the configuration that was just saved. */
+ fun updateWidget(context: Context) {
+ context.sendBroadcast(
+ Intent(context, TemplateWidget::class.java).apply {
+ action = BaseWidgetProvider.UPDATE_WIDGETS
+ },
+ )
+ }
+
+ /**
+ * Asks the launcher to pin the configured widget and suspends until it is added, reporting
+ * through [errors] and returning false when the widget cannot be requested at all.
+ */
+ @SuppressLint("NewApi") // The API 26 requirement is checked below before touching the pinning APIs.
+ suspend fun requestWidgetCreation(context: Context): Boolean {
+ if (!SdkVersion.isAtLeast(Build.VERSION_CODES.O)) {
+ Timber.e("Cannot pin the widget, pinning requires API ${Build.VERSION_CODES.O}")
+ _errors.emit(commonR.string.widget_creation_error)
+ return false
+ }
+
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val pinningSupported = try {
+ appWidgetManager.isRequestPinAppWidgetSupported
+ } catch (e: RemoteException) {
+ Timber.e(e, "Unable to read isRequestPinAppWidgetSupported")
+ false
+ }
+ if (!pinningSupported) {
+ Timber.e("Cannot pin the widget, the launcher does not support it")
+ _errors.emit(commonR.string.widget_creation_error)
+ return false
+ }
+
+ val widget = getPendingDaoEntity()
+ if (widget == null) {
+ _errors.emit(commonR.string.widget_creation_error)
+ return false
+ }
+
+ var requestAccepted = false
+ templateWidgetDao.getWidgetCountFlow()
+ // We drop the first value since we only care about knowing when the widget is actually added
+ .drop(1)
+ .onStart {
+ requestAccepted = 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, widget)
+ },
+ PendingIntent.FLAG_MUTABLE,
+ ),
+ )
+ // A rejected request never adds a widget, so emit to stop waiting for one
+ if (!requestAccepted) emit(0)
+ }.first()
+
+ if (!requestAccepted) {
+ Timber.e("The launcher rejected the widget pin request")
+ _errors.emit(commonR.string.widget_creation_error)
+ }
+ return requestAccepted
+ }
+
+ /**
+ * Restores the configuration of an existing widget, or falls back to the active server for a new one.
+ */
+ private suspend fun restoreConfiguration() {
+ val widget = templateWidgetDao.get(widgetId)
+
+ if (widget == null) {
+ _state.update {
+ it.copy(selectedServerId = serverManager.getServer()?.id ?: ServerManager.SERVER_ID_ACTIVE)
+ }
+ return
+ }
+
+ _state.update {
+ it.copy(
+ selectedServerId = widget.serverId,
+ template = widget.template,
+ textSize = widget.textSize.toInt().toString(),
+ selectedBackgroundType = widget.backgroundType,
+ textColorHex = widget.textColor,
+ isUpdateWidget = true,
+ )
+ }
+
+ if (widget.template.isNotBlank()) {
+ renderTemplate(widget.template, widget.serverId)
+ }
+ }
+
+ /** Renders [template] against [serverId], cancelling any render already in flight. */
+ private fun renderTemplate(template: String, serverId: Int) {
+ renderJob?.cancel()
+ // Marked before launching so `isActionEnabled` can't stay true on a stale render while
+ // this one is in flight (see `TemplateWidgetConfigureState.isActionEnabled`).
+ _state.update { it.copy(isRenderingPreview = true) }
+ renderJob = viewModelScope.launch {
+ val preview = try {
+ val rendered = serverManager.integrationRepository(serverId).renderTemplate(template, mapOf())
+ TemplatePreview.Rendered(rendered.toString())
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Exception while rendering template")
+ // A SerializationException suggests that the rendered result is not a String (= error)
+ TemplatePreview.Error(
+ if (e.cause is SerializationException) {
+ commonR.string.template_error
+ } else {
+ commonR.string.template_render_error
+ },
+ )
+ }
+ _state.update { it.copy(preview = preview, isRenderingPreview = false) }
+ }
+ }
+
+ /**
+ * Builds the widget to persist from the current configuration, or null when it is incomplete.
+ */
+ private suspend fun getPendingDaoEntity(): TemplateWidgetEntity? {
+ val current = _state.value
+ if (!current.isActionEnabled) {
+ Timber.e("Cannot build the widget, the current configuration is invalid")
+ return null
+ }
+
+ return TemplateWidgetEntity(
+ id = widgetId,
+ serverId = current.selectedServerId,
+ template = current.template,
+ textSize = current.textSizeOrDefault,
+ lastUpdate = templateWidgetDao.get(widgetId)?.lastUpdate ?: "Loading",
+ backgroundType = current.selectedBackgroundType,
+ textColor = current.textColorHex.takeIf {
+ current.selectedBackgroundType == WidgetBackgroundType.TRANSPARENT
+ },
+ )
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(widgetId: Int): TemplateWidgetConfigureViewModel
+ }
+}
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/screenshotTest/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest.kt
new file mode 100644
index 00000000000..182f030f7bd
--- /dev/null
+++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest.kt
@@ -0,0 +1,113 @@
+package io.homeassistant.companion.android.widgets.template
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.android.tools.screenshot.PreviewTest
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
+import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
+import io.homeassistant.companion.android.util.compose.HAPreviews
+import io.homeassistant.companion.android.util.previewServer1
+import io.homeassistant.companion.android.util.previewServer2
+
+class TemplateWidgetConfigureScreenshotTest {
+
+ @PreviewTest
+ @HAPreviews
+ @Composable
+ fun `TemplateWidgetConfigureContent rendered template`() {
+ HAThemeForPreview {
+ TemplateWidgetConfigureContent(
+ state = previewTemplateWidgetConfigureState,
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = false,
+ onNavigate = {},
+ onServerSelected = {},
+ onTemplateChanged = {},
+ onTextSizeChanged = {},
+ onBackgroundTypeSelected = {},
+ onTextColorSelected = {},
+ onActionClick = {},
+ )
+ }
+ }
+
+ @PreviewTest
+ @HAPreviews
+ @Composable
+ fun `TemplateWidgetConfigureContent empty template`() {
+ HAThemeForPreview {
+ TemplateWidgetConfigureContent(
+ state = previewTemplateWidgetConfigureState.copy(template = "", preview = TemplatePreview.Empty),
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = false,
+ onNavigate = {},
+ onServerSelected = {},
+ onTemplateChanged = {},
+ onTextSizeChanged = {},
+ onBackgroundTypeSelected = {},
+ onTextColorSelected = {},
+ onActionClick = {},
+ )
+ }
+ }
+
+ @PreviewTest
+ @HAPreviews
+ @Composable
+ fun `TemplateWidgetConfigureContent template error`() {
+ HAThemeForPreview {
+ TemplateWidgetConfigureContent(
+ state = previewTemplateWidgetConfigureState.copy(
+ preview = TemplatePreview.Error(commonR.string.template_render_error),
+ ),
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = false,
+ onNavigate = {},
+ onServerSelected = {},
+ onTemplateChanged = {},
+ onTextSizeChanged = {},
+ onBackgroundTypeSelected = {},
+ onTextColorSelected = {},
+ onActionClick = {},
+ )
+ }
+ }
+
+ @PreviewTest
+ @HAPreviews
+ @Composable
+ fun `TemplateWidgetConfigureContent single server`() {
+ HAThemeForPreview {
+ TemplateWidgetConfigureContent(
+ state = previewTemplateWidgetConfigureState.copy(
+ serversDropdownItems = listOf(
+ HADropdownItem(key = previewServer1.id, label = previewServer1.friendlyName),
+ ),
+ ),
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = false,
+ onNavigate = {},
+ onServerSelected = {},
+ onTemplateChanged = {},
+ onTextSizeChanged = {},
+ onBackgroundTypeSelected = {},
+ onTextColorSelected = {},
+ onActionClick = {},
+ )
+ }
+ }
+}
+
+private val previewTemplateWidgetConfigureState = TemplateWidgetConfigureState(
+ selectedServerId = previewServer1.id,
+ serversDropdownItems = listOf(previewServer1, previewServer2).map {
+ HADropdownItem(key = it.id, label = it.friendlyName)
+ },
+ template = "{{ states('sensor.temperature') }}\n{{ states('sensor.humidity') }}",
+ preview = TemplatePreview.Rendered("22.5°C\nHumidity: 45%"),
+ selectedBackgroundType = WidgetBackgroundType.TRANSPARENT,
+ dynamicColorAvailable = true,
+)
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_foldable_c908f502_0.png
new file mode 100644
index 00000000000..5842b572948
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_foldable_c908f502_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_e05166be_0.png
new file mode 100644
index 00000000000..17043d3dc1a
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_e05166be_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_landscape_9e00b29d_0.png
new file mode 100644
index 00000000000..fc161724f5e
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_phone_landscape_9e00b29d_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_small_phone_66e7bbf2_0.png
new file mode 100644
index 00000000000..d50015d29b3
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_small_phone_66e7bbf2_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_2f22c4ea_0.png
new file mode 100644
index 00000000000..ce2f6538f92
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_2f22c4ea_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_landscape_62cae397_0.png
new file mode 100644
index 00000000000..c7f5efc0c52
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent empty template_tablet_landscape_62cae397_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_foldable_c908f502_0.png
new file mode 100644
index 00000000000..a739860845e
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_foldable_c908f502_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_e05166be_0.png
new file mode 100644
index 00000000000..bbc9e0c4fd0
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_e05166be_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_landscape_9e00b29d_0.png
new file mode 100644
index 00000000000..ce52ff458c3
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_phone_landscape_9e00b29d_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_small_phone_66e7bbf2_0.png
new file mode 100644
index 00000000000..01a5fb3334d
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_small_phone_66e7bbf2_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_2f22c4ea_0.png
new file mode 100644
index 00000000000..156c7057221
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_2f22c4ea_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_landscape_62cae397_0.png
new file mode 100644
index 00000000000..7a50b09a28a
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent rendered template_tablet_landscape_62cae397_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_foldable_c908f502_0.png
new file mode 100644
index 00000000000..ed6c6d17681
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_foldable_c908f502_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_e05166be_0.png
new file mode 100644
index 00000000000..7385f11a5ab
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_e05166be_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_landscape_9e00b29d_0.png
new file mode 100644
index 00000000000..f2fb39a5b2b
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_phone_landscape_9e00b29d_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_small_phone_66e7bbf2_0.png
new file mode 100644
index 00000000000..222a6e7626a
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_small_phone_66e7bbf2_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_2f22c4ea_0.png
new file mode 100644
index 00000000000..2d08212025e
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_2f22c4ea_0.png differ
diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_landscape_62cae397_0.png
new file mode 100644
index 00000000000..5c58b52247e
Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest/TemplateWidgetConfigureContent template error_tablet_landscape_62cae397_0.png differ
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/HtmlTextTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/HtmlTextTest.kt
new file mode 100644
index 00000000000..49156998daa
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/HtmlTextTest.kt
@@ -0,0 +1,113 @@
+package io.homeassistant.companion.android.util.compose
+
+import android.graphics.Color as AndroidColor
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.sp
+import dagger.hilt.android.testing.HiltTestApplication
+import org.junit.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = HiltTestApplication::class)
+class HtmlTextTest {
+
+ @Test
+ fun `Given plain text when parsing html then it is returned unstyled`() {
+ val result = parseHtml("hello world")
+
+ assertEquals("hello world", result.text)
+ assertTrue(result.spanStyles.isEmpty())
+ }
+
+ @Test
+ fun `Given an empty string when parsing html then an empty AnnotatedString is returned`() {
+ val result = parseHtml("")
+
+ assertEquals("", result.text)
+ assertTrue(result.spanStyles.isEmpty())
+ }
+
+ @Test
+ fun `Given bold html when parsing then a bold span is applied to the range`() {
+ val result = parseHtml("hello")
+
+ assertEquals("hello", result.text)
+ val span = result.spanStyles.single()
+ assertEquals(0, span.start)
+ assertEquals(5, span.end)
+ assertEquals(FontWeight.Bold, span.item.fontWeight)
+ }
+
+ @Test
+ fun `Given italic html when parsing then an italic span is applied to the range`() {
+ val result = parseHtml("hello")
+
+ assertEquals("hello", result.text)
+ val span = result.spanStyles.single()
+ assertEquals(FontStyle.Italic, span.item.fontStyle)
+ }
+
+ @Test
+ fun `Given nested bold and italic html when parsing then both styles are applied`() {
+ val result = parseHtml("hello")
+
+ assertEquals("hello", result.text)
+ assertTrue(result.spanStyles.any { it.item.fontWeight == FontWeight.Bold })
+ assertTrue(result.spanStyles.any { it.item.fontStyle == FontStyle.Italic })
+ }
+
+ @Test
+ fun `Given underline html when parsing then an underline span is applied`() {
+ val result = parseHtml("hello")
+
+ assertEquals("hello", result.text)
+ val span = result.spanStyles.single()
+ assertEquals(TextDecoration.Underline, span.item.textDecoration)
+ }
+
+ @Test
+ fun `Given a font color html when parsing then a matching color span is applied`() {
+ val result = parseHtml("hello")
+
+ assertEquals("hello", result.text)
+ val span = result.spanStyles.single()
+ assertEquals(Color(AndroidColor.parseColor("#ff0000")), span.item.color)
+ }
+
+ @Test
+ fun `Given a big tag when parsing then a larger relative font size span is applied`() {
+ val result = parseHtml("hello")
+
+ val span = result.spanStyles.single()
+ assertEquals((1.25f * 12).sp, span.item.fontSize)
+ }
+
+ @Test
+ fun `Given a small tag when parsing then a smaller relative font size span is applied`() {
+ val result = parseHtml("hello")
+
+ val span = result.spanStyles.single()
+ assertEquals((0.8f * 12).sp, span.item.fontSize)
+ }
+
+ @Test
+ fun `Given a real line break when parsing then it is preserved as a line break`() {
+ val result = parseHtml("line1\nline2")
+
+ assertEquals("line1\nline2", result.text)
+ }
+
+ @Test
+ fun `Given a literal escaped line break when parsing then it is rendered as a line break`() {
+ val result = parseHtml("line1\\nline2")
+
+ assertEquals("line1\nline2", result.text)
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenTest.kt
new file mode 100644
index 00000000000..415a39dae2b
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenTest.kt
@@ -0,0 +1,295 @@
+package io.homeassistant.companion.android.widgets.template
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextInput
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
+import io.homeassistant.companion.android.HiltComponentActivity
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+import io.homeassistant.companion.android.common.compose.theme.HATheme
+import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = HiltTestApplication::class)
+@HiltAndroidTest
+class TemplateWidgetConfigureScreenTest {
+
+ @get:Rule(order = 0)
+ val hiltRule = HiltAndroidRule(this)
+
+ @get:Rule(order = 1)
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun `Given no rendered template when displayed then the action is disabled`() {
+ composeTestRule.apply {
+ testScreen(singleServerState) {
+ onNodeWithText(activity.getString(commonR.string.add_widget))
+ .performScrollTo()
+ .assertIsNotEnabled()
+ }
+ }
+ }
+
+ @Test
+ fun `Given a rendered template when displayed then the action is enabled`() {
+ composeTestRule.apply {
+ testScreen(renderedState) {
+ onNodeWithText(activity.getString(commonR.string.add_widget))
+ .performScrollTo()
+ .assertIsEnabled()
+ }
+ }
+ }
+
+ @Test
+ fun `Given an existing widget when displayed then the update action is shown`() {
+ composeTestRule.apply {
+ testScreen(renderedState.copy(isUpdateWidget = true)) {
+ onNodeWithText(activity.getString(commonR.string.update_widget))
+ .performScrollTo()
+ .assertIsEnabled()
+ }
+ }
+ }
+
+ @Test
+ fun `Given the screen is the task root when displayed then it offers close`() {
+ composeTestRule.apply {
+ testScreen(singleServerState, canNavigateBack = false) {
+ onNodeWithContentDescription(activity.getString(commonR.string.navigate_up)).assertDoesNotExist()
+ onNodeWithContentDescription(activity.getString(commonR.string.close)).performClick()
+ assertTrue(navigated)
+ }
+ }
+ }
+
+ @Test
+ fun `Given the screen has a back stack when displayed then it offers back`() {
+ composeTestRule.apply {
+ testScreen(singleServerState, canNavigateBack = true) {
+ onNodeWithContentDescription(activity.getString(commonR.string.close)).assertDoesNotExist()
+ onNodeWithContentDescription(activity.getString(commonR.string.navigate_up)).performClick()
+ assertTrue(navigated)
+ }
+ }
+ }
+
+ @Test
+ fun `Given a valid configuration when the action is clicked then onActionClick is triggered`() {
+ composeTestRule.apply {
+ testScreen(renderedState) {
+ onNodeWithText(activity.getString(commonR.string.add_widget))
+ .performScrollTo()
+ .performClick()
+ assertTrue(actionClicked)
+ }
+ }
+ }
+
+ @Test
+ fun `Given a single server when displayed then the server selector is hidden`() {
+ composeTestRule.apply {
+ testScreen(singleServerState) {
+ onNodeWithText(activity.getString(commonR.string.server_select)).assertDoesNotExist()
+ }
+ }
+ }
+
+ @Test
+ fun `Given several servers when displayed then the server selector is shown`() {
+ composeTestRule.apply {
+ testScreen(multipleServersState) {
+ onNodeWithText(activity.getString(commonR.string.server_select), substring = true)
+ .assertIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun `Given several servers when a server is picked then onServerSelected is triggered`() {
+ composeTestRule.apply {
+ testScreen(multipleServersState) {
+ onNodeWithText(activity.getString(commonR.string.server_select), substring = true)
+ .performClick()
+ onNodeWithText("Vacation home").performClick()
+ assertEquals(2, serverSelected)
+ }
+ }
+ }
+
+ @Test
+ fun `Given screen when typing a template then onTemplateChanged is triggered`() {
+ composeTestRule.apply {
+ testScreen(singleServerState) {
+ onNodeWithText(activity.getString(commonR.string.template), substring = true)
+ .performTextInput("{{ 1 }}")
+ assertEquals("{{ 1 }}", template)
+ }
+ }
+ }
+
+ @Test
+ fun `Given screen when typing a text size then onTextSizeChanged is triggered`() {
+ composeTestRule.apply {
+ testScreen(singleServerState) {
+ onNodeWithText(activity.getString(commonR.string.widget_text_size_label), substring = true)
+ .performScrollTo()
+ .performTextInput("24")
+ assertEquals("24", textSize)
+ }
+ }
+ }
+
+ @Test
+ fun `Given an empty template when displayed then the preview shows the empty message`() {
+ composeTestRule.apply {
+ testScreen(singleServerState) {
+ onNodeWithText(activity.getString(commonR.string.empty_template)).assertIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun `Given a rendered template when displayed then the preview shows the rendered text`() {
+ composeTestRule.apply {
+ testScreen(renderedState) {
+ onNodeWithText("42").assertIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun `Given a template render error when displayed then the preview shows the error message`() {
+ composeTestRule.apply {
+ testScreen(
+ singleServerState.copy(
+ template = "{{ broken",
+ preview = TemplatePreview.Error(commonR.string.template_render_error),
+ ),
+ ) {
+ onNodeWithText(activity.getString(commonR.string.template_render_error)).assertIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun `Given a non transparent background when displayed then the text color dropdown is hidden`() {
+ composeTestRule.apply {
+ testScreen(singleServerState.copy(selectedBackgroundType = WidgetBackgroundType.DAYNIGHT)) {
+ onNodeWithText(activity.getString(commonR.string.widget_text_color_label)).assertDoesNotExist()
+ }
+ }
+ }
+
+ @Test
+ fun `Given a transparent background when displayed then the text color dropdown is shown`() {
+ composeTestRule.apply {
+ testScreen(singleServerState.copy(selectedBackgroundType = WidgetBackgroundType.TRANSPARENT)) {
+ onNodeWithText(activity.getString(commonR.string.widget_text_color_label), substring = true)
+ .performScrollTo()
+ .assertIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun `Given screen when a background type is picked then onBackgroundTypeSelected is triggered`() {
+ composeTestRule.apply {
+ testScreen(singleServerState.copy(selectedBackgroundType = WidgetBackgroundType.DAYNIGHT)) {
+ onNodeWithText(activity.getString(commonR.string.widget_background_type_label), substring = true)
+ .performScrollTo()
+ .performClick()
+ onNodeWithText(activity.getString(commonR.string.widget_background_type_transparent))
+ .performClick()
+ assertEquals(WidgetBackgroundType.TRANSPARENT, backgroundType)
+ }
+ }
+ }
+
+ @Test
+ fun `Given a transparent background when a text color is picked then onTextColorSelected is triggered`() {
+ composeTestRule.apply {
+ testScreen(singleServerState.copy(selectedBackgroundType = WidgetBackgroundType.TRANSPARENT)) {
+ onNodeWithText(activity.getString(commonR.string.widget_text_color_label), substring = true)
+ .performScrollTo()
+ .performClick()
+ onNodeWithText(activity.getString(commonR.string.widget_text_color_black))
+ .performClick()
+ assertTrue(textColor != null)
+ }
+ }
+ }
+
+ private class TestHelper {
+ var serverSelected: Int? = null
+ var template: String? = null
+ var textSize: String? = null
+ var backgroundType: WidgetBackgroundType? = null
+ var textColor: String? = null
+ var actionClicked = false
+ var navigated = false
+ }
+
+ private fun AndroidComposeTestRule<*, *>.testScreen(
+ state: TemplateWidgetConfigureState,
+ canNavigateBack: Boolean = false,
+ dsl: TestHelper.() -> Unit,
+ ) {
+ TestHelper().apply {
+ setContent {
+ HATheme {
+ TemplateWidgetConfigureContent(
+ state = state,
+ snackbarHostState = remember { SnackbarHostState() },
+ canNavigateBack = canNavigateBack,
+ onNavigate = { navigated = true },
+ onServerSelected = { serverSelected = it },
+ onTemplateChanged = { template = it },
+ onTextSizeChanged = { textSize = it },
+ onBackgroundTypeSelected = { backgroundType = it },
+ onTextColorSelected = { textColor = it },
+ onActionClick = { actionClicked = true },
+ )
+ }
+ }
+ dsl()
+ }
+ }
+}
+
+private val singleServerState = TemplateWidgetConfigureState(
+ serversDropdownItems = listOf(HADropdownItem(key = 1, label = "Home")),
+ selectedServerId = 1,
+)
+
+private val multipleServersState = singleServerState.copy(
+ serversDropdownItems = listOf(
+ HADropdownItem(key = 1, label = "Home"),
+ HADropdownItem(key = 2, label = "Vacation home"),
+ ),
+)
+
+private val renderedState = singleServerState.copy(
+ template = "{{ states('sensor.example') }}",
+ preview = TemplatePreview.Rendered("42"),
+)
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..8e97f4cca23
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt
@@ -0,0 +1,258 @@
+package io.homeassistant.companion.android.widgets.template
+
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+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.MainDispatcherJUnit5Extension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+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.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/** Hex of `colorWidgetButtonLabelBlack`, which is what the widget persists. */
+private const val BLACK_HEX = "#3A3A3A"
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MainDispatcherJUnit5Extension::class)
+class TemplateWidgetConfigureViewModelTest {
+
+ private val dao = mockk(relaxUnitFun = true)
+ private val integrationRepository = mockk()
+ private val serverManager = mockk()
+
+ private val widgetId = 42
+ private val serverId = 1
+ private val server = mockk {
+ every { id } returns serverId
+ every { friendlyName } returns "Home"
+ }
+
+ @BeforeEach
+ fun setUp() {
+ every { serverManager.serversFlow } returns flowOf(listOf(server))
+ coEvery { serverManager.integrationRepository(any()) } returns integrationRepository
+ coEvery { serverManager.getServer() } returns server
+ coEvery { dao.get(any()) } returns null
+ every { dao.getWidgetCountFlow() } returns flowOf(0)
+ }
+
+ @Test
+ fun `Given an existing widget when created then persisted configuration is restored`() = runTest {
+ coEvery { dao.get(widgetId) } returns createWidgetEntity()
+ coEvery { integrationRepository.renderTemplate("{{ states('sensor.temp') }}", emptyMap()) } returns "21.5"
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.state.value
+ assertTrue(state.isUpdateWidget)
+ assertEquals(serverId, state.selectedServerId)
+ assertEquals("{{ states('sensor.temp') }}", state.template)
+ assertEquals("21", state.textSize)
+ assertEquals(WidgetBackgroundType.TRANSPARENT, state.selectedBackgroundType)
+ assertEquals(BLACK_HEX, state.textColorHex)
+ assertEquals(TemplatePreview.Rendered("21.5"), state.preview)
+ assertTrue(state.isActionEnabled)
+ }
+
+ @Test
+ fun `Given an existing widget with a blank template when created then nothing is rendered`() = runTest {
+ coEvery { dao.get(widgetId) } returns createWidgetEntity(template = "")
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ assertEquals(TemplatePreview.Empty, viewModel.state.value.preview)
+ assertFalse(viewModel.state.value.isActionEnabled)
+ coVerify(exactly = 0) { integrationRepository.renderTemplate(any(), any()) }
+ }
+
+ @Test
+ fun `Given servers when created then they are exposed as dropdown items`() = runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ assertEquals(
+ listOf(HADropdownItem(key = serverId, label = "Home")),
+ viewModel.state.value.serversDropdownItems,
+ )
+ }
+
+ @Test
+ fun `Given a template when changed then it is rendered against the selected server`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 + 1 }}", emptyMap()) } returns "2"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged("{{ 1 + 1 }}")
+ advanceUntilIdle()
+
+ assertEquals(TemplatePreview.Rendered("2"), viewModel.state.value.preview)
+ assertTrue(viewModel.state.value.isActionEnabled)
+ coVerify(exactly = 1) { integrationRepository.renderTemplate("{{ 1 + 1 }}", emptyMap()) }
+ }
+
+ @Test
+ fun `Given a rendered template with an emptied text size then the action is disabled`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) } returns "1"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.onTemplateChanged("{{ 1 }}")
+ advanceUntilIdle()
+ assertTrue(viewModel.state.value.isActionEnabled)
+
+ viewModel.onTextSizeChanged("")
+
+ assertFalse(viewModel.state.value.isActionEnabled)
+ }
+
+ @Test
+ fun `Given a template cleared when changed then no render is invoked`() = runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged("")
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { integrationRepository.renderTemplate(any(), any()) }
+ }
+
+ @Test
+ fun `Given a rendered template when changed again then action is disabled until the new render completes`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) } returns "1"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.onTemplateChanged("{{ 1 }}")
+ advanceUntilIdle()
+ assertTrue(viewModel.state.value.isActionEnabled)
+
+ coEvery { integrationRepository.renderTemplate("{{ 2 }}", emptyMap()) } returns "2"
+ viewModel.onTemplateChanged("{{ 2 }}")
+
+ // The new render hasn't completed yet: the stale "1" preview must not keep the action enabled.
+ assertEquals(TemplatePreview.Rendered("1"), viewModel.state.value.preview)
+ assertFalse(viewModel.state.value.isActionEnabled)
+
+ advanceUntilIdle()
+
+ assertEquals(TemplatePreview.Rendered("2"), viewModel.state.value.preview)
+ assertTrue(viewModel.state.value.isActionEnabled)
+ // The stale "1" render must not be re-collected once superseded, i.e. it was cancelled
+ // rather than left to complete and race with the new one.
+ coVerify(exactly = 1) { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) }
+ coVerify(exactly = 1) { integrationRepository.renderTemplate("{{ 2 }}", emptyMap()) }
+ }
+
+ @Test
+ fun `Given a template cleared when changed then the preview goes back to empty`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) } returns "1"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.onTemplateChanged("{{ 1 }}")
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged("")
+
+ assertEquals(TemplatePreview.Empty, viewModel.state.value.preview)
+ assertFalse(viewModel.state.value.isActionEnabled)
+ // Only the earlier "{{ 1 }}" render happened; clearing the template didn't trigger another.
+ coVerify(exactly = 1) { integrationRepository.renderTemplate(any(), any()) }
+ }
+
+ @Test
+ fun `Given a whitespace-only template when changed then the preview goes back to empty`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) } returns "1"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.onTemplateChanged("{{ 1 }}")
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged(" ")
+
+ assertEquals(TemplatePreview.Empty, viewModel.state.value.preview)
+ assertFalse(viewModel.state.value.isActionEnabled)
+ // Only the earlier "{{ 1 }}" render happened; the whitespace-only change didn't trigger another.
+ coVerify(exactly = 1) { integrationRepository.renderTemplate(any(), any()) }
+ }
+
+ @Test
+ fun `Given a template that fails to render then the error is exposed`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ broken", emptyMap()) } throws RuntimeException("boom")
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged("{{ broken")
+ advanceUntilIdle()
+
+ assertTrue(viewModel.state.value.preview is TemplatePreview.Error)
+ assertFalse(viewModel.state.value.isActionEnabled)
+ }
+
+ @Test
+ fun `Given valid selections when configuration is saved then widget data is persisted`() = runTest {
+ coEvery { integrationRepository.renderTemplate("{{ 1 }}", emptyMap()) } returns "1"
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.onTemplateChanged("{{ 1 }}")
+ advanceUntilIdle()
+ viewModel.onTextSizeChanged("18")
+ viewModel.onBackgroundTypeSelected(WidgetBackgroundType.TRANSPARENT)
+ viewModel.onTextColorSelected(BLACK_HEX)
+ advanceUntilIdle()
+
+ assertTrue(viewModel.updateWidgetConfiguration())
+
+ coVerify {
+ dao.add(
+ TemplateWidgetEntity(
+ id = widgetId,
+ serverId = serverId,
+ template = "{{ 1 }}",
+ textSize = 18F,
+ lastUpdate = "Loading",
+ backgroundType = WidgetBackgroundType.TRANSPARENT,
+ textColor = BLACK_HEX,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `Given an invalid configuration when configuration is saved then it is rejected`() = runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ assertFalse(viewModel.updateWidgetConfiguration())
+ }
+
+ private fun createViewModel() = TemplateWidgetConfigureViewModel(
+ templateWidgetDao = dao,
+ serverManager = serverManager,
+ widgetId = widgetId,
+ )
+
+ private fun createWidgetEntity(template: String = "{{ states('sensor.temp') }}") = TemplateWidgetEntity(
+ id = widgetId,
+ serverId = serverId,
+ template = template,
+ textSize = 21F,
+ lastUpdate = "on",
+ backgroundType = WidgetBackgroundType.TRANSPARENT,
+ textColor = BLACK_HEX,
+ )
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HATextField.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HATextField.kt
index d76093569e2..63849816866 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HATextField.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HATextField.kt
@@ -43,9 +43,12 @@ import io.homeassistant.companion.android.common.compose.theme.MaxButtonWidth
* is called. Note that this IME action may be different from what you specified in
* [KeyboardOptions.imeAction]
* @param maxLines the maximum height in terms of maximum number of visible lines. If [singleLine]
+ * is set to `true`, this value will be ignored. Defaults [singleLine] to `true` when set to 1
+ * @param minLines the minimum height in terms of minimum number of visible lines. If [singleLine]
* is set to `true`, this value will be ignored
* @param singleLine when `true`, this text field becomes a single horizontally scrolling text field
- * instead of wrapping onto multiple lines. [maxLines] will be ignored and automatically set to 1
+ * instead of wrapping onto multiple lines. [maxLines] will be ignored and automatically set to 1.
+ * Defaults to `true` when [maxLines] is 1
*/
@Composable
fun HATextField(
@@ -62,6 +65,7 @@ fun HATextField(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
maxLines: Int = Int.MAX_VALUE,
+ minLines: Int = 1,
singleLine: Boolean = maxLines == 1,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
@@ -75,6 +79,7 @@ fun HATextField(
leadingIcon = leadingIcon,
shape = RoundedCornerShape(size = HARadius.M),
maxLines = maxLines,
+ minLines = minLines,
singleLine = singleLine,
// The color is controlled from the [colors] attribute
textStyle = HATextStyle.UserInput.copy(color = Color.Unspecified),
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
index 43c3371ab7f..5da59dc4f64 100644
--- a/common/src/main/res/values/strings.xml
+++ b/common/src/main/res/values/strings.xml
@@ -899,6 +899,7 @@
Set template in the phone settings
Log in to set up a template
Error in template
+ Preview
Error rendering template
Provide a template below that will be displayed on the Wear OS template tile. See help for markup options.
Enter template here