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