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..7b46bb3ddeb
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt
@@ -0,0 +1,290 @@
+package io.homeassistant.companion.android.widgets.template
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.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.HAThemeForPreview
+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
+
+/**
+ * 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,
+ )
+ HATextField(
+ value = state.textSize,
+ onValueChange = onTextSizeChanged,
+ label = { Text(stringResource(commonR.string.widget_text_size_label)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ maxLines = 1,
+ modifier = Modifier.formControlWidth(),
+ )
+ AppearanceSection(
+ selectedBackgroundType = state.selectedBackgroundType,
+ dynamicColorAvailable = state.dynamicColorAvailable,
+ textColorHex = state.textColorHex,
+ 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(),
+ enabled = items.isNotEmpty(),
+ )
+}
+
+@Composable
+private fun TemplateSection(template: String, preview: TemplatePreview, onTemplateChanged: (String) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(HADimens.SPACE4)) {
+ 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) {
+ 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,
+ modifier = Modifier.size(HAPreviewIconSize),
+ )
+ Text(text = stringResource(commonR.string.template_preview_label))
+ }
+ Text(text = preview.toAnnotatedString())
+ }
+ }
+}
+
+@Composable
+private fun TemplatePreview.toAnnotatedString(): AnnotatedString = when (this) {
+ is TemplatePreview.Empty -> AnnotatedString(stringResource(commonR.string.empty_template))
+ is TemplatePreview.Error -> AnnotatedString(stringResource(messageRes))
+ is TemplatePreview.Rendered -> parseHtml(text)
+}
+
+@Composable
+private fun AppearanceSection(
+ selectedBackgroundType: WidgetBackgroundType,
+ dynamicColorAvailable: Boolean,
+ textColorHex: String?,
+ onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit,
+ onTextColorSelected: (colorHex: String) -> Unit,
+) {
+ 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()
+
+private const val TEMPLATE_FIELD_MIN_LINES = 3
+private val HAPreviewIconSize = 16.dp
+
+@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..4ef86c9242c
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureState.kt
@@ -0,0 +1,51 @@
+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
+
+ @StringRes
+ val actionButtonLabel = if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget
+
+ val textSizeOrDefault: Float
+ get() = textSize.toFloatOrNull()?.takeIf { it.isFinite() && it > 0 } ?: DEFAULT_TEXT_SIZE.toFloat()
+}
+
+internal 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..c378a6884c8
--- /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)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * 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)
+ }
+ }
+
+ 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) }
+ }
+
+ internal fun onTextColorSelected(colorHex: String) {
+ _state.update { it.copy(textColorHex = colorHex) }
+ }
+
+ /** 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) }
+ }
+ }
+
+ /**
+ * 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
+ },
+ )
+ }
+
+ /**
+ * Builds the widget to persist from the current configuration, or null when it is incomplete.
+ */
+ internal 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
+ },
+ )
+ }
+
+ /**
+ * 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
+ }
+
+ @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..6fe9beb6deb
--- /dev/null
+++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreenshotTest.kt
@@ -0,0 +1,89 @@
+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 = {},
+ )
+ }
+ }
+}
+
+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/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..3878963720e
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..afa542ffacf
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..d808d873aa7
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..fdef336b474
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..70f6ef7c930
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..d05ef21f1af
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..61716ad615b
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..56790ca949b
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..c6cf1486f64
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..bd51d4e90ca
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..f3d6000ce5a
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..ab5f7d68942
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..f5d1b204960
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..d34b6de80a0
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..24dff0cf15b
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..67c54cd31a8
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..195459e95f5
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..8c275f8ae75
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/widgets/template/TemplateWidgetConfigureViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt
new file mode 100644
index 00000000000..4af27b912f0
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt
@@ -0,0 +1,225 @@
+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
+
+@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)
+ }
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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)
+ }
+
+ @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,
+ )
+
+ companion object {
+ /** Hex of `colorWidgetButtonLabelBlack`, which is what the widget persists. */
+ private const val BLACK_HEX = "#3A3A3A"
+ }
+}
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..ee715238a87 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
@@ -44,6 +44,8 @@ import io.homeassistant.companion.android.common.compose.theme.MaxButtonWidth
* [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
+ * @param minLines the minimum height in terms of minimum number of visible lines. Ignored if
+ * [singleLine] is `true`
* @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
*/
@@ -62,6 +64,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 +78,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