From e7a926396d666107870598a926c56e444ab650b9 Mon Sep 17 00:00:00 2001 From: leroyboerefijn <34075651+leroyboerefijn@users.noreply.github.com> Date: Fri, 29 Jul 2022 23:42:17 +0200 Subject: [PATCH] Implement html styling on the template tile (#2653) * Implement styling on the template tyle * Also process preview in phone settings * Small cleanup * Improve template null handling * Update template rendering error * Update help text to indicate new line and markup options * Also support newline * More extensive replacement for
--- .../settings/wear/SettingsWearViewModel.kt | 6 +- .../wear/views/SettingsWearTemplateTile.kt | 45 ++++++++- .../widgets/template/TemplateWidget.kt | 14 ++- .../TemplateWidgetConfigureActivity.kt | 4 +- .../data/integration/IntegrationRepository.kt | 2 +- .../impl/IntegrationRepositoryImpl.kt | 4 +- common/src/main/res/values/strings.xml | 5 +- .../companion/android/tiles/TemplateTile.kt | 95 ++++++++++++++++--- 8 files changed, 146 insertions(+), 29 deletions(-) diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt index 08cc31664..2fa2e0cc5 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt @@ -112,10 +112,12 @@ class SettingsWearViewModel @Inject constructor( viewModelScope.launch { try { templateTileContentRendered.value = - integrationUseCase.renderTemplate(template, mapOf()) + integrationUseCase.renderTemplate(template, mapOf()) ?: getApplication().getString( + commonR.string.template_error + ) } catch (e: Exception) { templateTileContentRendered.value = getApplication().getString( - commonR.string.template_tile_error + commonR.string.template_render_error ) } } diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt index 584bab6df..d5a4f8ae8 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt @@ -1,5 +1,12 @@ 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 @@ -19,12 +26,20 @@ 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.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.util.IntervalToString @@ -47,8 +62,8 @@ fun SettingsWearTemplateTile( docsLink = WEAR_DOCS_LINK ) } - ) { - Column(Modifier.padding(all = 16.dp)) { + ) { padding -> + Column(Modifier.padding(padding).padding(all = 16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Image( asset = CommunityMaterial.Icon3.cmd_timer_cog, @@ -95,10 +110,34 @@ fun SettingsWearTemplateTile( maxLines = 10 ) Text( - renderedTemplate, + parseHtml(renderedTemplate), fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp) ) } } } + +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) + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidget.kt index 4a13b71c8..086fe46f3 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidget.kt @@ -85,11 +85,19 @@ class TemplateWidget : BaseWidgetProvider() { } // Content - var renderedTemplate = templateWidgetDao.get(appWidgetId)?.lastUpdate ?: "Loading" + var renderedTemplate: String? = templateWidgetDao.get(appWidgetId)?.lastUpdate ?: "Loading" try { renderedTemplate = integrationUseCase.renderTemplate(widget.template, mapOf()) - templateWidgetDao.updateTemplateWidgetLastUpdate(appWidgetId, renderedTemplate) - setViewVisibility(R.id.widgetTemplateError, View.GONE) + if (renderedTemplate != null) { + templateWidgetDao.updateTemplateWidgetLastUpdate( + appWidgetId, + renderedTemplate + ) + setViewVisibility(R.id.widgetTemplateError, View.GONE) + } else { + Log.e(TAG, "Template returned null: ${widget.template}") + setViewVisibility(R.id.widgetTemplateError, View.VISIBLE) + } } catch (e: Exception) { Log.e(TAG, "Unable to render template: ${widget.template}", e) setViewVisibility(R.id.widgetTemplateError, View.VISIBLE) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 7671ce66c..6464b6600 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -216,10 +216,10 @@ class TemplateWidgetConfigureActivity : BaseWidgetConfigureActivity() { var enabled: Boolean withContext(Dispatchers.IO) { try { - templateText = integrationUseCase.renderTemplate(template, mapOf()) + templateText = integrationUseCase.renderTemplate(template, mapOf()) ?: getString(commonR.string.template_error) enabled = true } catch (e: Exception) { - templateText = "Error in template" + templateText = getString(commonR.string.template_render_error) enabled = false } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 240013f91..dc87dad04 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -14,7 +14,7 @@ interface IntegrationRepository { suspend fun getNotificationRateLimits(): RateLimitResponse - suspend fun renderTemplate(template: String, variables: Map): String + suspend fun renderTemplate(template: String, variables: Map): String? suspend fun updateLocation(updateLocation: UpdateLocation) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index 84dbe7d42..596f3ee86 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -157,7 +157,7 @@ class IntegrationRepositoryImpl @Inject constructor( return urlRepository.getApiUrls().isNotEmpty() } - override suspend fun renderTemplate(template: String, variables: Map): String { + override suspend fun renderTemplate(template: String, variables: Map): String? { var causeException: Exception? = null for (it in urlRepository.getApiUrls()) { try { @@ -167,7 +167,7 @@ class IntegrationRepositoryImpl @Inject constructor( "render_template", mapOf("template" to Template(template, variables)) ) - ).getValue("template") + )["template"] } catch (e: Exception) { if (causeException == null) causeException = e // Ignore failure until we are out of URLS to try, but use the first exception as cause exception diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index f605afe1c..0892d24c6 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -686,8 +686,9 @@ Template tile content Renders and displays a template Set template in the phone settings - Error in template - Provide a template below that will be displayed on the Wear OS template tile. + Error in template + 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 Render any template with HTML formatting Template Widget diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt index cd06d9574..3f33771df 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt @@ -1,15 +1,26 @@ package io.homeassistant.companion.android.tiles +import android.graphics.Typeface import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +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.core.content.getSystemService +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY +import androidx.core.text.HtmlCompat.fromHtml import androidx.wear.tiles.ActionBuilders +import androidx.wear.tiles.ColorBuilders import androidx.wear.tiles.DimensionBuilders import androidx.wear.tiles.DimensionBuilders.dp import androidx.wear.tiles.LayoutElementBuilders import androidx.wear.tiles.LayoutElementBuilders.Box +import androidx.wear.tiles.LayoutElementBuilders.FONT_WEIGHT_BOLD import androidx.wear.tiles.LayoutElementBuilders.Layout import androidx.wear.tiles.LayoutElementBuilders.LayoutElement import androidx.wear.tiles.ModifiersBuilders @@ -58,9 +69,11 @@ class TemplateTile : TileService() { val template = integrationUseCase.getTemplateTileContent() val renderedText = try { - integrationUseCase.renderTemplate(template, mapOf()) + integrationUseCase.renderTemplate(template, mapOf()) ?: getString( + commonR.string.template_error + ) } catch (e: Exception) { - getString(commonR.string.template_tile_error) + getString(commonR.string.template_render_error) } Tile.Builder() @@ -102,18 +115,18 @@ class TemplateTile : TileService() { } fun layout(renderedText: String): LayoutElement = Box.Builder().apply { - addContent( - LayoutElementBuilders.Text.Builder() - .setText( - if (renderedText.isEmpty()) { - getString(commonR.string.template_tile_empty) - } else { - renderedText - } - ) - .setMaxLines(10) - .build() - ) + if (renderedText.isEmpty()) { + addContent( + LayoutElementBuilders.Text.Builder() + .setText(getString(commonR.string.template_tile_empty)) + .setMaxLines(10) + .build() + ) + } else { + addContent( + parseHtml(renderedText) + ) + } addContent( LayoutElementBuilders.Arc.Builder() .setAnchorAngle( @@ -152,4 +165,58 @@ class TemplateTile : TileService() { ) .build() } + + private fun parseHtml(renderedText: String): LayoutElementBuilders.Spannable { + // 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) + return LayoutElementBuilders.Spannable.Builder().apply { + var start = 0 + var end = 0 + while (end < renderedSpanned.length) { + end = renderedSpanned.nextSpanTransition(end, renderedSpanned.length, CharacterStyle::class.java) + + val fontStyle = LayoutElementBuilders.FontStyle.Builder().apply { + renderedSpanned.getSpans(start, end, CharacterStyle::class.java).forEach { span -> + when (span) { + is AbsoluteSizeSpan -> setSize( + DimensionBuilders.SpProp.Builder() + .setValue(span.size / applicationContext.resources.displayMetrics.scaledDensity) + .build() + ) + is ForegroundColorSpan -> setColor( + ColorBuilders.ColorProp.Builder() + .setArgb(span.foregroundColor) + .build() + ) + is RelativeSizeSpan -> { + val defaultSize = 16 // https://developer.android.com/training/wearables/design/typography + setSize( + DimensionBuilders.SpProp.Builder() + .setValue(span.sizeChange * defaultSize) + .build() + ) + } + is StyleSpan -> when (span.style) { + Typeface.BOLD -> setWeight(FONT_WEIGHT_BOLD) + Typeface.ITALIC -> setItalic(true) + Typeface.BOLD_ITALIC -> setWeight(FONT_WEIGHT_BOLD).setItalic(true) + } + is UnderlineSpan -> setUnderline(true) + } + } + }.build() + + addSpan( + LayoutElementBuilders.SpanText.Builder() + .setText(renderedSpanned.substring(start, end)) + .setFontStyle(fontStyle) + .build() + ) + + start = end + } + } + .setMaxLines(10) + .build() + } }