diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt index 9c33feca3bb..ad2d030838c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -343,7 +343,7 @@ open class HomeAssistantApplication : if (!isAutomotive()) { // Update widgets when the screen turns on, updates are skipped if widgets were not added - val buttonWidget = ButtonWidget() + ButtonWidget().registerReceiver(this) val entityWidget = EntityWidget() val mediaPlayerWidget = MediaPlayerControlsWidget() val templateWidget = TemplateWidget() @@ -353,7 +353,6 @@ open class HomeAssistantApplication : screenIntentFilter.addAction(Intent.ACTION_SCREEN_ON) screenIntentFilter.addAction(Intent.ACTION_SCREEN_OFF) - ContextCompat.registerReceiver(this, buttonWidget, screenIntentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) ContextCompat.registerReceiver(this, entityWidget, screenIntentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) ContextCompat.registerReceiver( this, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt new file mode 100644 index 00000000000..a66911b17b4 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonGlanceAppWidget.kt @@ -0,0 +1,328 @@ +package io.homeassistant.companion.android.widgets.button + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.toColorInt +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalGlanceId +import androidx.glance.LocalSize +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.color.ColorProviders +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.material.ColorProviders +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.semantics.semantics +import androidx.glance.semantics.testTag +import androidx.glance.text.Text +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.IconicsSize +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import com.mikepenz.iconics.utils.padding +import com.mikepenz.iconics.utils.size +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme +import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTypography +import io.homeassistant.companion.android.util.compose.glanceHaLightColors +import io.homeassistant.companion.android.util.icondialog.getIconByMdiName +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.SENT_SUCCESSFUL_KEY +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.CALL_SERVICE +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.CALL_SERVICE_AUTH +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.DEFAULT_MAX_ICON_SIZE +import io.homeassistant.companion.android.widgets.button.ButtonWidget.Companion.IS_LOADING_KEY +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import timber.log.Timber + +private val authKey = ActionParameters.Key("auth") +private val widgetIdKey = ActionParameters.Key("widgetId") + +class ButtonGlanceAppWidget() : GlanceAppWidget() { + @EntryPoint + @InstallIn(SingletonComponent::class) + internal interface ButtonGlanceWidgetEntryPoint { + fun buttonStateUpdater(): ButtonWidgetStateUpdater + } + + internal val isActionRunningKey = booleanPreferencesKey(IS_LOADING_KEY) + internal val isActionSuccessKey = booleanPreferencesKey(ButtonWidget.IS_SUCCESS_KEY) + internal val wasActionSentSuccessfulKey = booleanPreferencesKey(SENT_SUCCESSFUL_KEY) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val manager = GlanceAppWidgetManager(context) + val widgetId = manager.getAppWidgetId(id) + + provideContent { + val isActionRunning = currentState(key = isActionRunningKey) ?: false + val isActionSuccess = currentState(key = isActionSuccessKey) ?: false + val wasActionSentSuccessful = currentState(key = wasActionSentSuccessfulKey) ?: true + + val entryPoints = remember { EntryPoints.get(context, ButtonGlanceWidgetEntryPoint::class.java) } + val updater = remember { entryPoints.buttonStateUpdater() } + + LaunchedEffect(widgetId, isActionRunning, wasActionSentSuccessful, isActionSuccess) { + Timber.i("Running Launched Effect") + updater.updateIsActionRunning(widgetId, isActionRunning) + updater.updateIsActionError(widgetId, !wasActionSentSuccessful) + updater.updateIsActionSuccess(widgetId, isActionSuccess) + } + + val flow = remember(widgetId) { updater.getButtonEntityFlow(widgetId) } + val state by flow.collectAsState(Loading) + + Timber.i("GlanceAppWidget $isActionRunning") + Timber.i("GlanceAppWidget $state") + HomeAssistantGlanceTheme(colors = getWidgetColors(state?.backgroundType, state?.textColor)) { + ScreenForState( + id = id, + context = context, + state = state, + maxIconSize = DEFAULT_MAX_ICON_SIZE, + ) + } + } + } +} + +@Composable +fun ScreenForState( + id: GlanceId, + context: Context, + state: ButtonWidgetState?, + maxIconSize: Int, + modifier: Modifier = Modifier, +) { + when (state) { + Loading -> LoadingScreen() + is ButtonStateWithData -> ButtonScreen( + context = context, + state = state, + maxIconSize = maxIconSize, + ) + + Error -> ErrorScreen(context, id) + Success -> SuccessScreen(context, id) + + else -> {} + } +} + +@Composable +private fun SuccessScreen(context: Context, widgetId: GlanceId, modifier: Modifier = Modifier) { + LaunchedEffect(Unit) { + delay(1.seconds) + updateAppWidgetState(context, widgetId) { + it[booleanPreferencesKey(ButtonWidget.IS_SUCCESS_KEY)] = false + } + ButtonGlanceAppWidget().update(context, widgetId) + } + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.background(Color.Green).fillMaxSize(), + ) { + Image( + provider = ImageProvider(R.drawable.ic_check_black_24dp), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + ) + } +} + +@Composable +private fun ErrorScreen(context: Context, widgetId: GlanceId, modifier: Modifier = Modifier) { + LaunchedEffect(Unit) { + delay(1.seconds) + updateAppWidgetState(context, widgetId) { + it[booleanPreferencesKey(IS_LOADING_KEY)] = false + } + ButtonGlanceAppWidget().update(context, widgetId) + } + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.background(Color.Red).fillMaxSize(), + ) { + Image( + provider = ImageProvider(R.drawable.ic_clear_black), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + ) + } +} + +@Composable +fun LoadingScreen(modifier: Modifier = Modifier) { + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.buttonWidgetBackground().semantics { testTag = "LoadingScreen" }, + ) { + CircularProgressIndicator( + color = GlanceTheme.colors.primary, + modifier = GlanceModifier.size(HomeAssistantGlanceTheme.dimensions.iconSize), + ) + } +} + +@Composable +private fun GlanceModifier.buttonWidgetBackground(): GlanceModifier { + return this.appWidgetBackground().fillMaxSize().background( + GlanceTheme + .colors.widgetBackground, + ) +} + +@Composable +private fun ButtonScreen( + context: Context, + state: ButtonStateWithData?, + maxIconSize: Int, + modifier: Modifier = Modifier, +) { + val iconData = state?.icon?.let { CommunityMaterial.getIconByMdiName(it) } + ?: CommunityMaterial.Icon2.cmd_flash // Lightning Bolt + val iconDrawable = IconicsDrawable(context, iconData).apply { + padding = IconicsSize.dp(2) + size = IconicsSize.dp(24) + } + + val size = LocalSize.current + // Determine reasonable dimensions for drawing vector icon as a bitmap + val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble() + val awo = if (state != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(state.id) else null + val maxWidth = ( + awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, maxIconSize) + ?: maxIconSize + ).coerceAtLeast(16) + val maxHeight = ( + awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, maxIconSize) + ?: maxIconSize + ).coerceAtLeast(16) + val width: Int + val height: Int + if (maxWidth > maxHeight) { + width = maxWidth + height = (maxWidth * (1 / aspectRatio)).toInt() + } else { + width = (maxHeight * aspectRatio).toInt() + height = maxHeight + } + + val icon = DrawableCompat.wrap(iconDrawable).toBitmap(width = width, height = height) + + val requiresAuth = state?.requiresAuthentication + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxSize().buttonWidgetBackground() + .clickable( + onClick = actionRunCallback( + actionParametersOf( + authKey to (requiresAuth == true), + widgetIdKey to (state?.id ?: -1), + ), + ), + ), + ) { + Image( + provider = ImageProvider(icon), + contentDescription = null, + modifier = GlanceModifier.height(20.dp), + ) + state?.label?.let { + Text(text = it, style = HomeAssistantGlanceTypography.bodySmall) + } + } +} + +@Composable +fun getWidgetColors( + backgroundType: WidgetBackgroundType?, + textColor: String?, +): ColorProviders { + return when (backgroundType) { + WidgetBackgroundType.DYNAMICCOLOR -> GlanceTheme.colors + WidgetBackgroundType.DAYNIGHT -> HomeAssistantGlanceTheme.colors + WidgetBackgroundType.TRANSPARENT -> ColorProviders( + glanceHaLightColors + .copy( + background = Color.Transparent, + onSurface = Color( + textColor?.toColorInt() ?: glanceHaLightColors.onSurface.toArgb(), + ), + ), + ) + + else -> HomeAssistantGlanceTheme.colors + } +} + +class TapAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + updateAppWidgetState(context, glanceId) { + it[booleanPreferencesKey(IS_LOADING_KEY)] = true + } + ButtonGlanceAppWidget().update(context, glanceId) + val auth = parameters[authKey] ?: false + val widgetId = parameters[widgetIdKey] + val intent = Intent(context, ButtonWidget::class.java).apply { + action = if (auth) CALL_SERVICE_AUTH else CALL_SERVICE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + } + context.sendBroadcast(intent) + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview +@Composable +private fun ErrorScreenPreview() { + HomeAssistantGlanceTheme { + ErrorScreen(LocalContext.current, LocalGlanceId.current) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt index 0fda6371b9f..4a064e29869 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt @@ -1,44 +1,25 @@ package io.homeassistant.companion.android.widgets.button -import android.app.PendingIntent import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.graphics.Color -import android.view.View -import android.widget.RemoteViews import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.toColorInt -import androidx.core.os.BundleCompat -import com.google.android.material.color.DynamicColors -import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.IconicsSize -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial -import com.mikepenz.iconics.utils.padding -import com.mikepenz.iconics.utils.size +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.common.util.FailFast import io.homeassistant.companion.android.common.util.MapAnySerializer import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity -import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.util.getAttribute -import io.homeassistant.companion.android.util.icondialog.getIconByMdiName -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 io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver +import io.homeassistant.companion.android.widgets.EntitiesPerServer import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity import java.util.regex.Pattern -import javax.inject.Inject import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -50,257 +31,50 @@ import kotlinx.coroutines.withContext import timber.log.Timber @AndroidEntryPoint -class ButtonWidget : AppWidgetProvider() { - companion object { - const val CALL_SERVICE = - "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE" - private const val CALL_SERVICE_AUTH = - "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE_AUTH" - - // Vector icon rendering resolution fallback (if we can't infer via AppWidgetManager for some reason) - private const val DEFAULT_MAX_ICON_SIZE = 512 - private var widgetScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - } - - @Inject - lateinit var serverManager: ServerManager - - @Inject - lateinit var buttonWidgetDao: ButtonWidgetDao - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - // There may be multiple widgets active, so update all of them - for (appWidgetId in appWidgetIds) { - widgetScope.launch { - val views = getWidgetRemoteViews(context, appWidgetId) - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } - } - - private suspend fun updateAllWidgets(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) ?: return - val systemWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, ButtonWidget::class.java)) - val dbWidgetList = buttonWidgetDao.getAll() - - val invalidWidgetIds = dbWidgetList - .filter { !systemWidgetIds.contains(it.id) } - .map { it.id } - if (invalidWidgetIds.isNotEmpty()) { - Timber.i("Found widgets $invalidWidgetIds in database, but not in AppWidgetManager - sending onDeleted") - onDeleted(context, invalidWidgetIds.toIntArray()) - } - - val buttonWidgetEntityList = dbWidgetList.filter { systemWidgetIds.contains(it.id) } - if (buttonWidgetEntityList.isNotEmpty()) { - Timber.d("Updating all widgets") - for (item in buttonWidgetEntityList) { - val views = getWidgetRemoteViews(context, item.id) - - setLabelVisibility(views, item) - views.setViewVisibility(R.id.widgetProgressBar, View.INVISIBLE) - views.setViewVisibility(R.id.widgetImageButtonLayout, View.VISIBLE) - appWidgetManager.updateAppWidget(item.id, views) - } - } - } - - override fun onDeleted(context: Context, appWidgetIds: IntArray) { - // When the user deletes the widget, delete the preference associated with it. - widgetScope.launch { - buttonWidgetDao.deleteAll(appWidgetIds) - } - } - - override fun onEnabled(context: Context) { - // Enter relevant functionality for when the first widget is created +class ButtonWidget : BaseGlanceEntityWidgetReceiver() { + override suspend fun getWidgetEntitiesByServer(context: Context): Map { + return dao.getAll().associate { widget -> widget.id to EntitiesPerServer(widget.serverId, listOf()) } } - override fun onDisabled(context: Context) { - // Enter relevant functionality for when the last widget is disabled - } + override val glanceAppWidget: GlanceAppWidget = ButtonGlanceAppWidget() override fun onReceive(context: Context, intent: Intent) { val action = intent.action val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) - - Timber.d( - "Broadcast received: " + System.lineSeparator() + - "Broadcast action: " + action + System.lineSeparator() + - "AppWidgetId: " + appWidgetId, - ) + Timber.d("Broadcast received \nBroadcast action: $action \nAppWidgetId: $appWidgetId") +// Timber.d( +// "Broadcast received: " + System.lineSeparator() + +// "Broadcast action: " + action + System.lineSeparator() + +// "AppWidgetId: " + appWidgetId, +// ) super.onReceive(context, intent) when (action) { CALL_SERVICE_AUTH -> authThenCallConfiguredAction(context, appWidgetId) CALL_SERVICE -> widgetScope.launch { callConfiguredAction(context, appWidgetId) } - BaseWidgetProvider.UPDATE_WIDGETS, Intent.ACTION_SCREEN_ON -> widgetScope.launch { - updateAllWidgets( - context, - ) - } - - ACTION_APPWIDGET_CREATED -> { - widgetScope.launch { - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - FailFast.fail { "Missing appWidgetId in intent to add widget in DAO" } - } else { - val entity = intent.extras?.let { - BundleCompat.getSerializable( - it, - EXTRA_WIDGET_ENTITY, - ButtonWidgetEntity::class.java, - ) - } - entity?.let { - buttonWidgetDao.add(entity.copyWithWidgetId(appWidgetId)) - } ?: FailFast.fail { "Missing $EXTRA_WIDGET_ENTITY or it's of the wrong type in intent." } - } - updateAllWidgets(context) - } - } } } + private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int) { Timber.d("Calling authentication, then configured action") val intent = Intent(context, WidgetAuthenticationActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - context.startActivity(intent) - } - - private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews { - // Every time AppWidgetManager.updateAppWidget(...) is called, the button listener - // and label need to be re-assigned, or the next time the layout updates - // (e.g home screen rotation) the widget will fall back on its default layout - // without any click listener being applied - - val widget = buttonWidgetDao.get(appWidgetId) - val auth = widget?.requireAuthentication == true - - val intent = Intent(context, ButtonWidget::class.java).apply { - action = if (auth) CALL_SERVICE_AUTH else CALL_SERVICE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - } - - val useDynamicColors = - widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() - return RemoteViews( - context.packageName, - if (useDynamicColors) { - R.layout.widget_button_wrapper_dynamiccolor - } else { - R.layout.widget_button_wrapper_default - }, - ).apply { - // Theming - var textColor = context.getAttribute( - R.attr.colorWidgetOnBackground, - ContextCompat.getColor(context, commonR.color.colorWidgetButtonLabel), - ) - if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) { - widget.textColor?.let { textColor = it.toColorInt() } - setTextColor(R.id.widgetLabel, textColor) - } - setWidgetBackground(this, widget) - - // Label - setLabelVisibility(this, widget) - - // Content - val iconData = widget?.iconName?.let { CommunityMaterial.getIconByMdiName(it) } - ?: CommunityMaterial.Icon2.cmd_flash // Lightning bolt - - val iconDrawable = IconicsDrawable(context, iconData).apply { - padding = IconicsSize.dp(2) - size = IconicsSize.dp(24) - } - val icon = DrawableCompat.wrap(iconDrawable) - if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) { - setInt(R.id.widgetImageButton, "setColorFilter", textColor) - } - - // Determine reasonable dimensions for drawing vector icon as a bitmap - val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble() - val awo = if (widget != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(widget.id) else null - val maxWidth = ( - awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, DEFAULT_MAX_ICON_SIZE) - ?: DEFAULT_MAX_ICON_SIZE - ).coerceAtLeast(16) - val maxHeight = ( - awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, DEFAULT_MAX_ICON_SIZE) - ?: DEFAULT_MAX_ICON_SIZE - ).coerceAtLeast(16) - val width: Int - val height: Int - if (maxWidth > maxHeight) { - width = maxWidth - height = (maxWidth * (1 / aspectRatio)).toInt() - } else { - width = (maxHeight * aspectRatio).toInt() - height = maxHeight - } - - // Render the icon into the Button's ImageView - setImageViewBitmap(R.id.widgetImageButton, icon.toBitmap(width, height)) - - setOnClickPendingIntent( - R.id.widgetImageButtonLayout, - PendingIntent.getBroadcast( - context, - appWidgetId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), - ) - setTextViewText( - R.id.widgetLabel, - widget?.label ?: "", - ) - } - } - - private fun setWidgetBackground(views: RemoteViews, widget: ButtonWidgetEntity?) { - when (widget?.backgroundType) { - WidgetBackgroundType.TRANSPARENT -> { - views.setInt(R.id.widgetLayout, "setBackgroundColor", Color.TRANSPARENT) - } - - else -> { - views.setInt(R.id.widgetLayout, "setBackgroundResource", R.drawable.widget_button_background) - } - } - } - - private fun setLabelVisibility(views: RemoteViews, widget: ButtonWidgetEntity?) { - val labelVisibility = if (widget?.label.isNullOrBlank()) View.GONE else View.VISIBLE - views.setViewVisibility(R.id.widgetLabelLayout, labelVisibility) } private suspend fun callConfiguredAction(context: Context, appWidgetId: Int) { Timber.d("Calling widget action") + val widget = dao.get(appWidgetId) - // Set up progress bar as immediate feedback to show the click has been received - // Success or failure feedback will come from the mainScope coroutine - val loadingViews = RemoteViews(context.packageName, R.layout.widget_button) - val appWidgetManager = AppWidgetManager.getInstance(context) - - loadingViews.setViewVisibility(R.id.widgetProgressBar, View.VISIBLE) - loadingViews.setViewVisibility(R.id.widgetImageButtonLayout, View.GONE) - appWidgetManager.partiallyUpdateAppWidget(appWidgetId, loadingViews) - - val widget = buttonWidgetDao.get(appWidgetId) - // Set default feedback as negative - var feedbackColor = R.drawable.widget_button_background_red - var feedbackIcon = R.drawable.ic_clear_black - - // Load the action call data from Shared Preferences val domain = widget?.domain val action = widget?.service val actionDataJson = widget?.serviceData + val manager = GlanceAppWidgetManager(context) + val glanceId = manager.getGlanceIdBy(appWidgetId) + Timber.d( "Action Call Data loaded:" + System.lineSeparator() + "domain: " + domain + System.lineSeparator() + @@ -339,34 +113,44 @@ class ButtonWidget : AppWidgetProvider() { Timber.d("Action call sent successfully") // If action call does not throw an exception, send positive feedback - feedbackColor = R.drawable.widget_button_background_green - feedbackIcon = R.drawable.ic_check_black_24dp + updateButtonWidgetState(context = context, glanceId = glanceId, wasSent = true, isSuccess = true) } catch (e: CancellationException) { + updateButtonWidgetState(context = context, glanceId = glanceId) throw e } catch (e: Exception) { Timber.e(e, "Could not send action call.") + updateButtonWidgetState(context = context, glanceId = glanceId) withContext(Dispatchers.Main) { Toast.makeText(context, commonR.string.action_failure, Toast.LENGTH_LONG).show() } } } + delay(1.seconds) + glanceAppWidget.update(context, glanceId) + } + + private suspend fun updateButtonWidgetState(context: Context, glanceId: GlanceId, isLoading: Boolean = false, wasSent: Boolean = false, isSuccess: Boolean = false) { + updateAppWidgetState(context = context, glanceId = glanceId) { + it[booleanPreferencesKey(IS_LOADING_KEY)] = isLoading + it[booleanPreferencesKey(SENT_SUCCESSFUL_KEY)] = wasSent + it[booleanPreferencesKey(IS_SUCCESS_KEY)] = isSuccess + } + } - // Update widget and set visibilities for feedback - val feedbackViews = RemoteViews(context.packageName, R.layout.widget_button) - feedbackViews.setInt(R.id.widgetLayout, "setBackgroundResource", feedbackColor) - feedbackViews.setImageViewResource(R.id.widgetImageButton, feedbackIcon) - feedbackViews.setViewVisibility(R.id.widgetProgressBar, View.INVISIBLE) - feedbackViews.setViewVisibility(R.id.widgetLabelLayout, View.GONE) - feedbackViews.setViewVisibility(R.id.widgetImageButtonLayout, View.VISIBLE) - appWidgetManager.partiallyUpdateAppWidget(appWidgetId, feedbackViews) + companion object { - // Reload default views in the coroutine to pass to the post handler - val views = getWidgetRemoteViews(context, appWidgetId) + const val IS_LOADING_KEY = "isLoading" + const val IS_SUCCESS_KEY = "isSuccess" + const val SENT_SUCCESSFUL_KEY = "wasSentSuccessfully" + const val CALL_SERVICE = + "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE" + const val CALL_SERVICE_AUTH = + "io.homeassistant.companion.android.widgets.button.ButtonWidget.CALL_SERVICE_AUTH" - // Set a timer to change it back after 1 second - delay(1.seconds) - setLabelVisibility(views, widget) - setWidgetBackground(views, widget) - appWidgetManager.updateAppWidget(appWidgetId, views) + // Vector icon rendering resolution fallback (if we can't infer via AppWidgetManager for some reason) + const val DEFAULT_MAX_ICON_SIZE = 256 + private var widgetScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) } + + } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt old mode 100644 new mode 100755 index 41ce0efc856..a807970cf61 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -2,31 +2,67 @@ package io.homeassistant.companion.android.widgets.button import android.annotation.SuppressLint import android.appwidget.AppWidgetManager -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter +import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import android.widget.EditText -import android.widget.Spinner -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.core.graphics.toColorInt -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.IconButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial 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.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.theme.HATextStyle +import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.util.MapAnySerializer @@ -34,441 +70,548 @@ import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity +import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.applySafeDrawingInsets +import io.homeassistant.companion.android.util.compose.MdcAlertDialog import io.homeassistant.companion.android.util.getHexForColor -import io.homeassistant.companion.android.util.icondialog.IconDialogFragment -import io.homeassistant.companion.android.util.icondialog.getIconByMdiName -import io.homeassistant.companion.android.util.icondialog.mdiName -import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity +import io.homeassistant.companion.android.util.icondialog.IconDialog +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 +import io.homeassistant.companion.android.widgets.button.ButtonWidgetViewModel.ButtonWidgetUiState import io.homeassistant.companion.android.widgets.common.ActionFieldBinder -import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import io.homeassistant.companion.android.widgets.common.WidgetDynamicFieldAdapter import io.homeassistant.companion.android.widgets.common.WidgetUtils -import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber // TODO Migrate to compose https://github.com/home-assistant/android/issues/6305 @AndroidEntryPoint -class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { - private var actions = mutableMapOf>() - private var entities = mutableMapOf>() - private var dynamicFields = ArrayList() - private lateinit var dynamicFieldAdapter: WidgetDynamicFieldAdapter +class ButtonWidgetConfigureActivity : BaseActivity() { + private val viewModel: ButtonWidgetViewModel by viewModels() - private lateinit var binding: WidgetButtonConfigureBinding + private val supportedTextColors: List + get() = listOf( + application.getHexForColor(commonR.color.colorWidgetButtonLabelBlack), + application.getHexForColor(android.R.color.white), + ) - override val serverSelect: View - get() = binding.serverSelect + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override val serverSelectList: Spinner - get() = binding.serverSelectList + // 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) - private var requestLauncherSetup = false + // Find the widget id from the intent. + val appWidgetId = + intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + val requestLauncherSetup = intent.extras?.getBoolean( + ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, + false, + ) ?: false - private var actionAdapter: SingleItemArrayAdapter? = null + // If this activity was started with an intent without an app widget ID, finish with an error. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { + finish() + return + } - private val onAddFieldListener = View.OnClickListener { - val context = this@ButtonWidgetConfigureActivity - val fieldKeyInput = EditText(context) - fieldKeyInput.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) + viewModel.onSetup(appWidgetId, requestLauncherSetup, supportedTextColors) - AlertDialog.Builder(context) - .setTitle("Field") - .setView(fieldKeyInput) - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .setPositiveButton(android.R.string.ok) { _, _ -> - if (dynamicFields.any { - it.field == binding.widgetTextConfigService.text.toString() - } + setContent { + HATheme { + ButtonWidgetConfigureScreen(viewModel, { onAddWidgetClicked() }) + } + } + } + + private fun onAddWidgetClicked() { + lifecycleScope.launch { + if (intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true) { + if ( + SdkVersion.isAtLeast(Build.VERSION_CODES.O) ) { - return@setPositiveButton + requestPinWidget() + } else { + showAddWidgetError() } - - val position = dynamicFields.size - dynamicFields.add( - position, - ActionFieldBinder( - binding.widgetTextConfigService.text.toString(), - fieldKeyInput.text.toString(), - ), - ) - - dynamicFieldAdapter.notifyItemInserted(position) + } else { + onUpdateWidget() } - .show() + } } - private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus -> - if (hasFocus && view is AutoCompleteTextView) { - view.showDropDown() + @SuppressLint("ObsoleteSdkInt") + @RequiresApi(Build.VERSION_CODES.O) + private fun requestPinWidget() { + val context = this@ButtonWidgetConfigureActivity + lifecycleScope.launch { + viewModel.requestWidgetCreation(context) + finish() } } - private val actionTextWatcher: TextWatcher = ( - object : TextWatcher { - private var ongoingJob: Job? = null - - @SuppressLint("NotifyDataSetChanged") - override fun afterTextChanged(p0: Editable?) { - val actionText: String = p0.toString() - // To make sure only one job at the time is updating the data we keep a reference to the job and cancel - // it if it gets call again. - ongoingJob?.cancel() - ongoingJob = lifecycleScope.launch { - if (actions[selectedServerId].orEmpty().keys.contains(actionText)) { - Timber.d("Valid domain and action--processing dynamic fields") - - // Make sure there are not already any dynamic fields created - // This can happen if selecting the drop-down twice or pasting - dynamicFields.clear() - - // We only call this if servicesAvailable was fetched and is not null, - // so we can safely assume that it is not null here - val actionData = actions[selectedServerId]!![actionText]!!.actionData - val target = actionData.target - val fields = actionData.fields - - val fieldKeys = fields.keys - Timber.d("Fields applicable to this action: $fields") - - val existingActionData = mutableMapOf() - val addedFields = mutableListOf() - dao.get(appWidgetId)?.let { buttonWidget -> - if ( - buttonWidget.serverId != selectedServerId || - "${buttonWidget.domain}.${buttonWidget.service}" != actionText - ) { - return@let - } + private suspend fun onUpdateWidget() { + try { + viewModel.updateWidgetConfiguration() + setResult(RESULT_OK) + viewModel.updateWidget(this@ButtonWidgetConfigureActivity) + finish() + } catch (_: Exception) { + showUpdateWidgetError() + } + } - val dbMap: Map = kotlinJsonMapper.decodeFromString( - MapAnySerializer, - buttonWidget.serviceData, - ) - for (item in dbMap) { - val value = - item.value.toString().replace("[", "").replace("]", "") + - if (item.key == "entity_id") ", " else "" - existingActionData[item.key] = value.ifEmpty { null } - addedFields.add(item.key) - } - } + private fun showAddWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() + } - if (target != false) { - dynamicFields.add( - 0, - ActionFieldBinder(actionText, "entity_id", existingActionData["entity_id"]), - ) - } + private fun showUpdateWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_update_error, Toast.LENGTH_LONG).show() + } +} + +@Composable +private fun ButtonWidgetConfigureScreen(viewModel: ButtonWidgetViewModel, onAddWidgetClicked: () -> Unit) { + val state by viewModel.uiState.collectAsStateWithLifecycle(ButtonWidgetUiState()) + LaunchedEffect(viewModel.actionFieldState) { + snapshotFlow { viewModel.actionFieldState.text.toString() }.collectLatest { + viewModel.updateActionText(it) + } + } + ButtonWidgetConfigureView( + actionFieldState = viewModel.actionFieldState, + servers = state.servers, + selectedServerId = state.selectedServerId, + onServerSelected = viewModel::setServer, + serverActions = state.serverActions, + dynamicFields = state.dynamicFields, + onDynamicFieldUpdated = viewModel::updateDynamicField, + domainEntities = state.domainEntities, + icon = state.selectedIcon, + onIconSelected = viewModel::selectIcon, + onAddFieldDialogOkClicked = viewModel::addDynamicField, + label = state.label, + onLabelUpdated = viewModel::updateLabel, + textColorIndex = state.textColorIndex, + onTextColorSelected = viewModel::updateTextColorIndex, + selectedBackgroundType = state.selectedBackgroundType, + onBackgroundTypeSelected = viewModel::updateSelectedBackgroundType, + isRequireAuthenticationChecked = state.requiresAuthentication, + onRequireAuthenticationChecked = viewModel::setRequiresAuthentication, + isUpdatingWidget = state.isUpdating ?: false, + onAddWidgetClicked = onAddWidgetClicked, + ) +} - fieldKeys.sorted().forEach { fieldKey -> - Timber.d("Creating a text input box for $fieldKey") - - // Insert a dynamic layout - // IDs get priority and go at the top, since the other fields - // are usually optional but the ID is required - if (fieldKey.contains("_id")) { - dynamicFields.add( - 0, - ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey]), - ) - } else { - dynamicFields.add(ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey])) +@Composable +private fun ButtonWidgetConfigureView( + actionFieldState: TextFieldState, + servers: List, + selectedServerId: Int?, + onServerSelected: (Int) -> Unit, + serverActions: List, + dynamicFields: List, + onDynamicFieldUpdated: (Int, String) -> Unit, + domainEntities: List, + icon: IIcon, + onIconSelected: (IIcon) -> Unit, + onAddFieldDialogOkClicked: (Int, ActionFieldBinder) -> Unit, + label: String, + onLabelUpdated: (String) -> Unit, + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + textColorIndex: Int, + onTextColorSelected: (Int) -> Unit, + isRequireAuthenticationChecked: Boolean, + onRequireAuthenticationChecked: (Boolean) -> Unit, + isUpdatingWidget: Boolean, + onAddWidgetClicked: () -> Unit, +) { + var showAddFieldDialog by remember { mutableStateOf(false) } + var showIconDialog by remember { mutableStateOf(false) } + Timber.i("Selected Server: $selectedServerId") + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.configure_action)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { contentPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(contentPadding) + .padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showAddFieldDialog) { + val actionText = actionFieldState.text as String + AddFieldDialog( + action = actionText, + onCancel = { + showAddFieldDialog = false + }, + onOk = { actionField -> + if (dynamicFields.any { + it.field == actionText } + ) { + showAddFieldDialog = false + return@AddFieldDialog } - addedFields.minus("entity_id").minus(fieldKeys).forEach { extraFieldKey -> - Timber.d("Creating a text input box for extra $extraFieldKey") - dynamicFields.add( - ActionFieldBinder(actionText, extraFieldKey, existingActionData[extraFieldKey]), - ) - } - dynamicFieldAdapter.notifyDataSetChanged() - } else { - if (dynamicFields.isNotEmpty()) { - dynamicFields.clear() - dynamicFieldAdapter.notifyDataSetChanged() - } - } - } - } - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} - } - ) + onAddFieldDialogOkClicked(dynamicFields.size, actionField) + showAddFieldDialog = false + }, + modifier = Modifier, + ) + } - private fun getActionString(action: Action): String { - return "${action.domain}.${action.action}" - } + if (showIconDialog) { + IconDialog( + onSelect = { + showIconDialog = false + onIconSelected(it) + }, + onDismissRequest = { showIconDialog = false }, + ) + } - public override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + if (servers.size > 1) { + ServerSelector( + servers = servers, + selectedServerId = selectedServerId, + onServerSelected = onServerSelected, + modifier = Modifier.fillMaxWidth(), + ) + } - // 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) + ActionTextFieldInput(actionFieldState, serverActions) - binding = WidgetButtonConfigureBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.root.applySafeDrawingInsets() + if (dynamicFields.isNotEmpty()) { + dynamicFields.forEachIndexed { index, fieldBinder -> + DynamicFieldInput(index, fieldBinder, domainEntities, onDynamicFieldUpdated) + } + } - // 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, + HAAccentButton( + text = stringResource(commonR.string.add_action_data_field), + onClick = { showAddFieldDialog = true }, + modifier = Modifier.align(Alignment.End), ) - requestLauncherSetup = extras.getBoolean( - ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, - false, + + IconSelector( + icon, + { + showIconDialog = true + }, ) - } - // If this activity was started with an intent without an app widget ID, finish with an error. - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { - finish() - return - } + OutlinedTextField( + label = { Text(text = stringResource(commonR.string.label)) }, + value = label, + onValueChange = onLabelUpdated, + textStyle = HATextStyle.UserInput, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + ) - val backgroundTypeValues = WidgetUtils.getBackgroundOptionList(this) - binding.backgroundType.adapter = - ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, backgroundTypeValues) + BackgroundTypeSelector(selectedBackgroundType, onBackgroundTypeSelected) - lifecycleScope.launch { - val buttonWidget = dao.get(appWidgetId) - if (buttonWidget != null) { - val actionText = "${buttonWidget.domain}.${buttonWidget.service}" - binding.widgetTextConfigService.setText(actionText) - binding.label.setText(buttonWidget.label) - binding.backgroundType.setSelection( - WidgetUtils.getSelectedBackgroundOption( - this@ButtonWidgetConfigureActivity, - buttonWidget.backgroundType, - backgroundTypeValues, - ), - ) - binding.textColor.isVisible = buttonWidget.backgroundType == WidgetBackgroundType.TRANSPARENT - binding.textColorWhite.isChecked = - buttonWidget.textColor?.let { - it.toColorInt() == ContextCompat.getColor( - this@ButtonWidgetConfigureActivity, - android.R.color.white, - ) - } - ?: true - binding.textColorBlack.isChecked = - buttonWidget.textColor?.let { - it.toColorInt() == - ContextCompat.getColor( - this@ButtonWidgetConfigureActivity, - commonR.color.colorWidgetButtonLabelBlack, - ) - } - ?: false + if (selectedBackgroundType == WidgetBackgroundType.TRANSPARENT) { + WidgetTextColorSelector(textColorIndex, onTextColorSelected) + } - binding.addButton.setText(commonR.string.update_widget) + RequireAuthCheckbox(isChecked = isRequireAuthenticationChecked, onChecked = onRequireAuthenticationChecked) - binding.widgetCheckboxRequireAuthentication.isChecked = buttonWidget.requireAuthentication - } else { - binding.backgroundType.setSelection(0) - } + val addButtonStringResource = + if (isUpdatingWidget) commonR.string.update_widget else commonR.string.add_widget + HAAccentButton( + text = stringResource(addButtonStringResource), + onClick = onAddWidgetClicked, + modifier = Modifier.align(Alignment.End), + ) + } + } +} - setupServerSelect(buttonWidget?.serverId) - - // Do this off the main thread, takes a second or two... - runOnUiThread { - // Create an icon pack and load all drawables. - val iconName = buttonWidget?.iconName ?: "mdi:flash" - val icon = CommunityMaterial.getIconByMdiName(iconName) ?: CommunityMaterial.Icon2.cmd_flash - onIconDialogIconsSelected(icon) - binding.widgetConfigIconSelector.setOnClickListener { - var alertDialog: DialogFragment? = null - - alertDialog = IconDialogFragment( - callback = { - onIconDialogIconsSelected(it) - alertDialog?.dismiss() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionTextFieldInput( + actionFieldState: TextFieldState, + serverActions: List, + modifier: Modifier = Modifier, +) { + var isActionDropdownExpanded by remember { mutableStateOf(false) } + Box { + OutlinedTextField( + label = { Text(text = stringResource(commonR.string.label_action)) }, + state = actionFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = HATextStyle.UserInput, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { state -> + isActionDropdownExpanded = state.hasFocus + }, + ) + if (serverActions.isNotEmpty()) { + DropdownMenu( + expanded = isActionDropdownExpanded, + onDismissRequest = { + isActionDropdownExpanded = false + }, + properties = PopupProperties(focusable = false), + ) { + serverActions.forEach { action -> + val text = "${action.domain}.${action.action}" + DropdownMenuItem( + text = { Text(text = text) }, + onClick = { + actionFieldState.setTextAndPlaceCursorAtEnd(text) + isActionDropdownExpanded = false }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, ) - - alertDialog.show(supportFragmentManager, IconDialogFragment.TAG) } } } + } +} - actionAdapter = SingleItemArrayAdapter(this) { - if (it != null) getActionString(it) else "" +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DynamicFieldInput( + index: Int, + field: ActionFieldBinder, + domainEntities: List, + onDynamicFieldUpdated: (Int, String) -> Unit, + modifier: Modifier = Modifier, +) { + var isEntityDropdownExpanded by remember { mutableStateOf(false) } + val initialText = field.value as? String + val fieldInputState = rememberTextFieldState(initialText = initialText ?: "") + LaunchedEffect(fieldInputState) { + snapshotFlow { fieldInputState.text.toString() }.collectLatest { + onDynamicFieldUpdated(index, it) } - binding.widgetTextConfigService.setAdapter(actionAdapter) - binding.widgetTextConfigService.onFocusChangeListener = dropDownOnFocus - - lifecycleScope.launch { - serverManager.servers().forEach { server -> - launch { - try { - actions[server.id] = HashMap() - serverManager.integrationRepository(server.id).getServices()?.forEach { - actions[server.id]!![getActionString(it)] = it - } - if (server.id == selectedServerId) setAdapterActions(server.id) - } catch (e: Exception) { - // Custom components can cause actions to not load - // Display error text - Timber.e(e, "Unable to load actions from Home Assistant") - if (server.id == selectedServerId) binding.widgetConfigServiceError.visibility = View.VISIBLE - } - } - launch { - try { - entities[server.id] = HashMap() - serverManager.integrationRepository(server.id).getEntities()?.forEach { - entities[server.id]!![it.entityId] = it - } - if (server.id == selectedServerId) setAdapterActions(server.id) - } catch (e: Exception) { - // If entities fail to load, it's okay to pass - // an empty map to the dynamicFieldAdapter + } + Box { + OutlinedTextField( + label = { Text(text = field.field) }, + state = fieldInputState, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = HATextStyle.UserInput, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { state -> + if (field.field == "entity_id") { + isEntityDropdownExpanded = state.hasFocus } + }, + ) + if (field.field == "entity_id" && domainEntities.isNotEmpty()) { + DropdownMenu( + expanded = isEntityDropdownExpanded, + onDismissRequest = { + isEntityDropdownExpanded = false + }, + properties = PopupProperties(focusable = false), + ) { + domainEntities.forEach { entity -> + DropdownMenuItem( + text = { Text(text = entity) }, + onClick = { + fieldInputState.setTextAndPlaceCursorAtEnd("${fieldInputState.text} $entity, ") + isEntityDropdownExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) } } } + } +} - binding.widgetTextConfigService.addTextChangedListener(actionTextWatcher) - - 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.isVisible = false - } - } - - binding.addFieldButton.setOnClickListener(onAddFieldListener) - binding.addButton.setOnClickListener { - if (requestLauncherSetup) { - val widgetConfigAction = binding.widgetTextConfigService.text.toString() - if (SdkVersion.isAtLeast(Build.VERSION_CODES.O) && - selectedServerId != null && - ( - widgetConfigAction in actions[selectedServerId].orEmpty().keys || - widgetConfigAction.split(".", limit = 2).size == 2 - ) - ) { - lifecycleScope.launch { - requestWidgetCreation() - } - } else { - showAddWidgetError() - } - } else { - lifecycleScope.launch { - updateWidget() - } - } +@Composable +fun IconSelector(icon: IIcon, onIconSelectorClicked: () -> Unit, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(commonR.string.label_icon)) + IconButton( + onClick = onIconSelectorClicked, + modifier = Modifier.size(24.dp), + ) { + com.mikepenz.iconics.compose.Image( + asset = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(colorResource(commonR.color.colorIcon)), + ) } - - dynamicFieldAdapter = WidgetDynamicFieldAdapter(HashMap(), HashMap(), dynamicFields) - binding.widgetConfigFieldsLayout.adapter = dynamicFieldAdapter - binding.widgetConfigFieldsLayout.layoutManager = LinearLayoutManager(this) } +} - override fun onServerSelected(serverId: Int) { - binding.widgetTextConfigService.setText("") - setAdapterActions(serverId) +@Composable +fun RequireAuthCheckbox(isChecked: Boolean, onChecked: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isChecked, + onCheckedChange = { onChecked(it) }, + ) + Text(text = stringResource(commonR.string.widget_checkbox_require_authentication)) } +} - override suspend fun getPendingDaoEntity(): ButtonWidgetEntity { - val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } - val actionText = binding.widgetTextConfigService.text.toString() - val actions = actions[serverId].orEmpty() - val actionTextParts = actionText.split(".", limit = 2) - val domain = actions[actionText]?.domain ?: actionTextParts.getOrElse(0) { "" } - val action = actions[actionText]?.action ?: actionTextParts.getOrElse(1) { "" } - val actionDataMap = HashMap() - - dynamicFields.forEach { - var value = it.value - if (value != null) { - if (it.field == "entity_id" && value is String) { - // Remove trailing commas and spaces - val trailingRegex = "[, ]+$".toRegex() - value = value.replace(trailingRegex, "") - } - actionDataMap[it.field] = value - } - } +@Composable +fun AddFieldDialog( + action: String, + onCancel: (() -> Unit)?, + onOk: ((ActionFieldBinder) -> Unit)?, + modifier: Modifier = Modifier, +) { + val inputValue = remember { mutableStateOf("") } + + MdcAlertDialog( + modifier = modifier, + onDismissRequest = { }, + title = { Text(text = "Field") }, + content = { + TextField( + value = inputValue.value, + onValueChange = { input: String -> + inputValue.value = input + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth(), + ) + }, + onCancel = onCancel, + onSave = null, + onOK = { + val field = ActionFieldBinder(action, inputValue.value) + onOk?.invoke(field) + }, + ) +} - return ButtonWidgetEntity( - id = appWidgetId, - serverId = serverId, - domain = domain, - service = action, - label = binding.label.text.toString(), - iconName = binding.widgetConfigIconSelector.tag as String, - serviceData = kotlinJsonMapper.encodeToString(MapAnySerializer, actionDataMap), - 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 - }, - requireAuthentication = binding.widgetCheckboxRequireAuthentication.isChecked, - ) +@Composable +fun ServerSelector( + servers: List, + selectedServerId: Int?, + onServerSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val modelsByKey = remember(servers) { + servers.associateBy { it.id } } - override val widgetClass: Class<*> = ButtonWidget::class.java - - @SuppressLint("NotifyDataSetChanged") - private fun setAdapterActions(serverId: Int) { - Timber.d("Actions found: $actions") - actionAdapter?.clearAll() - if (actions[serverId] != null) { - actionAdapter?.addAll(actions[serverId]?.values.orEmpty().toMutableList()) - actionAdapter?.sort() + val items = remember(modelsByKey) { + modelsByKey.map { (id, server) -> + HADropdownItem(key = id, label = server.friendlyName) } - dynamicFieldAdapter.replaceValues( - actions[serverId].orEmpty() as HashMap, - entities[serverId].orEmpty() as HashMap, - ) + } - actionTextWatcher.afterTextChanged(binding.widgetTextConfigService.text) + HADropdownMenu( + items = items, + selectedKey = selectedServerId, + onItemSelected = { onServerSelected(it) }, + modifier = modifier, + label = "Select Server", + ) +} - // Update action adapter - runOnUiThread { - actionAdapter?.filter?.filter(binding.widgetTextConfigService.text) +@Composable +fun BackgroundTypeSelector( + selectedBackgroundType: WidgetBackgroundType, + onBackgroundTypeSelected: (WidgetBackgroundType) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val keys = remember { WidgetUtils.getBackgroundOptionList(context) } + val modelsByKey = remember(keys) { + keys.withIndex().associateBy { it.index } + } + val items = remember(modelsByKey) { + modelsByKey.map { (id, backgroundType) -> + HADropdownItem(key = id, label = backgroundType.value) } } + HADropdownMenu( + items = items, + selectedKey = WidgetUtils.getSelectedBackgroundOption(context, selectedBackgroundType, keys), + onItemSelected = { onBackgroundTypeSelected(WidgetUtils.getWidgetBackgroundType(context, keys[it])) }, + label = "Select background type", + modifier = modifier, + ) +} - private fun onIconDialogIconsSelected(selectedIcon: IIcon) { - binding.widgetConfigIconSelector.tag = selectedIcon.mdiName - val iconDrawable = IconicsDrawable(this, selectedIcon) - iconDrawable.colorFilter = - PorterDuffColorFilter(ContextCompat.getColor(this, commonR.color.colorIcon), PorterDuff.Mode.SRC_IN) +@Composable +fun WidgetTextColorSelector(textColorIndex: Int, onTextColorSelected: (Int) -> Unit, modifier: Modifier = Modifier) { + HADropdownMenu( + items = listOf( + HADropdownItem(0, stringResource(commonR.string.widget_text_color_black)), + HADropdownItem(1, stringResource(commonR.string.widget_text_color_white)), + ), + selectedKey = textColorIndex, + onItemSelected = { onTextColorSelected(it) }, + label = stringResource(commonR.string.widget_text_color_title), + modifier = modifier, + ) +} + +@Composable +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ButtonWidgetConfigureScreenPreview() { + HATheme { + ButtonWidgetConfigureView( + actionFieldState = rememberTextFieldState(), + servers = listOf( + previewServer1, + previewServer2, + ), + selectedServerId = 0, + onServerSelected = {}, + serverActions = emptyList(), + dynamicFields = listOf(ActionFieldBinder("Test Action", "Test", 1)), + onDynamicFieldUpdated = { i: Int, value: String -> }, + domainEntities = emptyList(), + icon = CommunityMaterial.Icon2.cmd_flash, + onIconSelected = {}, + label = "", + onLabelUpdated = {}, + textColorIndex = 0, + onTextColorSelected = {}, + onAddFieldDialogOkClicked = { i: Int, binder: ActionFieldBinder -> }, + selectedBackgroundType = WidgetBackgroundType.TRANSPARENT, + onBackgroundTypeSelected = {}, + isRequireAuthenticationChecked = false, + onRequireAuthenticationChecked = {}, + isUpdatingWidget = false, + onAddWidgetClicked = {}, + ) + } +} - binding.widgetConfigIconSelector.setImageBitmap(iconDrawable.toBitmap()) +@Composable +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AddFieldDialogPreview() { + HATheme { + AddFieldDialog("", {}, {}, modifier = Modifier) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt new file mode 100644 index 00000000000..4b486a17346 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetState.kt @@ -0,0 +1,76 @@ +package io.homeassistant.companion.android.widgets.button + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.toColorInt +import androidx.glance.GlanceTheme +import androidx.glance.color.ColorProviders +import androidx.glance.material.ColorProviders +import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme +import io.homeassistant.companion.android.util.compose.glanceHaLightColors + +sealed interface ButtonWidgetState { + val backgroundType: WidgetBackgroundType + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + } + val textColor: String? + get() = null + + companion object { + @Composable + fun ButtonWidgetState.getColors(): ColorProviders { + return when (backgroundType) { + WidgetBackgroundType.DYNAMICCOLOR -> GlanceTheme.colors + WidgetBackgroundType.DAYNIGHT -> HomeAssistantGlanceTheme.colors + WidgetBackgroundType.TRANSPARENT -> ColorProviders( + glanceHaLightColors + .copy( + background = Color.Transparent, + onSurface = Color( + textColor?.toColorInt() ?: glanceHaLightColors.onSurface.toArgb(), + ), + ), + ) + } + } + } +} + +internal object Loading : ButtonWidgetState +internal object Error : ButtonWidgetState +internal object Success : ButtonWidgetState + +internal data class ButtonStateWithData( + override val backgroundType: WidgetBackgroundType, + override val textColor: String?, + val id: Int, + val label: String?, + val icon: String, + val requiresAuthentication: Boolean +) : ButtonWidgetState { + + companion object { + /** + * Create a complete [ButtonStateWithData] from the DB + */ + fun from( + buttonEntity: ButtonWidgetEntity, + ): ButtonStateWithData { + return ButtonStateWithData( + id = buttonEntity.id, + backgroundType = buttonEntity.backgroundType, + textColor = buttonEntity.textColor, + label = buttonEntity.label, + icon = buttonEntity.iconName, + requiresAuthentication = buttonEntity.requireAuthentication + ) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt new file mode 100644 index 00000000000..73d60f8b852 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetStateUpdater.kt @@ -0,0 +1,77 @@ +package io.homeassistant.companion.android.widgets.button + +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import timber.log.Timber + +@Singleton +class ButtonWidgetStateUpdater @Inject constructor( + val buttonWidgetDao: ButtonWidgetDao, +) { + + private val isActionRunning = MutableStateFlow>(emptyMap()) + private val isActionError = MutableStateFlow>(emptyMap()) + private val isActionSuccess = MutableStateFlow>(emptyMap()) + + @OptIn(ExperimentalCoroutinesApi::class) + fun getButtonEntityFlow(widgetId: Int): Flow { + val runningFlow = isActionRunning + .map { it[widgetId] ?: false } + .distinctUntilChanged() + val errorFlow = isActionError + .map { it[widgetId] ?: false } + .distinctUntilChanged() + val successFlow = isActionSuccess + .map { it[widgetId] ?: false } + .distinctUntilChanged() + + return combine(runningFlow, errorFlow, successFlow, buttonWidgetDao.getFlow(widgetId)) { running, error, success, entity -> + Quartet(running, error, success, entity) + }.mapLatest { (running, error, success, entity) -> + Timber.i("Widget $widgetId is running: $running, error: $error, success: $success") + if (running) { + Loading + } else if (error) { + Error + } else if (success) { + Success + } else if (entity != null) { + ButtonStateWithData.from(entity) + } else { + null + } + } + } + + fun updateIsActionRunning(widgetId: Int, isRunning: Boolean) { + isActionRunning.value = isActionRunning.value.toMutableMap().apply { + put(widgetId, isRunning) + } + } + + fun updateIsActionError(widgetId: Int, isError: Boolean) { + isActionError.value = isActionError.value.toMutableMap().apply { + put(widgetId, isError) + } + } + + fun updateIsActionSuccess(widgetId: Int, isSuccess: Boolean) { + isActionSuccess.value = isActionSuccess.value.toMutableMap().apply { + put(widgetId, isSuccess) + } + } +} +data class Quartet( + val first: A, + val second: B, + val third: C, + val fourth: D +) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt new file mode 100755 index 00000000000..1adb4b3a499 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidgetViewModel.kt @@ -0,0 +1,448 @@ +package io.homeassistant.companion.android.widgets.button + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.ui.unit.min +import androidx.glance.action.action +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.currentState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.color.DynamicColors +import com.mikepenz.iconics.typeface.IIcon +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.integration.Action +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.MapAnySerializer +import io.homeassistant.companion.android.common.util.kotlinJsonMapper +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.icondialog.getIconByMdiName +import io.homeassistant.companion.android.util.icondialog.mdiName +import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import io.homeassistant.companion.android.widgets.common.ActionFieldBinder +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +@HiltViewModel +class ButtonWidgetViewModel @Inject constructor( + val buttonWidgetDao: ButtonWidgetDao, + val serverManager: ServerManager, +) : ViewModel() { + + data class ButtonWidgetUiState( + val selectedServerId: Int? = ServerManager.SERVER_ID_ACTIVE, + val servers: List = emptyList(), + val serverActions: List = emptyList(), + val dynamicFields: List = emptyList(), + val domainEntities: List = emptyList(), + val selectedIcon: IIcon = CommunityMaterial.Icon2.cmd_flash, + val selectedIconId: String? = null, + val label: String = "", + val selectedBackgroundType: WidgetBackgroundType = if (DynamicColors.isDynamicColorAvailable()) { + WidgetBackgroundType.DYNAMICCOLOR + } else { + WidgetBackgroundType.DAYNIGHT + }, + val textColorIndex: Int = 0, + val requiresAuthentication: Boolean = false, + val isUpdating: Boolean? = false, + ) + + val actionFieldState: TextFieldState = TextFieldState() + + private val _uiState: MutableStateFlow = MutableStateFlow(ButtonWidgetUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var supportedTextColors: List = emptyList() + + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + private var requestLauncherSetup: Boolean = false + + private var actions = mutableMapOf>() + private var entities = mutableMapOf>() + private val selectedServerMutex = Mutex() + private var selectedServerActions: List = emptyList() + + private var ongoingJob: Job? = null + + fun onSetup(widgetId: Int, requestLauncherSetup: Boolean, supportedTextColors: List) { + this.requestLauncherSetup = requestLauncherSetup + this.supportedTextColors = supportedTextColors + this.widgetId = widgetId + viewModelScope.launch { + updateUiState(servers = serverManager.servers(), selectedServerId = serverManager.getServer()?.id) + for (server in serverManager.servers()) { + getActionsFromServer(server) + getEntitiesFromServer(server) + } + maybeLoadPreviousState(widgetId) + } + } + + /** + * Return a [ButtonWidgetEntity] with the current selection, but without pushing this to the [buttonWidgetDao] + */ + private fun getPendingDaoEntity(): ButtonWidgetEntity { + val state = _uiState.value + with(state) { + val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } + val actionText = actionFieldState.text + val actions = actions[serverId].orEmpty() + val actionTextParts = actionText.split(".", limit = 2) + val domain = actions[actionText]?.domain ?: actionTextParts.getOrElse(0) { "" } + val action = actions[actionText]?.action ?: actionTextParts.getOrElse(1) { "" } + val actionDataMap = HashMap() + + dynamicFields.forEach { + var value = it.value + if (value != null) { + if (it.field == "entity_id" && value is String) { + // Remove trailing commas and spaces + val trailingRegex = "[, ]+$".toRegex() + value = value.replace(trailingRegex, "") + } + actionDataMap[it.field] = value + } + } + + return ButtonWidgetEntity( + id = widgetId, + serverId = serverId, + domain = domain, + service = action, + label = label, + iconName = selectedIcon.mdiName, + serviceData = kotlinJsonMapper.encodeToString(MapAnySerializer, actionDataMap), + backgroundType = selectedBackgroundType, + textColor = supportedTextColors[textColorIndex], + requireAuthentication = requiresAuthentication, + ) + } + } + + fun updateWidget(context: Context) { + val appContext = context.applicationContext + viewModelScope.launch { + val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(widgetId) + ButtonGlanceAppWidget().update(appContext, glanceId) + } + } + + private suspend fun maybeLoadPreviousState(widgetId: Int) { + buttonWidgetDao.get(widgetId)?.let { widget -> + val icon = CommunityMaterial.getIconByMdiName(widget.iconName) + val colorIndex = supportedTextColors.indexOf(widget.textColor) + val action = "${widget.domain}.${widget.service}" + updateActionText(action) + setServer(widget.serverId) + updateLabel(widget.label) + updateSelectedBackgroundType(widget.backgroundType) + selectIcon(icon) + updateTextColorIndex(if (colorIndex == -1) 0 else colorIndex) + setRequiresAuthentication(widget.requireAuthentication) + updateUiState(isUpdating = true) + } + } + + private fun updateUiState( + selectedServerId: Int? = null, + servers: List? = null, + serverActions: List? = null, + dynamicFields: List? = null, + domainEntities: List? = null, + selectedIcon: IIcon? = null, + selectedIconId: String? = null, + label: String? = null, + selectedBackgroundType: WidgetBackgroundType? = null, + textColorIndex: Int? = null, + requiresAuthentication: Boolean? = null, + isUpdating: Boolean? = null + ) { + _uiState.update { currentState -> + currentState.copy( + selectedServerId = selectedServerId ?: currentState.selectedServerId, + servers = servers ?: currentState.servers, + serverActions = serverActions ?: currentState.serverActions, + dynamicFields = dynamicFields ?: currentState.dynamicFields, + domainEntities = domainEntities ?: currentState.domainEntities, + selectedIcon = selectedIcon ?: currentState.selectedIcon, + selectedIconId = selectedIconId ?: currentState.selectedIconId, + label = label ?: currentState.label, + selectedBackgroundType = selectedBackgroundType ?: currentState.selectedBackgroundType, + textColorIndex = textColorIndex ?: currentState.textColorIndex, + requiresAuthentication = requiresAuthentication ?: currentState.requiresAuthentication, + isUpdating = isUpdating ?: currentState.isUpdating + ) + } + } + + private suspend fun getActionsFromServer(server: Server) { + val selectedServerId = _uiState.value.selectedServerId + try { + actions[server.id] = HashMap() + serverManager.integrationRepository(server.id).getServices()?.forEach { + actions[server.id]?.set(getActionString(it), it) + } + if (server.id == selectedServerId) setAdapterActions(server.id) + } catch (e: Exception) { + // Custom components can cause actions to not load + // Display error text + Timber.e(e, "Unable to load actions from Home Assistant") + } + } + + private suspend fun getEntitiesFromServer(server: Server) { + val selectedServerId = _uiState.value.selectedServerId + try { + entities[server.id] = HashMap() + serverManager.integrationRepository(server.id).getEntities()?.forEach { + entities[server.id]?.set(it.entityId, it) + } + if (server.id == selectedServerId) setAdapterActions(server.id) + } catch (e: Exception) { + // If entities fail to load, it's okay to pass + // an empty map to the dynamicFieldAdapter + } + } + + fun updateActionText(newAction: String) { + actionFieldState.setTextAndPlaceCursorAtEnd(newAction) + updateActionFields(newAction) + filterAdapterActions(newAction) + } + + private fun getActionString(action: Action): String { + return "${action.domain}.${action.action}" + } + + fun updateLabel(newLabel: String?) { + updateUiState(label = newLabel ?: "") + } + + fun setServer(serverId: Int) { + val selectedServerId = _uiState.value.selectedServerId + if (selectedServerId == serverId) return + actionFieldState.clearText() + updateUiState(selectedServerId = serverId) + viewModelScope.launch { + selectedServerMutex.withLock { + setAdapterActions(serverId) + } + } + } + + fun addDynamicField(position: Int, field: ActionFieldBinder) { + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + dynamicFields.add(position, field) + updateDynamicFields(dynamicFields = dynamicFields) + } + + private fun updateDynamicFields(dynamicFields: List) { + updateUiState(dynamicFields = dynamicFields) + } + + fun updateDynamicField(index: Int, value: String) { + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + val field = dynamicFields[index] + field.value = value + dynamicFields[index] = field + updateDynamicFields(dynamicFields = dynamicFields) + } + + fun selectIcon(icon: IIcon?) { + updateUiState(selectedIcon = icon, selectedIconId = icon?.mdiName) + } + + fun updateSelectedBackgroundType(backgroundType: WidgetBackgroundType) { + updateUiState(selectedBackgroundType = backgroundType) + } + + fun updateTextColorIndex(textColorIndex: Int) { + updateUiState(textColorIndex = textColorIndex) + } + + fun setRequiresAuthentication(authenticationRequired: Boolean) { + updateUiState(requiresAuthentication = authenticationRequired) + } + + fun setServerActions(actions: List) { + updateUiState(serverActions = actions) + } + + fun updateActionFields(actionText: String) { + val dynamicFields = _uiState.value.dynamicFields.toMutableList() + val selectedServerId = _uiState.value.selectedServerId + ongoingJob?.cancel() + ongoingJob = viewModelScope.launch { + if (actions[selectedServerId].orEmpty().keys.contains(actionText)) { + Timber.d("Valid domain and action--processing dynamic fields") + + // Make sure there are not already any dynamic fields created + // This can happen if selecting the drop-down twice or pasting + dynamicFields.clear() + + // We only call this if servicesAvailable was fetched and is not null, + // so we can safely assume that it is not null here + val actionData = actions[selectedServerId]!![actionText]!!.actionData + val target = actionData.target + val fields = actionData.fields + + val fieldKeys = fields.keys + Timber.d("Fields applicable to this action: $fields") + + val existingActionData = mutableMapOf() + val addedFields = mutableListOf() + buttonWidgetDao.get(widgetId)?.let { buttonWidget -> + if ( + buttonWidget.serverId != selectedServerId || + "${buttonWidget.domain}.${buttonWidget.service}" != actionText + ) { + return@let + } + + val dbMap: Map = kotlinJsonMapper.decodeFromString( + MapAnySerializer, + buttonWidget.serviceData, + ) + for (item in dbMap) { + val value = + item.value.toString().replace("[", "").replace("]", "") + + if (item.key == "entity_id") ", " else "" + // Ignore if value is just a comma + existingActionData[item.key] = if (value.isEmpty() || value.trim() == ",") { null } else value + addedFields.add(item.key) + } + } + + if (target != false) { + dynamicFields.add( + 0, + ActionFieldBinder(actionText, "entity_id", existingActionData["entity_id"]), + ) + setEntitiesForAction() + } + + fieldKeys.sorted().forEach { fieldKey -> + Timber.d("Creating a text input box for $fieldKey") + + // Insert a dynamic layout + // IDs get priority and go at the top, since the other fields + // are usually optional but the ID is required + if (fieldKey.contains("_id")) { + dynamicFields.add( + 0, + ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey]), + ) + } else { + dynamicFields.add(ActionFieldBinder(actionText, fieldKey, existingActionData[fieldKey])) + } + } + addedFields.minus("entity_id").minus(fieldKeys).forEach { extraFieldKey -> + Timber.d("Creating a text input box for extra $extraFieldKey") + dynamicFields.add( + ActionFieldBinder(actionText, extraFieldKey, existingActionData[extraFieldKey]), + ) + } + } else { + if (dynamicFields.isNotEmpty()) { + dynamicFields.clear() + } + } + updateDynamicFields(dynamicFields) + } + } + + private fun setAdapterActions(serverId: Int) { + Timber.i("Setting Adapter Actions") + var selectedServerActions: List = emptyList() + if (actions[serverId] != null) { + selectedServerActions = actions[serverId]?.values.orEmpty().toMutableList() + val comparator = Comparator { t1: Action, t2: Action -> + getActionString(t1).compareTo(getActionString(t2)) + } + this.selectedServerActions = selectedServerActions.sortedWith(comparator) + setServerActions(this.selectedServerActions) + } + } + + private fun filterAdapterActions(constraint: CharSequence) { + // Split Domain from String + val domain = constraint.split(".")[0] + val validItems = ArrayList() + for (i in 0 until selectedServerActions.size) { + val item = selectedServerActions[i] + if (getActionString(item).contains(domain)) { + validItems.add(item) + } + } + setServerActions(validItems) + } + + private fun setEntitiesForAction() { + val actionText = actionFieldState.text + val selectedServerId = _uiState.value.selectedServerId + val entityMap = entities[selectedServerId] ?: return + val domain = actions[selectedServerId]?.get(actionText)?.domain + + val domainEntities = mutableListOf("all") + + if (domain == ("homeassistant") || domain == null) { + domainEntities.addAll(entityMap.keys) + } else { + entityMap.keys.forEach { + if (it.startsWith(domain) || it.startsWith("group")) { + domainEntities.add(it) + } + } + } + updateUiState(domainEntities = domainEntities) + } + + suspend fun requestWidgetCreation(context: Context) { + // We drop the first value since we only care about knowing when the widget is actually added + buttonWidgetDao.getWidgetCountFlow().drop(1).onStart { + GlanceAppWidgetManager(context) + .requestPinGlanceAppWidget( + ButtonWidget::class.java, + successCallback = PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + Intent(context, ButtonWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, getPendingDaoEntity()) + }, + // We need the PendingIntent to be mutable so the system inject the EXTRA_APPWIDGET_ID of the created widget + PendingIntent.FLAG_MUTABLE, + ), + ) + }.first() + } + + suspend fun updateWidgetConfiguration() { + val entity = getPendingDaoEntity() + buttonWidgetDao.add(entity) + } +} diff --git a/app/src/main/res/layout/widget_button_configure.xml b/app/src/main/res/layout/widget_button_configure.xml index 5b332b8de9c..b8041bfeb3e 100644 --- a/app/src/main/res/layout/widget_button_configure.xml +++ b/app/src/main/res/layout/widget_button_configure.xml @@ -25,7 +25,8 @@ android:layout_height="50dp" android:gravity="center" android:orientation="horizontal" - android:visibility="gone"> + android:visibility="gone" + tools:visibility="visible"> + android:visibility="gone" + tools:visibility="visible"/> - \ No newline at end of file + diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt index f64f8ab43c4..feb1bed38fe 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/ButtonWidgetDao.kt @@ -11,6 +11,9 @@ interface ButtonWidgetDao : WidgetDao { @Query("SELECT * FROM button_widgets WHERE id = :id") suspend fun get(id: Int): ButtonWidgetEntity? + @Query("SELECT * FROM button_widgets WHERE id = :id") + fun getFlow(id: Int): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun add(entity: ButtonWidgetEntity) diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 5dd05066d78..1e46a567e20 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -375,7 +375,7 @@ Entity ID(s): Icon: Label: - Action: + Action Label Device language default