Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8ea4572
Migrate template widget configuration to Compose and Material3
su7ri Aug 1, 2026
650e675
Disable the action button while a new template render is in flight
su7ri Aug 1, 2026
cd70cee
Merge branch 'main' into compose-template-widget-6304
su7ri Aug 1, 2026
b2d06f5
Address review: screenshot test, isBlank, shared parseHtml util, prev…
su7ri Aug 2, 2026
91868ae
Add reference screenshots for TemplateWidgetConfigureScreen
su7ri Aug 2, 2026
e440398
Wrap TemplateSection's children in a Column to emit from a single source
claude Aug 2, 2026
0daa207
Fix preview card icon/text colors in dark mode
su7ri Aug 4, 2026
8c80041
Add unit tests for parseHtml
claude Aug 4, 2026
e578ca5
Address remaining review feedback on TemplateWidgetConfigureScreen
claude Aug 4, 2026
bc72086
Move private constants to the top of their files
claude Aug 4, 2026
c0a1e5e
Avoid re-parsing the rendered preview and gate saving on a valid text…
claude Aug 4, 2026
d3c5165
Move private ViewModel members to file conventions
claude Aug 4, 2026
71d3de4
Drop unnecessary internal modifier on onTextColorSelected
claude Aug 4, 2026
7b81b8b
Address remaining TimoPtr comments: visibility, tests, screenshots
claude Aug 4, 2026
79dc55d
Move test states to top-level private vals
su7ri Aug 6, 2026
b37a35f
Merge branch 'main' into compose-template-widget-6304
su7ri Aug 6, 2026
e33d63a
Merge branch 'main' into compose-template-widget-6304
su7ri Aug 9, 2026
63a2baf
Update reference screenshots for TemplateWidgetConfigureScreen
su7ri Aug 14, 2026
248c6a2
Merge branch 'compose-template-widget-6304' of https://github.com/su7…
su7ri Aug 14, 2026
6ef896f
Reuse empty template screenshot test for single-server case
su7ri Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<TemplateWidgetEntity, TemplateWidgetDao>() {
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<TemplateWidgetConfigureViewModel.Factory> { 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()
}
}
}
Loading
Loading