diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index f4c48c298db..172e74f694d 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -6,10 +6,6 @@ - - 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 5e4a746f7cc..904ae47c926 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,244 +1,97 @@ package io.homeassistant.companion.android.widgets.template import android.appwidget.AppWidgetManager -import android.os.Build 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.BaseActivity import io.homeassistant.companion.android.common.R as commonR -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 io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.applySafeDrawingInsets +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme 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.CancellationException 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 +class TemplateWidgetConfigureActivity : BaseActivity() { - override val serverSelect: View - get() = binding.serverSelect + private val viewModel: TemplateWidgetConfigureViewModel by viewModels() - override val serverSelectList: Spinner - get() = binding.serverSelectList - - private var requestLauncherSetup = false + private val supportedTextColors: List + get() = listOf( + application.getHexForColor(commonR.color.colorWidgetButtonLabelBlack), + application.getHexForColor(android.R.color.white), + ) - 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, - ) - } + val widgetId = extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + val requestLauncherSetup = extras?.getBoolean( + ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, + false, + ) ?: 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) + viewModel.onSetup(widgetId = widgetId, supportedTextColors = supportedTextColors) - 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 { + HomeAssistantAppTheme { + TemplateWidgetConfigureScreen( + viewModel = viewModel, + onActionClick = { onActionClick(requestLauncherSetup) }, ) - 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(requestLauncherSetup: Boolean) { + lifecycleScope.launch { if (requestLauncherSetup) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - lifecycleScope.launch { - requestWidgetCreation() - } - } else { - showAddWidgetError() // this shouldn't be possible - } + requestPinWidget() } else { - lifecycleScope.launch { - updateWidget() - } + onUpdateWidget() } } } - 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 suspend fun requestPinWidget() { + try { + viewModel.requestWidgetCreation(this@TemplateWidgetConfigureActivity) + finish() + } catch (e: IllegalStateException) { + if (e is CancellationException) throw e + showAddWidgetError() } } - private fun renderTemplateText(template: String) { - val serverId = selectedServerId - if (serverId == null) { - Timber.w("Not rendering template because server is not set") - return + private suspend fun onUpdateWidget() { + try { + viewModel.updateWidgetConfiguration(this@TemplateWidgetConfigureActivity) + setResult(RESULT_OK) + finish() + } catch (e: IllegalStateException) { + if (e is CancellationException) throw e + showAddWidgetError() } + } - 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 - }, - ) - enabled = false - } - } - binding.renderedTemplate.text = - templateText?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) } - binding.addButton.isEnabled = enabled && isValidServerId() - } + private fun showAddWidgetError() { + viewModel.showError(commonR.string.widget_creation_error) } } 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..5a7305f5414 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureScreen.kt @@ -0,0 +1,285 @@ +package io.homeassistant.companion.android.widgets.template + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +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.HATextField +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.WidgetBackgroundTypeExposedDropdownMenu +import io.homeassistant.companion.android.util.previewServer1 +import io.homeassistant.companion.android.util.previewServer2 +import io.homeassistant.companion.android.util.safeBottomWindowInsets +import io.homeassistant.companion.android.util.safeTopWindowInsets + +@Composable +internal fun TemplateWidgetConfigureScreen( + viewModel: TemplateWidgetConfigureViewModel, + onActionClick: () -> Unit, +) { + val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.errorMessage.collect { resId -> + snackbarHostState.showSnackbar(context.getString(resId)) + } + } + + TemplateWidgetConfigureView( + servers = servers, + selectedServerId = uiState.selectedServerId, + onServerSelected = viewModel::setServer, + templateText = uiState.templateText, + onTemplateTextChanged = viewModel::onTemplateTextChanged, + renderedTemplate = uiState.renderedTemplate, + isTemplateValid = uiState.isTemplateValid, + templateRenderError = uiState.templateRenderError, + textSize = uiState.textSize, + onTextSizeChanged = viewModel::onTextSizeChanged, + selectedBackgroundType = uiState.selectedBackgroundType, + onBackgroundTypeSelected = viewModel::onBackgroundTypeSelected, + textColorIndex = uiState.textColorIndex, + onTextColorSelected = viewModel::onTextColorSelected, + isUpdateWidget = uiState.isUpdateWidget, + onActionClick = onActionClick, + snackbarHostState = snackbarHostState, + ) +} + +@Composable +private fun TemplateWidgetConfigureView( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + templateText: String, + onTemplateTextChanged: (String) -> Unit, + renderedTemplate: String?, + isTemplateValid: Boolean, + templateRenderError: TemplateRenderError?, + textSize: String, + onTextSizeChanged: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, + isUpdateWidget: Boolean, + onActionClick: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.create_template)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding) + .padding(all = HADimens.SPACE4), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + ServerSelectionSection( + servers = servers, + selectedServerId = selectedServerId, + onServerSelected = onServerSelected, + ) + + TemplateInputSection( + templateText = templateText, + onTemplateTextChanged = onTemplateTextChanged, + renderedTemplate = renderedTemplate, + templateRenderError = templateRenderError, + ) + + WidgetAppearanceSection( + textSize = textSize, + onTextSizeChanged = onTextSizeChanged, + selectedBackgroundType = selectedBackgroundType, + onBackgroundTypeSelected = onBackgroundTypeSelected, + textColorIndex = textColorIndex, + onTextColorSelected = onTextColorSelected, + ) + + HAAccentButton( + text = stringResource(if (isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget), + onClick = onActionClick, + enabled = isTemplateValid, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Suppress("ComposeUnstableCollections") // Matches ServerExposedDropdownMenu signature +@Composable +private fun ServerSelectionSection( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, +) { + if (servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = selectedServerId, + onSelected = { onServerSelected(it) }, + modifier = Modifier.padding(bottom = HADimens.SPACE2), + ) + } +} + +@Composable +private fun TemplateInputSection( + templateText: String, + onTemplateTextChanged: (String) -> Unit, + renderedTemplate: String?, + templateRenderError: TemplateRenderError?, +) { + HATextField( + value = templateText, + onValueChange = onTemplateTextChanged, + placeholder = { Text(stringResource(commonR.string.template_widget_default)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = Int.MAX_VALUE, + singleLine = false, + ) + + TemplateRenderResult( + templateText = templateText, + renderedTemplate = renderedTemplate, + templateRenderError = templateRenderError, + ) +} + +@Composable +private fun TemplateRenderResult( + templateText: String, + renderedTemplate: String?, + templateRenderError: TemplateRenderError?, +) { + if (renderedTemplate != null) { + Text( + text = renderedTemplate, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } else if (templateRenderError != null) { + Text( + text = stringResource( + when (templateRenderError) { + TemplateRenderError.TEMPLATE_ERROR -> commonR.string.template_error + TemplateRenderError.RENDER_ERROR -> commonR.string.template_render_error + }, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } else if (templateText.isEmpty()) { + Text( + text = stringResource(commonR.string.empty_template), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE1), + ) + } +} + +@Composable +private fun WidgetAppearanceSection( + textSize: String, + onTextSizeChanged: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, +) { + HATextField( + value = textSize, + onValueChange = onTextSizeChanged, + label = { Text(stringResource(commonR.string.widget_text_size_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + WidgetBackgroundTypeExposedDropdownMenu( + current = selectedBackgroundType, + onSelected = { onBackgroundTypeSelected(it) }, + ) + + AnimatedVisibility(visible = selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + ExposedDropdownMenu( + label = stringResource(commonR.string.widget_text_color_label), + keys = listOf( + stringResource(commonR.string.widget_text_color_black), + stringResource(commonR.string.widget_text_color_white), + ), + currentIndex = textColorIndex, + onSelected = { onTextColorSelected(it) }, + ) + } +} + +@Preview +@Composable +private fun TemplateWidgetConfigureViewPreview() { + HomeAssistantAppTheme { + TemplateWidgetConfigureView( + servers = listOf(previewServer1, previewServer2), + selectedServerId = 0, + onServerSelected = {}, + templateText = "Hello world", + renderedTemplate = "Hello world", + isTemplateValid = true, + templateRenderError = null, + textSize = "14", + onTextSizeChanged = {}, + selectedBackgroundType = WidgetBackgroundType.DAYNIGHT, + onBackgroundTypeSelected = {}, + textColorIndex = 0, + onTextColorSelected = {}, + isUpdateWidget = false, + onActionClick = {}, + onTemplateTextChanged = {}, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt new file mode 100644 index 00000000000..a81129fd482 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureUiState.kt @@ -0,0 +1,36 @@ +package io.homeassistant.companion.android.widgets.template + +import com.google.android.material.color.DynamicColors +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType + +/** + * Represents the UI state of the Template Widget configuration screen. + */ +data class TemplateWidgetConfigureUiState( + val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE, + val templateText: String = "", + val renderedTemplate: String? = null, + val isTemplateValid: Boolean = false, + val textSize: String = "14", + val selectedBackgroundType: WidgetBackgroundType = + if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + val textColorIndex: Int = 0, + val isUpdateWidget: Boolean = false, + val templateRenderError: TemplateRenderError? = null, +) + +/** + * Represents the type of error encountered when rendering a template. + */ +enum class TemplateRenderError { + /** Error in the template syntax itself. */ + TEMPLATE_ERROR, + + /** Error communicating with the server or rendering the template. */ + RENDER_ERROR, +} \ No newline at end of file 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..dc45c1731b3 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModel.kt @@ -0,0 +1,294 @@ +package io.homeassistant.companion.android.widgets.template + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.annotation.VisibleForTesting +import androidx.core.text.HtmlCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.servers.ServerManager +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.Companion.UPDATE_WIDGETS +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import timber.log.Timber + +private const val RENDER_DEBOUNCE_MS = 500 +private const val DEFAULT_TEXT_SIZE = 12.0f + +@HiltViewModel +class TemplateWidgetConfigureViewModel @Inject constructor( + private val templateWidgetDao: TemplateWidgetDao, + private val serverManager: ServerManager, +) : ViewModel() { + + private var supportedTextColors: List = emptyList() + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + + private val _uiState = MutableStateFlow(TemplateWidgetConfigureUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _errorMessage = MutableSharedFlow() + + /** One-shot error events carrying a string resource ID to display in a Snackbar. */ + val errorMessage: SharedFlow = _errorMessage.asSharedFlow() + + val servers = serverManager.serversFlow + + private var templateRenderingJob: Job? = null + + /** + * Initialize the ViewModel with the widget ID and supported text colors. + * Loads existing widget configuration if editing an existing widget. + * + * This guard prevents re-initialization when the Activity is recreated due to + * configuration changes, since the ViewModel survives those changes. + */ + fun onSetup(widgetId: Int, supportedTextColors: List) { + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId + maybeLoadPreviousState(widgetId) + startTemplateRendering() + } + + private fun maybeLoadPreviousState(widgetId: Int) { + viewModelScope.launch { + templateWidgetDao.get(widgetId)?.let { widget -> + _uiState.update { state -> + state.copy( + isUpdateWidget = true, + selectedServerId = widget.serverId, + templateText = widget.template, + textSize = widget.textSize.toInt().toString(), + selectedBackgroundType = widget.backgroundType, + textColorIndex = if (widget.textColor != null) { + val colorIndex = supportedTextColors.indexOf(widget.textColor) + if (colorIndex == -1) 0 else colorIndex + } else { + state.textColorIndex + }, + ) + } + } + } + } + + /** + * Update the selected server. + */ + fun setServer(serverId: Int) { + _uiState.update { + if (it.selectedServerId == serverId) it else it.copy(selectedServerId = serverId) + } + } + + /** + * Update the template text. + */ + fun onTemplateTextChanged(text: String) { + _uiState.update { it.copy(templateText = text) } + } + + /** + * Update the text size. + */ + fun onTextSizeChanged(size: String) { + _uiState.update { it.copy(textSize = size) } + } + + /** + * Update the selected background type. + */ + fun onBackgroundTypeSelected(type: WidgetBackgroundType) { + _uiState.update { it.copy(selectedBackgroundType = type) } + } + + /** + * Update the selected text color index. + */ + fun onTextColorSelected(index: Int) { + _uiState.update { it.copy(textColorIndex = index) } + } + + @OptIn(FlowPreview::class) + private fun startTemplateRendering() { + templateRenderingJob?.cancel() + templateRenderingJob = viewModelScope.launch { + combine( + _uiState.mapField { it.templateText }, + _uiState.mapField { it.selectedServerId }, + ) { template, serverId -> template to serverId } + .debounce(RENDER_DEBOUNCE_MS.milliseconds) + .collect { (template, serverId) -> + renderTemplate(template, serverId) + } + } + } + + /** + * Extracts a distinct field from the StateFlow to avoid unnecessary emissions. + */ + private fun StateFlow.mapField( + selector: (TemplateWidgetConfigureUiState) -> T, + ): Flow { + return map(selector).distinctUntilChanged() + } + + private suspend fun renderTemplate(template: String, serverId: Int) { + if (template.isEmpty()) { + _uiState.update { it.copy(renderedTemplate = null, isTemplateValid = false) } + return + } + if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) { + Timber.w("Not rendering template because server is not set") + return + } + withContext(Dispatchers.Default) { + try { + val result = serverManager.integrationRepository(serverId) + .renderTemplate(template, mapOf()) + .toString() + @Suppress("UNNECESSARY_SAFE_CALL") + val rendered = HtmlCompat.fromHtml(result, HtmlCompat.FROM_HTML_MODE_LEGACY) + ?.toString() + ?.trimEnd() + ?: result + _uiState.update { + it.copy(renderedTemplate = rendered, isTemplateValid = true, templateRenderError = null) + } + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + if (e is SerializationException) { + Timber.e(e, "Template syntax error") + } else { + Timber.e(e, "Error rendering template") + } + val errorRendered = if (e.cause is SerializationException) { + TemplateRenderError.TEMPLATE_ERROR + } else { + TemplateRenderError.RENDER_ERROR + } + _uiState.update { + it.copy( + renderedTemplate = null, + isTemplateValid = false, + templateRenderError = errorRendered, + ) + } + } + } + } + + private fun getPendingDaoEntity(): TemplateWidgetEntity { + val state = _uiState.value + val textColor = if (state.selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + supportedTextColors.getOrNull(state.textColorIndex) ?: supportedTextColors.first() + } else { + null + } + return TemplateWidgetEntity( + id = widgetId, + serverId = state.selectedServerId, + template = state.templateText, + textSize = state.textSize.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, + lastUpdate = "Loading", + backgroundType = state.selectedBackgroundType, + textColor = textColor, + ) + } + + /** + * Save the widget configuration to the database and update the widget. + * + * @param context the context used to send a broadcast to trigger widget update + * @throws IllegalStateException if the widget ID is invalid + */ + suspend fun updateWidgetConfiguration(context: Context) { + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + throw IllegalStateException("Widget ID is invalid") + } + + val entity = getPendingDaoEntity() + templateWidgetDao.add(entity) + + val intent = Intent(context, TemplateWidget::class.java) + intent.action = UPDATE_WIDGETS + context.sendBroadcast(intent) + } + + /** + * Requests the system to pin a new Template widget. + * + * Saves a pending entity and waits until the widget has been created by monitoring the DAO. + * + * **WARNING**: This function does not handle user cancellation. If a user cancels the widget creation, + * this function will not return. If this function is called again and the user does not cancel, + * both calls to the function will return. While this behavior could be avoided, + * it does not cause issues in the current implementation as returning multiple times has no adverse effects. + * + * @param context the context used to request the pin widget + */ + suspend fun requestWidgetCreation(context: Context) { + val pendingEntity = getPendingDaoEntity() + templateWidgetDao.getWidgetCountFlow().drop(1).onStart { + val appWidgetManager = context.getSystemService(AppWidgetManager::class.java) + val flags = PendingIntent.FLAG_MUTABLE + 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, pendingEntity) + }, + flags, + ), + ) + }.first() + } + + /** + * Emit a one-shot error event to be displayed as a Snackbar. + * + * @param messageResId the string resource ID for the error message + */ + fun showError(messageResId: Int) { + viewModelScope.launch { _errorMessage.emit(messageResId) } + } +} 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/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..59192f75cd6 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureViewModelTest.kt @@ -0,0 +1,283 @@ +package io.homeassistant.companion.android.widgets.template + +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.ConsoleLogExtension +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MainDispatcherJUnit5Extension::class, ConsoleLogExtension::class) +class TemplateWidgetConfigureViewModelTest { + + private val templateWidgetDao: TemplateWidgetDao = mockk(relaxed = true) + private val serverManager: ServerManager = mockk(relaxed = true) + private val integrationRepository: IntegrationRepository = mockk(relaxed = true) + + private lateinit var viewModel: TemplateWidgetConfigureViewModel + + private val supportedTextColors = listOf("#000000", "#FFFFFF") + + @BeforeEach + fun setup() { + coEvery { serverManager.isRegistered() } returns true + val server: Server = mockk(relaxed = true) + coEvery { serverManager.getServer(any()) } returns server + coEvery { serverManager.integrationRepository(any()) } returns integrationRepository + viewModel = TemplateWidgetConfigureViewModel( + templateWidgetDao = templateWidgetDao, + serverManager = serverManager, + ) + viewModel.ioDispatcher = UnconfinedTestDispatcher() + } + + @Nested + inner class SetupTest { + @Test + fun `Given no existing widget when onSetup then isUpdateWidget is false`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isUpdateWidget) + } + + @Test + fun `Given existing widget when onSetup then loads widget state`() = runTest { + val existingWidget = TemplateWidgetEntity( + id = 42, + serverId = 1, + template = "{{ states('sensor.temp') }}", + textSize = 16f, + lastUpdate = "2024-01-01", + backgroundType = WidgetBackgroundType.TRANSPARENT, + textColor = "#FFFFFF", + ) + coEvery { templateWidgetDao.get(42) } returns existingWidget + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state.isUpdateWidget) + assertEquals(1, state.selectedServerId) + assertEquals("{{ states('sensor.temp') }}", state.templateText) + assertEquals("16", state.textSize) + assertEquals(WidgetBackgroundType.TRANSPARENT, state.selectedBackgroundType) + assertEquals(1, state.textColorIndex) + } + + @Test + fun `Given existing widget with unknown text color when onSetup then defaults to index 0`() = runTest { + val existingWidget = TemplateWidgetEntity( + id = 42, + serverId = 1, + template = "test", + textSize = 12f, + lastUpdate = "2024-01-01", + backgroundType = WidgetBackgroundType.TRANSPARENT, + textColor = "#FF0000", + ) + coEvery { templateWidgetDao.get(42) } returns existingWidget + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertEquals(0, viewModel.uiState.value.textColorIndex) + } + + @Test + fun `Given onSetup called twice then second call is ignored`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + viewModel.onTemplateTextChanged("modified") + viewModel.onSetup(widgetId = 99, supportedTextColors = supportedTextColors) + advanceUntilIdle() + + assertEquals("modified", viewModel.uiState.value.templateText) + } + } + + @Nested + inner class ServerSelectionTest { + @Test + fun `Given different server when setServer then selectedServerId is updated`() { + viewModel.setServer(serverId = 5) + + assertEquals(5, viewModel.uiState.value.selectedServerId) + } + + @Test + fun `Given same server when setServer then no change`() { + viewModel.setServer(serverId = 5) + viewModel.setServer(serverId = 5) + + assertEquals(5, viewModel.uiState.value.selectedServerId) + } + } + + @Nested + inner class TemplateRenderingTest { + @Test + fun `Given valid template when text changes then template is rendered`() = runTest { + coEvery { + integrationRepository.renderTemplate(any(), any()) + } returns "25.0" + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("{{ states('sensor.temp') }}") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("25.0", state.renderedTemplate) + assertTrue(state.isTemplateValid) + } + + @Test + fun `Given empty template when text changes then template is not valid`() = runTest { + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) + } + + @Test + fun `Given render error when rendering template then template is not valid`() = runTest { + coEvery { + integrationRepository.renderTemplate(any(), any()) + } throws RuntimeException("Render failed") + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("{{ invalid }}") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) + assertEquals(TemplateRenderError.RENDER_ERROR, state.templateRenderError) + } + + @Test + fun `Given server not registered when rendering template then template state is unchanged`() = runTest { + coEvery { serverManager.isRegistered() } returns false + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("{{ states('sensor.temp') }}") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertNull(state.renderedTemplate) + assertFalse(state.isTemplateValid) + } + } + + @Nested + inner class UpdateWidgetTest { + @Test + fun `Given valid widget ID when updateWidgetConfiguration then entity is saved to DAO`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("test template") + viewModel.onTextSizeChanged("16") + viewModel.onBackgroundTypeSelected(WidgetBackgroundType.DAYNIGHT) + + viewModel.updateWidgetConfiguration(context) + + coVerify { + templateWidgetDao.add( + match { + it.id == 42 && + it.template == "test template" && + it.textSize == 16f && + it.backgroundType == WidgetBackgroundType.DAYNIGHT && + it.textColor == null + }, + ) + } + } + + @Test + fun `Given transparent background when updateWidgetConfiguration then text color is set`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("test") + viewModel.onBackgroundTypeSelected(WidgetBackgroundType.TRANSPARENT) + viewModel.onTextColorSelected(1) + + viewModel.updateWidgetConfiguration(context) + + coVerify { + templateWidgetDao.add( + match { + it.textColor == "#FFFFFF" && + it.backgroundType == WidgetBackgroundType.TRANSPARENT + }, + ) + } + } + + @Test + fun `Given invalid widget ID when updateWidgetConfiguration then throws IllegalStateException`() = runTest { + val context: android.content.Context = mockk(relaxed = true) + var thrown = false + try { + viewModel.updateWidgetConfiguration(context) + } catch (_: IllegalStateException) { + thrown = true + } + assertTrue(thrown) + } + + @Test + fun `Given invalid text size when updateWidgetConfiguration then uses default text size`() = runTest { + coEvery { templateWidgetDao.get(any()) } returns null + val context: android.content.Context = mockk(relaxed = true) + + viewModel.onSetup(widgetId = 42, supportedTextColors = supportedTextColors) + advanceUntilIdle() + viewModel.onTemplateTextChanged("test") + viewModel.onTextSizeChanged("invalid") + + viewModel.updateWidgetConfiguration(context) + + coVerify { + templateWidgetDao.add(match { it.textSize == 12.0f }) + } + } + } +}