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()
+ }
}