diff --git a/app/gradle.lockfile b/app/gradle.lockfile index d3038a4fcc4..c02783fcadf 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -631,6 +631,8 @@ org.jetbrains.kotlin:kotlin-tooling-core:2.3.0=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:swift-export-embeddable:2.2.21=swiftExportClasspathResolvable org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0=fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0=fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestRuntimeClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestRuntimeClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 84305ccf7b7..00c025802b0 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + - - - - + + + + + + + + + + + + + + + + @@ -300,6 +311,12 @@ + + + + + LoadingScreen() + is GridStateWithData -> GridContentWithData(state) + } +} + +@Composable +private fun LoadingScreen() { + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.gridWidgetBackground().semantics { testTag = "LoadingScreen" }, + ) { + CircularProgressIndicator( + color = GlanceTheme.colors.primary, + modifier = GlanceModifier.size(HomeAssistantGlanceTheme.dimensions.iconSize), + ) + } +} + +@Composable +private fun GridContentWithData(state: GridStateWithData, modifier: GlanceModifier = GlanceModifier) { + fun titleBar(): @Composable (() -> Unit) = { + state.label?.let { label -> + TitleBar( + startIcon = ImageProvider(R.drawable.ic_home_variant_outline), + title = label, + actions = { + CircleIconButton( + imageProvider = ImageProvider(R.drawable.ic_refresh), + contentDescription = glanceStringResource(commonR.string.refresh), + onClick = actionRefreshGrid(), + backgroundColor = null, + ) + }, + ) + } + } + + val showTitleBar = showTitleBar() + val gridCells = gridCells() + val scaffoldTopPadding = if (showTitleBar) HADimens.SPACE0 else HADimens.SPACE3 + + Scaffold( + modifier = modifier + .gridWidgetBackground() + .padding(top = scaffoldTopPadding), + titleBar = if (showTitleBar && state.label != null) titleBar() else null, + ) { + GridContentWithData( + state = state, + gridCells = gridCells, + ) + } +} + +@Composable +private fun GridContentWithData(state: GridStateWithData, gridCells: Int, modifier: GlanceModifier = GlanceModifier) { + fun buttonProvider(): @Composable (GridButtonData) -> Unit = { + GridButton( + modifier = GlanceModifier.fillMaxSize(), + label = it.label, + icon = it.icon, + state = it.state, + isActive = it.isActive, + buttonColors = state.getButtonColors(), + onClick = actionPressEntity(it.id), + ) + } + + if (gridCells == 1) { + LazyButtonColumn( + modifier = modifier.fillMaxSize(), + items = state.items, + itemContentProvider = buttonProvider(), + ) + } else { + LazyButtonGrid( + modifier = modifier.fillMaxSize(), + gridCells = gridCells, + items = state.items, + itemContentProvider = buttonProvider(), + ) + } +} + +/** + * Size of the widget per the reference breakpoints. Each size has its own display + * characteristics such as - showing content as list vs grid, font sizes etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class GridWidgetLayoutSize(val maxWidth: Dp) { + // Compact buttons, no title bar + Small(maxWidth = 180.dp), + + // Icon and text + Normal(maxWidth = 439.dp), + ; + + companion object { + /** + * Returns the corresponding [GridWidgetLayoutSize] to be considered for the current + * widget size. + */ + @Composable + fun fromLocalSize(): GridWidgetLayoutSize { + val width = LocalSize.current.width + return if (width >= Small.maxWidth) Normal else Small + } + + @Composable + fun showTitleBar(): Boolean { + return fromLocalSize() == Normal && LocalSize.current.height >= Small.maxWidth + } + + @Composable + fun gridCells(): Int { + return when (val width = LocalSize.current.width) { + in 0.dp..Small.maxWidth -> ceil(width / 96.dp).toInt() + else -> ceil(width / 260.dp).toInt() + } + } + } +} + +@Composable +private fun LazyButtonColumn( + items: ImmutableList, + itemContentProvider: @Composable (item: GridButtonData) -> Unit, + modifier: GlanceModifier = GlanceModifier, +) { + LazyColumn( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(items) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .padding(bottom = HADimens.SPACE2), + ) { + itemContentProvider(it) + } + } + } +} + +@Composable +private fun LazyButtonGrid( + gridCells: Int, + items: ImmutableList, + itemContentProvider: @Composable (item: GridButtonData) -> Unit, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + cellSpacing: Dp = 12.dp, +) { + val numRows = ceil(items.size.toDouble() / gridCells).toInt() + + // Cell spacing is achieved by allocating equal amount of padding to each cell. Cells on edge + // apply it completely to inner sides, while cells not on edge apply it evenly on sides. + val perCellHorizontalPadding = (cellSpacing * (gridCells - 1)) / gridCells + val perCellVerticalPadding = (cellSpacing * (numRows - 1)) / numRows + + LazyVerticalGrid( + modifier = modifier, + gridCells = GridCells.Fixed(gridCells), + horizontalAlignment = horizontalAlignment, + ) { + itemsIndexed(items, itemId = { index, item -> index.toLong() }) { index, item -> + val row = index / gridCells + val column = index % gridCells + + val cellTopPadding = when (row) { + 0 -> 0.dp + numRows - 1 -> perCellVerticalPadding + else -> perCellVerticalPadding / 2 + } + + val cellBottomPadding = when (row) { + 0 -> perCellVerticalPadding + numRows - 1 -> perCellVerticalPadding + else -> perCellVerticalPadding / 2 + } + + val cellStartPadding = when (column) { + 0 -> 0.dp + gridCells - 1 -> perCellHorizontalPadding + else -> perCellHorizontalPadding / 2 + } + + val cellEndPadding = when (column) { + 0 -> perCellHorizontalPadding + gridCells - 1 -> 0.dp + else -> perCellHorizontalPadding / 2 + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .padding( + start = cellStartPadding, + end = cellEndPadding, + top = cellTopPadding, + bottom = cellBottomPadding, + ), + ) { + itemContentProvider(item) + } + } + } +} + +@Composable +private fun GridButton( + label: String, + icon: String, + onClick: Action, + modifier: GlanceModifier = GlanceModifier, + state: String? = null, + isActive: Boolean = false, + buttonColors: GridButtonColors = GridButtonColors.Default, +) { + val size = GridWidgetLayoutSize.fromLocalSize() + + val isCompact = size == GridWidgetLayoutSize.Small + val horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start + val baseModifier = if (!isCompact) modifier.height(HADimens.SPACE16) else modifier + val innerPadding = if (isCompact) HADimens.SPACE2 else HADimens.SPACE3 + val backgroundColor = if (isActive) buttonColors.activeBackgroundColor else buttonColors.backgroundColor + val contentColor = if (isActive) buttonColors.activeContentColor else buttonColors.contentColor + + Row( + modifier = baseModifier + .clickable(onClick) + .cornerRadius(HARadius.XL) + .padding(innerPadding) + .background(backgroundColor), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = horizontalAlignment, + ) { + GridButtonIcon(icon, colorFilter = ColorFilter.tint(contentColor)) + + if (!isCompact) { + Spacer(GlanceModifier.width(HADimens.SPACE2)) + Column { + Text( + text = label, + style = HomeAssistantGlanceTypography.bodyLarge.copy(color = contentColor), + maxLines = 1, + ) + Text( + text = state ?: "—", + style = HomeAssistantGlanceTypography.bodySmall.copy(color = contentColor), + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun GridButtonIcon(icon: String, colorFilter: ColorFilter, modifier: GlanceModifier = GlanceModifier) { + val iconProvider = rememberMdiIconProvider(icon) + Image( + modifier = modifier.size(24.dp), + provider = iconProvider, + contentDescription = null, + colorFilter = colorFilter, + ) +} + +@Composable +private fun rememberMdiIconProvider(name: String): ImageProvider { + val context = LocalContext.current + return remember(name) { + val bitmap = CommunityMaterial.getIconByMdiName(name)?.let { + IconicsDrawable(context, it).toBitmap() + } + bitmap?.let(::ImageProvider) ?: ImageProvider(commonR.drawable.ic_priority) + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 96, heightDp = 200) +@Preview(widthDp = 259, heightDp = 200) +@Preview(widthDp = 259, heightDp = 120) +@Preview(widthDp = 438, heightDp = 200) +@Preview(widthDp = 644, heightDp = 200) +private annotation class GridWidgetBreakpointPreviews + +@OptIn(ExperimentalGlancePreviewApi::class) +@Composable +@GridWidgetBreakpointPreviews +private fun DynamicGridWidgetPreview() { + HomeAssistantGlanceTheme(GlanceTheme.colors) { + GridContentWithData( + modifier = GlanceModifier.cornerRadius(32.dp), + state = GridStateWithData( + label = "Home", + items = persistentListOf( + GridButtonData("0", "Living room", "mdi:lightbulb", "Off"), + GridButtonData("1", "Bedside table lamp", "mdi:lamp", "On", true), + GridButtonData("2", "Bedroom temperature", "mdi:thermometer", "15 ºC"), + GridButtonData("3", "Main entrance", "mdi:gesture-tap-button", "5 min ago"), + GridButtonData("4", "Test 5", "mdi:aaa"), + ), + ), + ) + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Composable +@GridWidgetBreakpointPreviews +private fun LoadingPreview() { + HomeAssistantGlanceTheme(GlanceTheme.colors) { + LoadingScreen() + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidget.kt new file mode 100644 index 00000000000..ae012c33e99 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidget.kt @@ -0,0 +1,29 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.homeassistant.companion.android.widgets.BaseGlanceEntityWidgetReceiver +import io.homeassistant.companion.android.widgets.EntitiesPerServer + +/** + * Receiver for the Grid Glance Widget. + * + * Manages lifecycle events and entity updates for the grid widget. Maps widget IDs to their associated entities + * and integrates with the database via [GridWidgetDao]. + * + * Note: Don't forget to register this Receiver in the manifest and in the Application. + * Otherwise the widgets won't update at all after the composition ends. + */ +@AndroidEntryPoint +class GridWidget : BaseGlanceEntityWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = GridGlanceAppWidget() + + override suspend fun getWidgetEntitiesByServer(context: Context): Map = dao + .getAll() + .associate { widget -> + widget.id to EntitiesPerServer(widget.serverId, widget.items.map { it.entityId }) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetActions.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetActions.kt new file mode 100644 index 00000000000..8ae8a67d3f9 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetActions.kt @@ -0,0 +1,112 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.action.Action +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import timber.log.Timber + +/** + * Get an Action that will toggle the given Entity of a grid widget once given to Glance. + */ +@Composable +internal fun actionPressEntity(entityId: String): Action { + return actionRunCallback(actionParametersOf(ENTITY_ID_KEY to entityId)) +} + +/** + * Get an Action that will refresh the grid widget once given to Glance. + */ +@Composable +internal fun actionRefreshGrid(): Action { + return actionRunCallback() +} + +/** + * Basic action that will refresh the given widget. Use [actionRefreshGrid] to get the + * Action for Glance. + * + * Note: This needs to be public since it is instantiated by the Glance framework. + * + * From the doc https://developer.android.com/design/ui/mobile/guides/widgets/widget_quality_guide#tier2-content: + * > Widget must let users manually refresh content, if there is an expectation the data refreshes more frequently than the UI. + */ +class RefreshAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + GridGlanceAppWidget().update(context, glanceId) + } +} + +@VisibleForTesting +internal val ENTITY_ID_KEY = ActionParameters.Key("ENTITY_ID_KEY") + +/** + * Action that will press the given Entity through a given parameters with the key [ENTITY_ID_KEY]. Use [actionPressEntity] to get the + * Action for Glance. + * + * The action calls the server to toggle the item. On Success the widget is updated and the state will be updated + * through the [GridWidgetStateUpdater]. + * + * Note: This needs to be public since it is instantiated by the Glance framework. + */ +class PressEntityAction : ActionCallback { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PressEntityActionEntryPoint { + fun serverManager(): ServerManager + fun gridWidgetDao(): GridWidgetDao + } + + @VisibleForTesting + fun getEntryPoints(context: Context): PressEntityActionEntryPoint { + return EntryPoints.get(context.applicationContext, PressEntityActionEntryPoint::class.java) + } + + @VisibleForTesting + fun getGlanceManager(context: Context): GlanceAppWidgetManager { + return GlanceAppWidgetManager(context) + } + + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val entryPoints = getEntryPoints(context) + val serverManager = entryPoints.serverManager() + val glanceManager = getGlanceManager(context) + val appWidgetId = glanceManager.getAppWidgetId(glanceId) + val dao = entryPoints.gridWidgetDao() + + val entityId = parameters[ENTITY_ID_KEY] + + if (entityId == null) { + Timber.w("Aborting toggle action because entityId is null") + return + } + + val widgetEntity = dao.get(appWidgetId) + + if (widgetEntity == null) { + Timber.w("Aborting press action widget entity is null for $appWidgetId") + return + } + + onEntityPressedWithoutState( + entityId = entityId, + integrationRepository = serverManager.integrationRepository(widgetEntity.serverId), + ) + + GridGlanceAppWidget().update(context, glanceId) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetState.kt new file mode 100644 index 00000000000..89f4ae879e9 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetState.kt @@ -0,0 +1,94 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.os.Build +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +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 androidx.glance.unit.ColorProvider +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.util.compose.HomeAssistantGlanceTheme +import io.homeassistant.companion.android.util.compose.glanceHaLightColors +import kotlinx.collections.immutable.ImmutableList +import kotlinx.parcelize.Parcelize + +@Parcelize +internal data object GridButtonState : Parcelable + +internal sealed interface GridWidgetState { + 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 GridWidgetState.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(), + ), + ), + ) + } + } + + @Composable + @ReadOnlyComposable + fun GridWidgetState.getButtonColors(): GridButtonColors { + return when (backgroundType) { + WidgetBackgroundType.DYNAMICCOLOR -> GridButtonColors( + backgroundColor = GlanceTheme.colors.primaryContainer, + contentColor = GlanceTheme.colors.onPrimaryContainer, + activeBackgroundColor = GlanceTheme.colors.primary, + activeContentColor = GlanceTheme.colors.onPrimary, + ) + else -> GridButtonColors.Default + } + } + } +} + +internal object LoadingGridState : GridWidgetState + +internal data class GridStateWithData(val label: String?, val items: ImmutableList) : GridWidgetState + +internal data class GridButtonData( + val id: String, + val label: String, + val icon: String, + val state: String? = null, + val isActive: Boolean = false, +) + +internal data class GridButtonColors( + val backgroundColor: ColorProvider, + val contentColor: ColorProvider, + val activeBackgroundColor: ColorProvider = backgroundColor, + val activeContentColor: ColorProvider = contentColor, +) { + companion object { + val Default + @Composable + @ReadOnlyComposable + get() = GridButtonColors( + backgroundColor = GlanceTheme.colors.primary, + contentColor = GlanceTheme.colors.onPrimary, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdater.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdater.kt new file mode 100644 index 00000000000..3a333f0b938 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdater.kt @@ -0,0 +1,81 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.friendlyState +import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest +import timber.log.Timber + +internal class GridWidgetStateUpdater @Inject constructor( + private val gridWidgetDao: GridWidgetDao, + private val serverManager: ServerManager, + @param:ApplicationContext private val context: Context, +) { + fun stateFlow(widgetId: Int): Flow = gridWidgetDao.getFlow(widgetId) + .withEntityState() + .distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun Flow.withEntityState(): Flow = transformLatest { widgetEntity -> + // Use transformLatest to cancel previous entity fetches if widgetEntity changes quickly + var currentUiState = widgetEntity.toUiState() + emit(currentUiState) + + val entityIds = widgetEntity.items.map(GridWidgetEntity.Item::entityId) + val entityUpdates = getAndObserveEntities(widgetEntity.serverId, entityIds) + + entityUpdates?.collect { updatedEntity -> + val items = currentUiState.items.toMutableList() + val itemIndex = items.indexOfFirst { it.id == updatedEntity.entityId } + + if (itemIndex != -1) { + items[itemIndex] = items[itemIndex].copy( + state = updatedEntity.friendlyState(context, appendUnitOfMeasurement = true), + isActive = updatedEntity.isActive(), + ) + currentUiState = currentUiState.copy(items = items.toImmutableList()) + emit(currentUiState) + } + } + } + + private suspend fun getAndObserveEntities(serverId: Int, entityIds: List): Flow? { + val currentEntities = entityIds.asFlow().mapNotNull { + serverManager.integrationRepository(serverId).getEntity(it) + } + val entityUpdateFlow = serverManager.integrationRepository(serverId).getEntityUpdates(entityIds) + + if (entityUpdateFlow == null) { + Timber.w("Failed to get entity updates, the widget won't update") + } + + return entityUpdateFlow?.onStart { + emitAll(currentEntities) + } + } +} + +private fun GridWidgetEntity.toUiState() = GridStateWithData( + label = label, + items = items.map(GridWidgetEntity.Item::toUiState).toImmutableList(), +) + +private fun GridWidgetEntity.Item.toUiState() = GridButtonData( + id = entityId, + label = label, + icon = iconName, +) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtils.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtils.kt new file mode 100644 index 00000000000..c3616cfd70a --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtils.kt @@ -0,0 +1,32 @@ +package io.homeassistant.companion.android.widgets.grid + +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.homeassistant.companion.android.widgets.grid.config.GridConfiguration +import io.homeassistant.companion.android.widgets.grid.config.GridItem +import kotlinx.collections.immutable.toImmutableList + +internal fun GridConfiguration.asDbEntity(widgetId: Int) = GridWidgetEntity( + id = widgetId, + serverId = serverId, + label = label, + items = items.map { it.asDbEntity(widgetId) }, +) + +internal fun GridItem.asDbEntity(widgetId: Int) = GridWidgetEntity.Item( + gridId = widgetId, + entityId = entityId, + label = label, + iconName = icon, +) + +internal fun GridWidgetEntity.asGridConfiguration() = GridConfiguration( + serverId = serverId, + label = label, + items = items.map(GridWidgetEntity.Item::asGridItem).toImmutableList(), +) + +private fun GridWidgetEntity.Item.asGridItem() = GridItem( + label = label, + icon = iconName, + entityId = entityId, +) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfiguration.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfiguration.kt new file mode 100644 index 00000000000..635594205da --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfiguration.kt @@ -0,0 +1,15 @@ +package io.homeassistant.companion.android.widgets.grid.config + +import android.os.Parcelable +import io.homeassistant.companion.android.common.data.servers.ServerManager +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GridConfiguration( + val serverId: Int = ServerManager.SERVER_ID_ACTIVE, + val label: String? = null, + val items: List = emptyList(), +) : Parcelable + +@Parcelize +data class GridItem(val label: String, val icon: String, val entityId: String = "", val id: Int = 0) : Parcelable diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt new file mode 100644 index 00000000000..0c2a449db22 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt @@ -0,0 +1,346 @@ +package io.homeassistant.companion.android.widgets.grid.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +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.lifecycle.compose.collectAsStateWithLifecycle +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.SingleEntityPicker +import io.homeassistant.companion.android.util.previewEntity1 +import io.homeassistant.companion.android.util.previewEntity2 +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun GridWidgetConfigurationScreen(viewModel: GridConfigurationViewModel, onSubmit: (GridConfiguration) -> Unit) { + val servers by viewModel.servers.collectAsStateWithLifecycle() + val entities by viewModel.entities.collectAsStateWithLifecycle() + val config by viewModel.gridConfig.collectAsStateWithLifecycle() + + GridWidgetConfigurationContent( + servers = servers, + onServerSelected = viewModel::setServer, + entities = entities, + onSubmit = onSubmit, + config = config, + onNameChange = viewModel::setLabel, + onItemAdd = viewModel::addItem, + onItemEdit = viewModel::editItem, + onItemMove = viewModel::moveItem, + onItemDelete = viewModel::deleteItem, + ) +} + +@Composable +private fun GridWidgetConfigurationContent( + servers: ImmutableList, + onServerSelected: (Int) -> Unit, + entities: ImmutableList, + config: GridConfiguration, + onNameChange: (String) -> Unit, + onItemAdd: (Entity) -> Unit, + onItemEdit: (Int, Entity) -> Unit, + onItemMove: (GridItem, GridItem) -> Unit, + onItemDelete: (Int) -> Unit, + onSubmit: (config: GridConfiguration) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.widget_grid_title)) }, + windowInsets = safeTopWindowInsets(), + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground), + ) + }, + ) { padding -> + GridWidgetConfigurationContent( + modifier = Modifier + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding), + config = config, + onNameChange = onNameChange, + servers = servers, + onServerSelected = onServerSelected, + onItemMove = onItemMove, + entities = entities, + onItemEdit = onItemEdit, + onItemDelete = onItemDelete, + onItemAdd = onItemAdd, + onSubmit = onSubmit, + ) + } +} + +@Composable +private fun GridWidgetConfigurationContent( + config: GridConfiguration, + onNameChange: (String) -> Unit, + servers: ImmutableList, + onServerSelected: (Int) -> Unit, + onItemMove: (GridItem, GridItem) -> Unit, + entities: ImmutableList, + onItemEdit: (Int, Entity) -> Unit, + onItemDelete: (Int) -> Unit, + onItemAdd: (Entity) -> Unit, + onSubmit: (GridConfiguration) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + PreferenceSection { + var name by remember(config.label) { mutableStateOf(config.label.orEmpty()) } + TextField( + modifier = Modifier.fillMaxWidth(), + value = name, + label = { Text(stringResource(commonR.string.widget_grid_name)) }, + singleLine = true, + onValueChange = { + onNameChange(it) + name = it + }, + ) + + if (servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = config.serverId, + onSelected = { onServerSelected(it) }, + ) + } + } + + PreferenceSection( + modifier = Modifier.weight(1f), + title = stringResource(commonR.string.widget_grid_add_entities), + ) { + EntityList( + onItemMove = onItemMove, + config = config, + entities = entities, + onItemEdit = onItemEdit, + onItemDelete = onItemDelete, + onItemAdd = onItemAdd, + ) + } + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(HADimens.SPACE4), + onClick = { onSubmit(config) }, + ) { + Text(stringResource(commonR.string.update_widget)) + } + } +} + +@Composable +private fun EntityList( + onItemMove: (GridItem, GridItem) -> Unit, + config: GridConfiguration, + entities: ImmutableList, + onItemEdit: (Int, Entity) -> Unit, + onItemDelete: (Int) -> Unit, + onItemAdd: (Entity) -> Unit, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberLazyListState() + val reorderState = rememberReorderableLazyListState(lazyListState) { from, to -> + onItemMove(from.key as GridItem, to.key as GridItem) + } + LazyColumn( + modifier = modifier, + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + itemsIndexed( + items = config.items, + key = { _, item -> item }, + ) { index, item -> + ReorderableItem( + state = reorderState, + key = item, + ) { + EntityEditorRow( + entityId = item.entityId, + entities = entities, + onSelect = { selected -> + FailFast.failOnCatch( + { "Selected entity not found: $selected" }, + Unit, + ) { + val entity = entities.first { it.entityId == selected } + onItemEdit(index, entity) + } + true + }, + onDelete = { onItemDelete(index) }, + leadingContent = { + Image( + modifier = Modifier + .draggableHandle() + .size(width = 40.dp, height = 24.dp), + asset = CommunityMaterial.Icon.cmd_drag_horizontal_variant, + contentDescription = stringResource(commonR.string.hold_to_reorder), + colorFilter = ColorFilter.tint(LocalContentColor.current), + ) + }, + ) + } + } + item { + EntityEditorRow( + entities = entities, + onSelect = { selected -> + FailFast.failOnCatch( + { "Selected entity not found: $selected" }, + Unit, + ) { + val entity = entities.first { it.entityId == selected } + onItemAdd(entity) + } + false + }, + onDelete = {}, + leadingContent = { + Image( + modifier = Modifier.size(width = 40.dp, height = 24.dp), + asset = CommunityMaterial.Icon3.cmd_plus_thick, + contentDescription = stringResource(commonR.string.hold_to_reorder), + colorFilter = ColorFilter.tint(LocalContentColor.current), + ) + }, + ) + } + } +} + +@Composable +private fun EntityEditorRow( + entities: ImmutableList, + onSelect: (String) -> Boolean, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + entityId: String? = null, + leadingContent: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingContent?.invoke() + SingleEntityPicker( + modifier = Modifier.weight(1f), + label = { Text(stringResource(commonR.string.entity)) }, + entities = entities, + currentEntity = entityId, + onEntityCleared = onDelete, + onEntitySelected = onSelect, + ) + } +} + +@Composable +private fun PreferenceSection( + modifier: Modifier = Modifier, + title: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier) { + Column( + modifier = Modifier.padding(HADimens.SPACE4), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + title?.let { SectionHeader(title) } + content() + } + + Divider(Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SectionHeader(title: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier.padding(start = HADimens.SPACE12), + text = title, + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.primary, + ) +} + +@Preview(showBackground = true) +@Composable +private fun GridConfigurationPreview() { + HomeAssistantAppTheme { + GridWidgetConfigurationContent( + config = GridConfiguration( + serverId = 0, + label = "Home lights", + items = persistentListOf( + GridItem("Bedroom", "mdi:lightbulb"), + GridItem("Bedroom", "mdi:lightbulb"), + GridItem("Living room", "mdi:lightbulb"), + ), + ), + servers = persistentListOf( + previewServer1, + previewServer2, + ), + onServerSelected = {}, + entities = persistentListOf( + previewEntity1, + previewEntity2, + ), + onNameChange = {}, + onItemAdd = {}, + onItemEdit = { _, _ -> }, + onItemMove = { _, _ -> }, + onItemDelete = {}, + onSubmit = {}, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationViewModel.kt new file mode 100644 index 00000000000..7313170b035 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationViewModel.kt @@ -0,0 +1,189 @@ +package io.homeassistant.companion.android.widgets.grid.config + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import io.homeassistant.companion.android.widgets.grid.GridGlanceAppWidget +import io.homeassistant.companion.android.widgets.grid.GridWidget +import io.homeassistant.companion.android.widgets.grid.asDbEntity +import io.homeassistant.companion.android.widgets.grid.asGridConfiguration +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class GridConfigurationViewModel @Inject constructor( + private val gridWidgetDao: GridWidgetDao, + private val serverManager: ServerManager, + @param:ApplicationContext private val applicationContext: Context, +) : ViewModel() { + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + val servers = serverManager.serversFlow + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), persistentListOf()) + + private val _gridConfig = MutableStateFlow(GridConfiguration()) + val gridConfig = _gridConfig.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val entities: StateFlow> = gridConfig + .map { it.serverId } + .distinctUntilChanged() + .filterNotNull() + .mapLatest { serverId -> + if (serverManager.isRegistered()) { + serverManager.integrationRepository(serverId) + .getEntities() + .orEmpty() + .toImmutableList() + } else { + Timber.w("No server registered") + persistentListOf() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), persistentListOf()) + + fun onSetup(widgetId: Int) { + if (this.widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + loadPreviousState(widgetId) + } + this.widgetId = widgetId + } + + suspend fun isValidConfig(config: GridConfiguration): Boolean { + val entityIds = entities.value.map { it.entityId } + return serverManager.getServer(config.serverId) != null && config.items.all { it.entityId in entityIds } + } + + private fun loadPreviousState(widgetId: Int) = viewModelScope.launch { + gridWidgetDao.get(widgetId)?.let { dao -> + _gridConfig.update { dao.asGridConfiguration() } + } + } + + fun setServer(serverId: Int) = _gridConfig.update { currentConfig -> + if (currentConfig.serverId != serverId) { + currentConfig.copy( + serverId = serverId, + items = emptyList(), + ) + } else { + currentConfig + } + } + + fun setLabel(label: String) = _gridConfig.update { it.copy(label = label) } + + fun addItem(entity: Entity) = _gridConfig.update { it.copy(items = it.items + entity.asGridItem()) } + + fun editItem(selectedIndex: Int, entity: Entity) = _gridConfig.update { + it.copy(items = it.items.mapIndexed { index, it -> if (index == selectedIndex) entity.asGridItem() else it }) + } + + fun moveItem(from: GridItem, to: GridItem) = _gridConfig.update { + it.copy( + items = it.items.map { gridItem -> + if (gridItem == from) { + to + } else if (gridItem == to) { + from + } else { + gridItem + } + }, + ) + } + + fun deleteItem(selectedIndex: Int) = _gridConfig.update { + it.copy(items = it.items.filterIndexed { index, _ -> index != selectedIndex }) + } + + suspend fun updateWidgetConfiguration(config: GridConfiguration): Boolean { + if (!isValidConfig(config)) { + Timber.d("Widget data is invalid") + return false + } + + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.w("Widget ID is invalid") + return false + } + + Timber.d("Updating widget $widgetId with $config") + val entity = config.asDbEntity(widgetId) + gridWidgetDao.add(entity) + return true + } + + /** + * Requests the widget to be created and waits until it has been saved to 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. + */ + suspend fun requestWidgetCreation(context: Context, config: GridConfiguration) { + // We drop the first value since we only care about knowing when the widget is actually added + gridWidgetDao.getWidgetCountFlow().drop(1).onStart { + Timber.d("Requesting widget creation with config $config") + GlanceAppWidgetManager(context) + .requestPinGlanceAppWidget( + GridWidget::class.java, + successCallback = PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + Intent(context, GridWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, config.asDbEntity(widgetId)) + }, + // We need the PendingIntent to be mutable so the system inject the EXTRA_APPWIDGET_ID of the created widget + PendingIntent.FLAG_MUTABLE, + ), + ) + }.first() + } + + fun updateWidget(context: Context) = viewModelScope.launch { + val appContext = context.applicationContext + val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(widgetId) + GridGlanceAppWidget().update(appContext, glanceId) + } + + private fun Entity.asGridItem() = GridItem( + entityId = entityId, + label = friendlyName, + icon = getIcon(applicationContext).name, + ) +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridWidgetConfigureActivity.kt new file mode 100644 index 00000000000..d3bfb4bd704 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridWidgetConfigureActivity.kt @@ -0,0 +1,92 @@ +package io.homeassistant.companion.android.widgets.grid.config + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.BaseActivity +import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class GridWidgetConfigureActivity : BaseActivity() { + private val viewModel: GridConfigurationViewModel by viewModels() + + public 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) + val widgetId = intent.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + viewModel.onSetup(widgetId) + + setContent { + HomeAssistantAppTheme { + GridWidgetConfigurationScreen( + viewModel = viewModel, + onSubmit = ::onSubmit, + ) + } + } + } + + @SuppressLint("ObsoleteSdkInt") + private fun onSubmit(config: GridConfiguration) { + lifecycleScope.launch { + if (intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + viewModel.isValidConfig(config) + ) { + requestPinWidget(config) + } else { + showAddWidgetError() + } + } else { + onUpdateWidget(config) + } + } + } + + @SuppressLint("ObsoleteSdkInt") + @RequiresApi(Build.VERSION_CODES.O) + private fun requestPinWidget(config: GridConfiguration) { + val context = this@GridWidgetConfigureActivity + lifecycleScope.launch { + viewModel.requestWidgetCreation(context, config) + finish() + } + } + + private suspend fun onUpdateWidget(config: GridConfiguration) { + val success = viewModel.updateWidgetConfiguration(config) + if (success) { + setResult(RESULT_OK) + viewModel.updateWidget(this@GridWidgetConfigureActivity) + finish() + } else { + showUpdateWidgetError() + } + } + + private fun showAddWidgetError() { + Toast.makeText(applicationContext, R.string.widget_creation_error, Toast.LENGTH_LONG).show() + } + + private fun showUpdateWidgetError() { + Toast.makeText(applicationContext, R.string.widget_update_error, Toast.LENGTH_LONG).show() + } +} diff --git a/app/src/main/res/drawable-nodpi/widget_example_grid.png b/app/src/main/res/drawable-nodpi/widget_example_grid.png new file mode 100644 index 00000000000..156bcff79a9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_example_grid.png differ diff --git a/app/src/main/res/xml/grid_widget_info.xml b/app/src/main/res/xml/grid_widget_info.xml new file mode 100644 index 00000000000..fb0bfbb51e0 --- /dev/null +++ b/app/src/main/res/xml/grid_widget_info.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidgetTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidgetTest.kt new file mode 100644 index 00000000000..586a5ba27e2 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidgetTest.kt @@ -0,0 +1,120 @@ +package io.homeassistant.companion.android.widgets.grid + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.testing.unit.isIndeterminateCircularProgressIndicator +import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest +import androidx.glance.testing.unit.hasContentDescriptionEqualTo +import androidx.glance.testing.unit.hasTestTag +import androidx.glance.testing.unit.hasTextEqualTo +import io.homeassistant.companion.android.common.R as commonR +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class GridGlanceAppWidgetTest { + private val context = RuntimeEnvironment.getApplication() + + @Test + fun `Given LoadingState when GridWidgetContent then it displays CircularProgressIndicator`() = runGlanceAppWidgetUnitTest { + setContext(context) + + provideComposable { + GridWidgetContent(LoadingGridState) + } + + onNode(hasTestTag("LoadingScreen")) + .assertExists() + + onNode(isIndeterminateCircularProgressIndicator()) + .assertExists() + } + + @Test + fun `Given GridStateWithData when GridWidgetContent then it displays items`() = runGlanceAppWidgetUnitTest { + setContext(context) + + val items = listOf( + GridButtonData("1", "Light", "mdi:lightbulb", "On", true), + GridButtonData("2", "Switch", "mdi:toggle-switch", "Off", false), + ) + val state = GridStateWithData(label = "My Grid", items = items) + + provideComposable { + GridWidgetContent(state) + } + + onNode(hasTextEqualTo("Light")).assertExists() + onNode(hasTextEqualTo("On")).assertExists() + + onNode(hasTextEqualTo("Switch")).assertExists() + onNode(hasTextEqualTo("Off")).assertExists() + } + + @Test + fun `Given GridStateWithData with label when GridWidgetContent then it displays title bar if size permits`() = runGlanceAppWidgetUnitTest { + setContext(context) + setAppWidgetSize(DpSize(400.dp, 400.dp)) + + val state = GridStateWithData(label = "My Grid", items = emptyList()) + + provideComposable { + GridWidgetContent(state) + } + + onNode(hasContentDescriptionEqualTo(context.getString(commonR.string.refresh))) + .assertExists() + } + + @Test + fun `Given GridStateWithData in small width when GridWidgetContent then it does not display title bar`() = runGlanceAppWidgetUnitTest { + setContext(context) + setAppWidgetSize(DpSize(100.dp, 400.dp)) // Small width + + val state = GridStateWithData(label = "My Grid", items = emptyList()) + + provideComposable { + GridWidgetContent(state) + } + + onNode(hasContentDescriptionEqualTo(context.getString(commonR.string.refresh))) + .assertDoesNotExist() + } + + @Test + fun `Given GridStateWithData in normal width but small height when GridWidgetContent then it does not display title bar`() = runGlanceAppWidgetUnitTest { + setContext(context) + // Normal width (>180dp), Small height (<180dp) + setAppWidgetSize(DpSize(200.dp, 100.dp)) + + val state = GridStateWithData(label = "My Grid", items = emptyList()) + + provideComposable { + GridWidgetContent(state) + } + + onNode(hasContentDescriptionEqualTo(context.getString(commonR.string.refresh))) + .assertDoesNotExist() + } + + @Test + fun `Given GridStateWithData in compact mode then items do not show labels`() = runGlanceAppWidgetUnitTest { + setContext(context) + setAppWidgetSize(DpSize(100.dp, 400.dp)) // Small width -> Compact mode + + val items = listOf( + GridButtonData("1", "Light", "mdi:lightbulb", "On", true), + ) + val state = GridStateWithData(label = "My Grid", items = items) + + provideComposable { + GridWidgetContent(state) + } + + // In compact mode, text is not shown + onNode(hasTextEqualTo("Light")).assertDoesNotExist() + onNode(hasTextEqualTo("On")).assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetPressActionTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetPressActionTest.kt new file mode 100644 index 00000000000..4ad36a7d5c8 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetPressActionTest.kt @@ -0,0 +1,78 @@ +package io.homeassistant.companion.android.widgets.grid + +import androidx.glance.GlanceId +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.GlanceAppWidgetManager +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class GridWidgetPressActionTest { + + private val entryPoints = object : PressEntityAction.PressEntityActionEntryPoint { + val dao = mockk() + val serverManager = mockk() + + override fun serverManager(): ServerManager = serverManager + override fun gridWidgetDao(): GridWidgetDao = dao + } + + private data class FakeGlanceId(val id: Int) : GlanceId + + @Test + fun `Given a widgetID when not present in DAO and invoking onAction then do nothing`() = runTest { + val action = spyk() + val glanceManager = mockk() + val parameters = actionParametersOf(ENTITY_ID_KEY to "switch.test") + val widgetId = 1 + + every { action.getEntryPoints(any()) } returns entryPoints + every { action.getGlanceManager(any()) } returns glanceManager + every { glanceManager.getAppWidgetId(any()) } returns widgetId + + coEvery { entryPoints.gridWidgetDao().get(widgetId) } returns null + + action.onAction(mockk(), FakeGlanceId(widgetId), parameters) + + coVerify(exactly = 0) { entryPoints.serverManager().integrationRepository(any()) } + } + + @Test + fun `Given a widgetID when present in DAO and invoking onAction then call integration repository`() { + val action = spyk() + val glanceManager = mockk() + val entityId = "switch.test" + val parameters = actionParametersOf(ENTITY_ID_KEY to entityId) + val widgetId = 1 + val serverId = 123 + val entity = GridWidgetEntity(widgetId, serverId, "Label", emptyList()) + val integrationRepository = mockk(relaxed = true) + + every { action.getEntryPoints(any()) } returns entryPoints + every { action.getGlanceManager(any()) } returns glanceManager + every { glanceManager.getAppWidgetId(any()) } returns widgetId + + coEvery { entryPoints.gridWidgetDao().get(widgetId) } returns entity + coEvery { entryPoints.serverManager().integrationRepository(serverId) } returns integrationRepository + + // GridGlanceAppWidget.update() will throw IllegalArgumentException when called, so use it to verify onAction + val exception = assertThrows(IllegalArgumentException::class.java) { + runTest { + action.onAction(mockk(), FakeGlanceId(widgetId), parameters) + } + } + assertEquals("Invalid Glance ID", exception.message) + + coVerify { integrationRepository.callAction("switch", "toggle", mapOf("entity_id" to entityId)) } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdaterTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdaterTest.kt new file mode 100644 index 00000000000..0ac0e7ee0ff --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdaterTest.kt @@ -0,0 +1,81 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.content.Context +import app.cash.turbine.test +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.mockk.coEvery +import io.mockk.mockk +import java.time.LocalDateTime +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GridWidgetStateUpdaterTest { + + private val dao = mockk() + private val serverManager = mockk() + private val integrationRepository = mockk() + private val context = mockk(relaxed = true) + private val updater = GridWidgetStateUpdater(dao, serverManager, context) + + @Test + fun `Given widget in DAO when subscribing to stateFlow then emits initial state`() = runTest { + val widgetId = 42 + val serverId = 1 + val item1 = GridWidgetEntity.Item(1, "switch.test", "Switch", "mdi:switch") + val entity = GridWidgetEntity(widgetId, serverId, "Label", listOf(item1)) + + coEvery { dao.getFlow(widgetId) } returns flowOf(entity) + coEvery { serverManager.integrationRepository(serverId) } returns integrationRepository + coEvery { integrationRepository.getEntity("switch.test") } returns null + coEvery { integrationRepository.getEntityUpdates(listOf("switch.test")) } returns emptyFlow() + + updater.stateFlow(widgetId).test { + val state = awaitItem() as GridStateWithData + assertEquals("Label", state.label) + assertEquals(1, state.items.size) + assertEquals("switch.test", state.items[0].id) + assertEquals("Switch", state.items[0].label) + awaitComplete() + } + } + + @Test + fun `Given widget in DAO with entity updates when subscribing to stateFlow then emits updated state`() = runTest { + val widgetId = 42 + val serverId = 1 + val item1 = GridWidgetEntity.Item(1, "switch.test", "Switch", "mdi:switch") + val entity = GridWidgetEntity(widgetId, serverId, "Label", listOf(item1)) + + coEvery { dao.getFlow(widgetId) } returns flowOf(entity) + coEvery { serverManager.integrationRepository(serverId) } returns integrationRepository + coEvery { integrationRepository.getEntity("switch.test") } returns null + + val updatedEntity = Entity( + entityId = "switch.test", + state = "on", + attributes = mapOf(), + lastChanged = LocalDateTime.now(), + lastUpdated = LocalDateTime.now(), + ) + + coEvery { integrationRepository.getEntityUpdates(listOf("switch.test")) } returns flowOf(updatedEntity) + + updater.stateFlow(widgetId).test { + awaitItem() // Ignore initial + + val updatedState = awaitItem() as GridStateWithData + assertEquals("switch.test", updatedState.items[0].id) + assertEquals("Switch", updatedState.items[0].label) + assertTrue(updatedState.items[0].isActive) + awaitComplete() + } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetTest.kt new file mode 100644 index 00000000000..21037eba436 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetTest.kt @@ -0,0 +1,55 @@ +package io.homeassistant.companion.android.widgets.grid + +import android.content.Context +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.homeassistant.companion.android.widgets.EntitiesPerServer +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GridWidgetTest { + private val dao: GridWidgetDao = mockk() + + private val receiver = GridWidget().apply { + dao = this@GridWidgetTest.dao + } + + @Test + fun `Given no entry in DAO when invoking getWidgetEntitiesByServer then returns empty`() = runTest { + val context = mockk() + + coEvery { dao.getAll() } returns emptyList() + + assertEquals(emptyMap(), receiver.getWidgetEntitiesByServer(context)) + } + + @Test + fun `Given entries in DAO when invoking getWidgetEntitiesByServer then returns properly mapped items`() = runTest { + val context = mockk() + + coEvery { dao.getAll() } returns listOf( + GridWidgetEntity( + id = 1, + serverId = 42, + label = null, + items = listOf(GridWidgetEntity.Item(1, "entity1", "Label", "Icon")), + ), + GridWidgetEntity( + id = 2, + serverId = 43, + label = null, + items = listOf(GridWidgetEntity.Item(2, "entity2", "Label", "Icon")), + ), + ) + assertEquals( + mapOf( + 1 to EntitiesPerServer(serverId = 42, listOf("entity1")), + 2 to EntitiesPerServer(serverId = 43, listOf("entity2")), + ), + receiver.getWidgetEntitiesByServer(context), + ) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtilsTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtilsTest.kt new file mode 100644 index 00000000000..c96ff44122c --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtilsTest.kt @@ -0,0 +1,79 @@ +package io.homeassistant.companion.android.widgets.grid + +import io.homeassistant.companion.android.database.widget.GridWidgetEntity +import io.homeassistant.companion.android.widgets.grid.config.GridConfiguration +import io.homeassistant.companion.android.widgets.grid.config.GridItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GridWidgetUtilsTest { + + @Test + fun `Given a GridConfiguration with items when converted to DbEntity then properties match`() { + val config = GridConfiguration( + serverId = 123, + label = "My Grid", + items = listOf( + GridItem( + label = "Item 1", + icon = "mdi:lightbulb", + entityId = "light.one", + id = 0, + ), + GridItem( + label = "Item 2", + icon = "mdi:switch", + entityId = "switch.two", + ), + ), + ) + val widgetId = 42 + + val entity = config.asDbEntity(widgetId) + + assertEquals(widgetId, entity.id) + assertEquals(123, entity.serverId) + assertEquals("My Grid", entity.label) + assertEquals(2, entity.items.size) + + val item1 = entity.items[0] + assertEquals(widgetId, item1.gridId) + assertEquals("light.one", item1.entityId) + assertEquals("Item 1", item1.label) + assertEquals("mdi:lightbulb", item1.iconName) + + val item2 = entity.items[1] + assertEquals(widgetId, item2.gridId) + assertEquals("switch.two", item2.entityId) + assertEquals("Item 2", item2.label) + assertEquals("mdi:switch", item2.iconName) + } + + @Test + fun `Given a GridWidgetEntity with items when converted to GridConfiguration then properties match`() { + val entity = GridWidgetEntity( + id = 42, + serverId = 456, + label = "My Entity Grid", + items = listOf( + GridWidgetEntity.Item( + gridId = 42, + entityId = "sensor.temp", + label = "Temp", + iconName = "mdi:thermometer", + ), + ), + ) + + val config = entity.asGridConfiguration() + + assertEquals(456, config.serverId) + assertEquals("My Entity Grid", config.label) + assertEquals(1, config.items.size) + + val item = config.items[0] + assertEquals("Temp", item.label) + assertEquals("mdi:thermometer", item.icon) + assertEquals("sensor.temp", item.entityId) + } +} diff --git a/automotive/gradle.lockfile b/automotive/gradle.lockfile index 5f55c4e080e..8397205d955 100644 --- a/automotive/gradle.lockfile +++ b/automotive/gradle.lockfile @@ -632,6 +632,8 @@ org.jetbrains.kotlin:kotlin-tooling-core:2.3.0=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:swift-export-embeddable:2.2.21=swiftExportClasspathResolvable org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0=fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0=fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestRuntimeClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestRuntimeClasspath,minimalDebugCompileClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestRuntimeClasspath,minimalReleaseUnitTestCompileClasspath,minimalReleaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml index 88f13e4cd98..ad8f6cf1277 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -833,7 +833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -844,7 +844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -855,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2258,6 +2258,13 @@ file="${:automotive*fullDebug*MAIN*sourceProvider*0*resDir*1}/values-v27"/> + + + + - - - - + + + + + + + + { "implementation"(libs.kotlin.reflect) "implementation"(libs.kotlinx.coroutines.core) "implementation"(libs.kotlinx.coroutines.android) + "implementation"(libs.kotlinx.collections.immutable) "implementation"(libs.androidx.concurrent.ktx) "fullImplementation"(libs.kotlinx.coroutines.play.services) diff --git a/common/lint-baseline.xml b/common/lint-baseline.xml index e6285cfa6c8..75a43eb3868 100644 --- a/common/lint-baseline.xml +++ b/common/lint-baseline.xml @@ -129,7 +129,7 @@ errorLine2=" ~~~~~~~~~"> @@ -140,7 +140,7 @@ errorLine2=" ~~~~~~~~"> @@ -151,7 +151,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt index 2829621f4fd..a3c5569de13 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt @@ -40,6 +40,8 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.CameraWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetEntity +import io.homeassistant.companion.android.database.widget.GridWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetEntity import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity import io.homeassistant.companion.android.database.widget.StaticWidgetDao @@ -63,6 +65,7 @@ import io.homeassistant.companion.android.database.widget.WidgetTapActionConvert StaticWidgetEntity::class, TodoWidgetEntity::class, TemplateWidgetEntity::class, + GridWidgetEntity::class, NotificationItem::class, LocationHistoryItem::class, TileEntity::class, @@ -74,7 +77,7 @@ import io.homeassistant.companion.android.database.widget.WidgetTapActionConvert Server::class, Setting::class, ], - version = 51, + version = 52, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -101,6 +104,7 @@ import io.homeassistant.companion.android.database.widget.WidgetTapActionConvert AutoMigration(from = 48, to = 49), AutoMigration(from = 49, to = 50), AutoMigration(from = 50, to = 51), + AutoMigration(from = 51, to = 52), ], ) @TypeConverters( @@ -120,6 +124,7 @@ internal abstract class AppDatabase : RoomDatabase() { abstract fun staticWidgetDao(): StaticWidgetDao abstract fun todoWidgetDao(): TodoWidgetDao abstract fun templateWidgetDao(): TemplateWidgetDao + abstract fun gridWidgetDao(): GridWidgetDao abstract fun notificationDao(): NotificationDao abstract fun locationHistoryDao(): LocationHistoryDao abstract fun tileDao(): TileDao diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt index 51154b29a31..2a2d0f59a48 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -22,6 +22,7 @@ import io.homeassistant.companion.android.database.wear.FavoritesDao import io.homeassistant.companion.android.database.wear.ThermostatTileDao import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.GridWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.StaticWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetDao @@ -97,4 +98,7 @@ internal object DatabaseModule { @Provides fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao = database.entityStateComplicationsDao() + + @Provides + fun provideGridWidgetDao(database: AppDatabase): GridWidgetDao = database.gridWidgetDao() } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetDao.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetDao.kt new file mode 100644 index 00000000000..e031966194f --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetDao.kt @@ -0,0 +1,35 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.TypeConverters +import io.homeassistant.companion.android.database.widget.converters.GridWidgetItemConverter +import kotlinx.coroutines.flow.Flow + +@Dao +@TypeConverters(GridWidgetItemConverter::class) +interface GridWidgetDao : WidgetDao { + + @Query("SELECT * FROM grid_widgets WHERE id = :id") + suspend fun get(id: Int): GridWidgetEntity? + + @Query("SELECT * FROM grid_widgets WHERE id = :id") + fun getFlow(id: Int): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + override suspend fun add(entity: GridWidgetEntity) + + @Query("DELETE FROM grid_widgets WHERE id = :id") + override suspend fun delete(id: Int) + + @Query("DELETE FROM grid_widgets WHERE id IN (:ids)") + override suspend fun deleteAll(ids: IntArray) + + @Query("SELECT * FROM grid_widgets") + suspend fun getAll(): List + + @Query("SELECT COUNT(*) FROM grid_widgets") + override fun getWidgetCountFlow(): Flow +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntity.kt new file mode 100644 index 00000000000..a56b4a71a0e --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntity.kt @@ -0,0 +1,33 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import io.homeassistant.companion.android.database.widget.converters.GridWidgetItemConverter +import kotlinx.serialization.Serializable + +@TypeConverters(GridWidgetItemConverter::class) +@Entity(tableName = "grid_widgets") +data class GridWidgetEntity( + @PrimaryKey + override val id: Int, + @ColumnInfo(name = "server_id", defaultValue = "0") + override val serverId: Int, + @ColumnInfo(name = "label") + val label: String?, + @ColumnInfo(name = "items") + val items: List, + @ColumnInfo(name = "background_type", defaultValue = "DAYNIGHT") + override val backgroundType: WidgetBackgroundType = WidgetBackgroundType.DAYNIGHT, + @ColumnInfo(name = "text_color") + override val textColor: String? = null, +) : WidgetEntity, + ThemeableWidgetEntity { + + @Serializable + data class Item(val gridId: Int, val entityId: String, val label: String, val iconName: String) : + java.io.Serializable + + override fun copyWithWidgetId(appWidgetId: Int): GridWidgetEntity = copy(id = appWidgetId) +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/converters/GridWidgetItemConverter.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/converters/GridWidgetItemConverter.kt new file mode 100644 index 00000000000..5efcdd88fbe --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/converters/GridWidgetItemConverter.kt @@ -0,0 +1,17 @@ +package io.homeassistant.companion.android.database.widget.converters + +import androidx.room.TypeConverter +import io.homeassistant.companion.android.common.util.kotlinJsonMapper +import io.homeassistant.companion.android.database.widget.GridWidgetEntity + +class GridWidgetItemConverter { + @TypeConverter + fun fromJson(value: String?): List? { + return value?.let { kotlinJsonMapper.decodeFromString>(it) } + } + + @TypeConverter + fun toJson(data: List?): String? { + return data?.let { kotlinJsonMapper.encodeToString(it) } + } +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 97558fa385b..de5141e1160 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -222,6 +222,7 @@ Unnamed file Download complete. Download failed. + Drag to reorder Template value must not be blank Enable sensor Requires \'Physical activity\' permission @@ -983,6 +984,11 @@ A custom component is preventing action data from loading. Unable to create widget. Unable to fetch data for configured entity. + Add entities + A configurable grid of actions + Action grid + Name + Grid widget Toggle Living room 72.9 °F diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntityTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntityTest.kt new file mode 100644 index 00000000000..0337fa866b3 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntityTest.kt @@ -0,0 +1,25 @@ +package io.homeassistant.companion.android.database.widget + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GridWidgetEntityTest { + + private val referenceEntity = GridWidgetEntity( + id = 123, + serverId = 456, + label = "My Grid", + items = listOf(GridWidgetEntity.Item(1, "light.test", "Light", "mdi:lightbulb")), + ) + + @Test + fun `Given a grid widget entity when invoking copyWithWidgetId then return copy with new ID`() { + val newId = 999 + val copy = referenceEntity.copyWithWidgetId(newId) + + assertEquals(newId, copy.id) + assertEquals(referenceEntity.serverId, copy.serverId) + assertEquals(referenceEntity.label, copy.label) + assertEquals(referenceEntity.items, copy.items) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b638fcb660..7007a12407f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,6 +50,7 @@ junit = "6.0.2" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.9.0" +kotlinx-immutable = "0.4.0" ksp = "2.3.4" ktlint = "14.0.1" leakcanaryAndroid = "2.14" @@ -203,6 +204,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-metadata-jvm = { group = "org.jetbrains.kotlin", name = "kotlin-metadata-jvm", version.ref = "kotlin" }