From 4508257a188843e922173592ccd3ca7cb30e8f23 Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Sat, 2 Nov 2024 21:47:58 +0100 Subject: [PATCH 1/9] Initial grid widget implementation Adds a new widget that allows to configure multiple actions. Fixes #1193 #4549 --- app/src/main/AndroidManifest.xml | 20 + .../android/HomeAssistantApplication.kt | 2 + .../widgets/grid/GridGlanceAppWidget.kt | 417 ++++++++++++++++++ .../android/widgets/grid/GridWidget.kt | 20 + .../android/widgets/grid/GridWidgetActions.kt | 112 +++++ .../android/widgets/grid/GridWidgetState.kt | 94 ++++ .../widgets/grid/GridWidgetStateUpdater.kt | 79 ++++ .../android/widgets/grid/GridWidgetUtils.kt | 31 ++ .../widgets/grid/config/GridConfiguration.kt | 15 + .../grid/config/GridConfigurationScreen.kt | 256 +++++++++++ .../grid/config/GridConfigurationViewModel.kt | 164 +++++++ .../config/GridWidgetConfigureActivity.kt | 94 ++++ .../main/res/drawable/widget_example_grid.png | Bin 0 -> 51812 bytes app/src/main/res/xml/grid_widget_info.xml | 17 + .../companion/android/database/AppDatabase.kt | 7 +- .../android/database/DatabaseModule.kt | 4 + .../android/database/widget/GridWidgetDao.kt | 38 ++ .../database/widget/GridWidgetEntity.kt | 28 ++ .../converters/GridWidgetItemConverter.kt | 17 + common/src/main/res/values/strings.xml | 8 + 20 files changed, 1422 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidget.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetActions.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetState.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdater.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtils.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfiguration.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationViewModel.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridWidgetConfigureActivity.kt create mode 100644 app/src/main/res/drawable/widget_example_grid.png create mode 100644 app/src/main/res/xml/grid_widget_info.xml create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetDao.kt create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntity.kt create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/database/widget/converters/GridWidgetItemConverter.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a931a9be72..03d919ff66c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -254,6 +254,20 @@ android:resource="@xml/template_widget_info" /> + + + + + + + + + + + @@ -300,6 +314,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, + ) + }, + ) + } + } + + 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), + ) + } + + val scaffoldTopPadding = if (showTitleBar()) 0.dp else 12.dp + + Scaffold( + modifier = modifier + .gridWidgetBackground() + .padding(top = scaffoldTopPadding), + titleBar = if (showTitleBar() && state.label != null) titleBar() else null, + ) { + val gridCells = gridCells() + if (gridCells == 1) { + LazyButtonColumn( + modifier = GlanceModifier.fillMaxSize(), + items = state.items, + itemContentProvider = buttonProvider(), + ) + } else { + LazyButtonGrid( + modifier = GlanceModifier.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 >= 180.dp + } + + @Composable + fun gridCells(): Int { + val width = LocalSize.current.width + return when (width) { + in 0.dp..Small.maxWidth -> ceil(width / 96.dp).toInt() + else -> ceil(width / 260.dp).toInt() + } + } + } +} + +@Composable +private fun LazyButtonColumn( + items: List, + itemContentProvider: @Composable (item: GridButtonData) -> Unit, + modifier: GlanceModifier = GlanceModifier, +) { + LazyColumn( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(items) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .padding(bottom = 8.dp), + ) { + itemContentProvider(it) + } + } + } +} + +@Composable +private fun LazyButtonGrid( + gridCells: Int, + items: List, + 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) { 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, + onClick: Action, + modifier: GlanceModifier = GlanceModifier, + icon: String? = null, + 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(64.dp) else modifier + val innerPadding = if (isCompact) 8.dp else 12.dp + val backgroundColor = if (isActive) buttonColors.activeBackgroundColor else buttonColors.backgroundColor + val contentColor = if (isActive) buttonColors.activeContentColor else buttonColors.contentColor + + Row( + modifier = baseModifier + .clickable(onClick) + .cornerRadius(16.dp) + .padding(innerPadding) + .background(backgroundColor), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = horizontalAlignment, + ) { + icon?.let { + GridButtonIcon(icon, colorFilter = ColorFilter.tint(contentColor)) + } ?: GridButtonIconFallback(label) + + if (!isCompact) { + Spacer(GlanceModifier.width(8.dp)) + 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, modifier: GlanceModifier = GlanceModifier, colorFilter: ColorFilter? = null) { + val iconProvider = rememberMdiIconProvider(icon) + Image( + modifier = modifier.size(24.dp), + provider = iconProvider, + contentDescription = null, + colorFilter = colorFilter, + ) +} + +@Composable +private fun GridButtonIconFallback(name: String, modifier: GlanceModifier = GlanceModifier) { + Box(modifier, contentAlignment = Alignment.Center) { + Text(name.first().toString(), style = HomeAssistantGlanceTypography.titleLarge) + } +} + +@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(bitmap) } ?: 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 = listOf( + 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"), + ), + ), + ) + } +} 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..a4e921d8a49 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidget.kt @@ -0,0 +1,20 @@ +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 + +@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..26baa675111 --- /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 toggle the given Entity through a given parameters with the key [ENTITY_ID_KEY]. Use [actionPressEntity] to get the + * Action for Glance. + * + * The action call the server to toggle the item. On Success the widget is updated and the state will be updated + * through the [GridWidgetStateUpdater]. On Failure it will show a toast with the failure message. + * + * 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..b6e4beb6717 --- /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.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? = null, val items: List = emptyList()) : + 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..51db1579c10 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdater.kt @@ -0,0 +1,79 @@ +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.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +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.observe(widgetId).withEntityState() + + @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 -> + currentUiState = widgetEntity.toUiState().copy( + items = currentUiState.items.map { item -> + if (updatedEntity.entityId == item.id) { + item.copy( + state = updatedEntity.friendlyState(context, appendUnitOfMeasurement = true), + isActive = updatedEntity.isActive(), + ) + } else { + item + } + }, + ) + 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), +) + +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..0c07e342c4e --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtils.kt @@ -0,0 +1,31 @@ +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 + +fun GridConfiguration.asDbEntity(widgetId: Int) = GridWidgetEntity( + id = widgetId, + serverId = serverId, + label = label, + items = items.map { it.asDbEntity(widgetId) }, +) + +fun GridItem.asDbEntity(widgetId: Int) = GridWidgetEntity.Item( + gridId = widgetId, + entityId = entityId, + label = label, + iconName = icon, +) + +fun GridWidgetEntity.asGridConfiguration() = GridConfiguration( + serverId = serverId, + label = label, + items = items.map(GridWidgetEntity.Item::asGridItem), +) + +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..00ba80a67c5 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt @@ -0,0 +1,256 @@ +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.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Icon +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.res.colorResource +import androidx.compose.ui.res.painterResource +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.IconicsPainter +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.integration.Entity +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.icondialog.getIconByMdiName +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 + +@Composable +fun GridWidgetConfigurationScreen(viewModel: GridConfigurationViewModel, onAddWidget: (GridConfiguration) -> Unit) { + val servers by viewModel.servers.collectAsStateWithLifecycle() + val entities by viewModel.entities.collectAsStateWithLifecycle() + val config by viewModel.gridConfig.collectAsStateWithLifecycle() + + GridWidgetConfigurationContent( + servers = servers, + selectedServerId = config.serverId, + onServerSelected = viewModel::setServer, + entities = entities, + onConfigure = onAddWidget, + config = config, + onNameChange = viewModel::setLabel, + onItemAdd = viewModel::addItem, + onItemEdit = viewModel::editItem, + onItemDelete = viewModel::deleteItem, + ) +} + +@Composable +private fun GridWidgetConfigurationContent( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + entities: List, + config: GridConfiguration, + onNameChange: (String) -> Unit, + onItemAdd: (Entity) -> Unit, + onItemEdit: (Int, Entity) -> Unit, + onItemDelete: (Int) -> Unit, + onConfigure: (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 -> + Column( + modifier = Modifier + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding), + ) { + 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 = selectedServerId, + onSelected = { onServerSelected(it) }, + ) + } + } + + PreferenceSection( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(1f), + title = stringResource(commonR.string.widget_grid_add_entities), + ) { + config.items.forEachIndexed { i, item -> + val iconPainter = remember(item.icon) { + CommunityMaterial.getIconByMdiName(item.icon)?.let { + IconicsPainter(it) + } + } + EntityEditorRow( + entityId = item.entityId, + entities = entities, + icon = iconPainter?.let { { Icon(it, null) } }, + onSelect = { selected -> + val entity = entities.first { it.entityId == selected } + onItemEdit(i, entity) + true + }, + onDelete = { onItemDelete(i) }, + ) + } + + EntityEditorRow( + entities = entities, + icon = { + Icon(painterResource(R.drawable.ic_plus), null) + }, + onSelect = { selected -> + val entity = entities.first { it.entityId == selected } + onItemAdd(entity) + false + }, + onDelete = {}, + ) + } + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onClick = { onConfigure(config) }, + ) { + Text(stringResource(commonR.string.update_widget)) + } + } + } +} + +@Composable +private fun EntityEditorRow( + entities: List, + onSelect: (String) -> Boolean, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + entityId: String? = null, + icon: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.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(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + title?.let { SectionHeader(title) } + content() + } + + Divider(Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SectionHeader(title: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier.padding(start = 48.dp), + text = title, + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.primary, + ) +} + +@Preview(showBackground = true) +@Composable +private fun GridConfigurationPreview() { + HomeAssistantAppTheme { + GridWidgetConfigurationContent( + config = GridConfiguration( + label = "Home lights", + items = listOf( + GridItem("Bedroom", "mdi:lightbulb"), + GridItem("Bedroom", "mdi:lightbulb"), + GridItem("Living room", "mdi:lightbulb"), + ), + ), + servers = listOf( + previewServer1, + previewServer2, + ), + selectedServerId = 0, + onServerSelected = {}, + entities = listOf( + previewEntity1, + previewEntity2, + ), + onNameChange = {}, + onItemAdd = {}, + onItemEdit = { _, _ -> }, + onItemDelete = {}, + onConfigure = {}, + ) + } +} 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..5527e48a4e8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationViewModel.kt @@ -0,0 +1,164 @@ +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.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.defaultServersFlow + + 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() + } else { + Timber.w("No server registered") + emptyList() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), emptyList()) + + 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 -> + currentConfig.copy( + serverId = serverId, + items = if (currentConfig.serverId == serverId) currentConfig.items else emptyList(), + ) + } + + 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(i: Int, entity: Entity) = _gridConfig.update { + it.copy(items = it.items.mapIndexed { index, it -> if (index == i) entity.asGridItem() else it }) + } + + fun deleteItem(i: Int) = _gridConfig.update { + it.copy(items = it.items.filterIndexed { index, _ -> index != i }) + } + + suspend fun updateWidgetConfiguration(config: GridConfiguration) { + if (!isValidConfig(config)) { + Timber.d("Widget data is invalid") + error("Widget data is invalid") + } + + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.w("Widget ID is invalid") + error("Widget ID is invalid") + } + + Timber.d("Updating widget $widgetId with $config") + val entity = config.asDbEntity(widgetId) + gridWidgetDao.add(entity) + } + + /** + * 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..5ae9a663ec6 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridWidgetConfigureActivity.kt @@ -0,0 +1,94 @@ +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.enableEdgeToEdge +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?) { + enableEdgeToEdge() + 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, + onAddWidget = { onSetupWidget(it) }, + ) + } + } + } + + @SuppressLint("ObsoleteSdkInt") + private fun onSetupWidget(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) { + try { + viewModel.updateWidgetConfiguration(config) + setResult(RESULT_OK) + viewModel.updateWidget(this@GridWidgetConfigureActivity) + finish() + } catch (_: IllegalStateException) { + 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/widget_example_grid.png b/app/src/main/res/drawable/widget_example_grid.png new file mode 100644 index 0000000000000000000000000000000000000000..156bcff79a9f89bdba08684b5cdd28f89047bd53 GIT binary patch literal 51812 zcmeFX1zT0!_ceUz?(RmA?oMe*mF{jhba%I+Akrn>-JR0XT}sEH8=j5#@Be;^cVBQR zJ66m!*BoPxxuR5+Wzmp{kwG93n!KFU2M`Fx4g`Ywj0g|h`JJ~-4E%!xmeY0tfyg3W zf1wioo|ywTiCm?%Tt7NmxOx~nn}a+&JXozAY+X!^!RD-v&X#E>!oZi5AbBZ?PoC*V zs~*0%=3DR(OAFnaG-W~gBC4QCA5B;+Ddql?34`%zq0`IzjQLWJ-Th)qF$qQmHE~#! zw{oW}m$2BBRPFW+-+$aaRkIH#hS7dDd~l$rbP$p_Y9K9W806Z`!ie>Hg|YJmp1X*-1%uLZvlUcTS#4DdzNqcE z=TTEZ%~5{mqNMr+t!vE~8!mSl8u|>)4&9Xj@`l0;(c`9my$)K;K!vz)rP4+TlpB$> zRBWv1SKX|Hn18x7K87%;^?@T(${3m`#MiU&!XsZV#6ajjG0^zp1N7PhYewOXMzFj} z`eg7-)B{kS$jHuuEg-3X7mF(*^ctp_U0hVR1n7foW*ZGmTEjrFnkx8v_QnIKj?B&E z-vDx%Xqlk#BdK@#rHq6?nzHG2G|*`s73kY|8(4@<7=e>(Z)pF$X!9nT+NguU)Ibrn z0UA=bJG{hGH9JFzK;6NFcx&965&Ca<41}3(WYgUuj1*VB1hB zOU6{h|BVfo*e*EG&{~=~K2qFVm!fS9M%PV5CJNaAp1nc|L-D_NN^tG4;Dm9=;#Tdw z78+3P!dnDKKfyA?8rhVFVE;3{8#X$kr!X%v1q`^MA8MKQbvR_y{=YhG{vLwJM;cvutEEZ2P{KU)TP_6!YP+(I{ogB6`oV!pI#3C+GF!KJWIGDby7nya@A{}g zFqQe3|9fN7NupbXgJZ#?8KLQ@OG7_Fjk*dO`2*X{pZ~p(8i+03MdtKn!CSl4P}!)o z+9;iJ8wEV%|DSz#X5RIcEy&q99LJm3dK8uS8%cD_8fnq|XVfT$AuSEfh@A4(YW1M< zrC_^AtsaZd|FvA6hzj-IrfkT1Oqm7Uj)zB%z6Sqr6Ah1L4w42 z>~y62ddXB7{&h$V4LjsX7+5h3Loio0tHo~cxtb_&QX=d>>$WU05{nGThMD?oCjrl` zVTo>n{%;O6NQo#nP?NXic6zV<8Ak`|6FB_Woz$`NZ(EMvW0~1<5A~q49BzK{5LhIKvZEk%7e!m;)Ob$SXg#;!m$4aNDb;TD#d#@ z!N$!Uh0DRn&`{UloBt-E#Uu6fcAY7lWq^#8gB!sHE9BxoFtw;bOO>JrJ~$N!e= zGu~yRm+ze-EJ#|)9USB8rN>HOV_Uj;JagL1PyE z-wi9e-?Kg8=r-7e_8McjSO3@EQfU4yJUQ0ex8D%U$seZVQ>DDy4boXydrH(ts=coi zzI{uVo*-{$-@R^SciFL{rK5X?8EQoE_21WmDYWmaw9Bh>Sit=0s<=cT%4q7Io*pAk zX-aBpXyUjA>A})UK41;7a~XI$ok(y3A5*MQaSjf?iE@HTGmLudD(7QFeB8HsF?LLs z8(6BA?NxE%f+;EAz9pWn$O>vbc@{IXL__=)qwvoxWD^dJc~uZvWEABs&o#C5p^10$ z@*KkC7$EV&X&O2@ydO_BpMU%a>+pNXv79hqCsra5vC!9N5xw2;6TaQpX8*z3p)Zu! zgEoX)a+q`$9Lv(pAn`uK5&G`9@vsWqEleunjhILax8i;a`Qmvwb!vRngv3tRBiNMx zum2e=z?OsC15r3$S`U8-7TW&mk_vg}FFv^nirin1^@T=#by{_s{_R-7;#QaDheqU@;UUxzf4G*lBC9E@{& z9>a6%-YbZkJ{mFd*JhTZ2i17*>mtU-0>$3TeXglISvD(f;*>ErtrjyZd^Ur~^K(wS zl{%r6Ap3X?tS``X47-lW^N0PTgm_JZMPWKl|K^T2Hu zaC7=Dv;owb=QbXxh&wEX=q*D{4Lp0@KlgXT^0_%4XgzHUYWMNzwy3=mp^v7{&CSKa z!;6DJz~Z2EcZAU^3t$|oxQ4e*E$RHux0K`QtbLpBnmr*ogP*@l;f#ps1k=4I$So;( zOQ{zKD)$k2)Pid+-~kPvB_*P*ZQGy6YPF@`bUeM4eYZjAOX?Dt=|FsM zPuS^Z%!FoX0BlTu@WfnM&*GMe?@g5YErzdu{5)lg+i5 z@yiTfcuGo3Uk9DW>xWX?%$lO|kq*j9QYqtHIM5Il7}#)4?A8{SR`WScnRYEQon_3d zSg3Mg?`NuBr)7H&lXISVD!|u+DaF-rKdGym94}2&IrqyBjR+N7JK;!U5fO7G;*V`v0;W03=1UXlfaPjbJ9zFLl7{ErbD7~5z94D8kdh74cXJAqN$(hXXY4B8M;g*7`x$sP3P=#v-xzh)?cB& zY+SE1BPQkP$zPz<4&|}#BUV%E0zH@9tj9_^x|-nSvObVpPBM#B4<{l1$(5U4B{Nu- z+za~04{fshw$xgae$KYL0j2{6Ua_ZrmnNsVBa0g$??0ISSEX6m9c!qdro)p5$nCa@ zQ>fx=ggbBstPW{ie}>j=bT}t@Iva02>DTvIi>bRLpFF%8Bj0^AqAOI!+j#F*txu7YaX+BpSY z-$xd6pM(wA0N+*9)I$h?j@*XYb!M61b&-2HedC%him48qs9ADebF5is%a zX17d{fyLwHQ0J(ji_Idzw1 z_a^&S-l(MfupaAv6!ePev3q+vt;;kxj7~W|->NIhNz?X;Mp*s8!I*{J zdIy}erE-Ls77R#2JQp82m=lF`qf}eSm4V>JXV=Ycxq(Qz@S{DnHRlg-FIp`m;6^0U zp#5IF(s4DAii4v%h38OcZ?zQT`KFVv%KvE|>^7&IM7DVz2$bR^MOzydXmG7o9D0JEu&Sj|#13`1%aur@bqskd}?mFaUr*hu1l-9-o_cZ9Pvp9vNAP_56BZNCbLO(6Pb#o1I#SwL!JG!sCA^f3^efJK?))&M6cA`0e)hRJ8CZY0MgWQ1*^xfq&j%Q9 z7dTcs_jAt7r_E~rbrU25AjRo{>?WP0{6omRfe%(z*(2fAm4%xsUWRhS-k#Np@5r&| z{eG5sDcU11GYd<1Y2_mc5G;cGKU8MQ=FL*~;0ERYF?-x1=QfA>%6_N|0ivFdY3yv(f}}-n8o5kUOoWJ-?aR*`hI~clvU0yb`#W`1or(#>c|t z8VIIM)%x*dC1v<1Yrcn_o{nqF{)hECEN0r_Bges|CD^56`cZy99@~a&ZP7t62oI~( z5N{CxVJDtW`eWEc6W&EQZ=QKRHe7fL7gbdB1mB%fGc%*UQT}5*cAx?R5y#17a7}EV zWtWs7pFW=|(bCaxXZlcBeaO()(~C8H-jZGD45(~5dE{s_U;{`80I;4X^J3knFAzS( zR9Ac5PS;lt#x+Z%-5&ihZQ~*bPzxA%R2w$~aO>*A?J?i&PFg)Xaj!AAyW?3$_eng? zhoHEnwKt`r12sQmX8wx$alS5WettoZMg1mZQ_pUtiQJ))#Bu_E+wkR)>G}Dw^Gk9v z7Q_=hg6!e?xVtMTgu{E((>3N0zH}`3>EYUFcl`J>Es}dHS!Kt$Bfwhv`e*?^5#;3b z?%D)zOy=m>Ej1Zr(45xKzl0P|TTn7G%8ST)c@g~X%K$i~hHiwkv@{K8%~icEaU8tqK%DG& zSGTH;K%sV3>jryOST=#7FL^V<4PUFixpbJIwL2w#4BTnklb{{%x>O(lPPZe5&rxMZ zP?I+GK&;Rx#k<&Pk83{dfUBJ6bGjqQsvIy)5ObT_jwcg>>U?=J{F1n&UtY|_#MFQ4 zfAijMy@5!)7ew{qy@`iP%#G{aP7VNwN%aQb#1rn}oF5#BeSUUxGUrK7p+#cV@$UR5 zf66lCvGXOw@~jIcp~s3_5)-y*l{J=>2Vd-Ae;O3{_02`K*)a9v2EqjIL+Y!$s#)fC z9lMu~5yZ*R0DDiz4>wLGq_5A6mbR{g$V?aYw+9{Jf~2awJz6r$(?)c{#Z(Q3n)+Xo zf=`&Rj{wk6S#B6zIZ0q*5)nlKp@?9)!J(%+1c6c>JK3*}@MiU8Ncx2Xm6V%spGaiJ zeErW3BcXr^uKV!{ynG@E)Hv3RLn9M=EoAuV6YfdVg#k|$RM-B;u!B=PyuS-IUU~Nk z&I^-Ro?B5Ax9ZY|SMV#~-nd)W*};XTGhI+uVn533MB$&%5ntz0PY0!wp>eE3!^HWA zY0n4$2;wFnC~frBoXEmumCp7nD=tOWq(z>hyNp340YyNYmOd{bYLok~y1-?6Iy%#y zK3gzk0}9l&xJ6AW;EUNS8*5WjZcr)v!DKo}UP?{M z_kIvc+7t05O11W`Pf!DL``(Z*M;F-1lcs-FK`ZVCqtp4zW-HFp;)zhVRIfMU8M6=5} z@~xJV8&7ofc+m*JuB02Ax^PgCg9D9wWp2xbe5blB#BFSJy0-UR1ra6RHAP1vlBU$_ zWUSW=ZmR{bv9ik8E5)_wX=?{fbZX&1_@(1y9=@4tmn*$WGPxBJm{P8X_!w%q6Y5(j z#`Rjk`eo>|jL>Ph4B&<1BI`@(4uKPycLDCaTn&;%uwab5ZP6p5az6V&!@*j@0 zu>-zO)(Pb8tlDs-9ug^~B^{o())OUWrTtr)C(2A6O6NLKp<}qu!>oudISRGk+6l0SZ*I0p|8CR8&+9pCVjQ+YypB%A32$ zv`zNR)F^BnN){!Kd(rJazSc7vx&G^JXfUaPTh!bBN?g_Ev!|>kD-C%sy+!m`-++uR zcFNlBZtn$K}S)&uOy&CSi*)zg<~Amyvy_tJ?8Dv#kL?tMzOEnyS+ z+Pdm+p&K6;XMe$2FQwHK(46J9vr8!E|DpB)AWGk6X0G4jzq%e=5GCT?JR1q%<={UM z2~gJ;nh40%yy4bPnxpNsz;9m1XM7-U7Pl|2yjq$x?!}rK$bmSsSGlQGSFajz{xA}l zAMjZ3SOsD!z(1iH#k@Gz9h)sD){YGZ#J5bgvuE#1+IWc3wt##^e7r0BTi9#*9Ly%t zju4JY9Ne}o3#9Kq}k%%`nT|eJN z8?F38om0DERs%}&s)HN0!@o83j+LRQ$nvMb#z~w3Yv}2P0#qHHKlz<4Lv--(#M%_T zQ+rY|f2oZ3O|l7@O*^TFM(l(YO^=$3VQn`;l`hF#1MHx2;FLl6+>i$?fX zz&KmmID+}H1Pbp*#4S3R-Mm&?>ThrJ=%5;t%!w%cn?NsK`&ne_!moos4*rriU^J(+ zuC|MmOpBD|-vZ5)%oZ|cVQX<3Q(98e6%2P#Wij@aKchp`8DOWi7JdlY+KW^ijwESP z?#6x(7AE8ALTAHoDBdN~+AUO(vppVGHXUEL0!b%Y<$MTD1jg1F-%Cs?u^~kx06Htq zhsP2=E~$$2D=rf-uQ?r%R|44}#WREn7cXmIrg$0)2M0m^Jh!e6*3kcsNTWgv2zQi- z(3xJ>yOUPj5{!JWPE${zhU>Kclt9V$!r$Pq2%9hn^L-=?NH?&O)bX00u3G^-oa!Ow zfMfkm(r+14<$6Y4rcc1ZgALa#XRji(Fz)*XO7IN|g1aot`gVk?~?|8K!pwzgU}a}X$G?Um5b$$o_~ zk3c+Ev>rF1|Ll_W#uD)vU#2)ko+bx^^Qz_HLV@_ff5gDX)_bxlboTeV?PnjOPQlwu znY{ju?q0}Vo(eSwhXA1p2PJ$1YlX+DsOK{U+a_Ph`Mg zC6b8C5!st1NhYrw;N{`Bx3G`<7!x`9m&~}*66WS`Z<&}L8IxUo;MO)>GJP3FJ8eP%QmZx3)Dwwjs;)8BoJL&HRoMMYM7Xz=1{ zMzzbTy1F4id{}6+rUAeix^Q~aqc;GOO$9(vAqRM48Zv;p3xn8J=!;o?%PZzq&DRRkzi-Zv-9 zW+cFroVIwhZo3C>?+@9k+(s;B=H|K%uf+4Fy3C@x_Bgg*)i&4DOnmI)m{%fR-^@JXc`>F5xwmD5fm|83{7qiw@VPGc zUtT10XMXWS(x`mS?;m`AzRBv(m!H2acWKtsYB_pWrrlmM-0zhyKgPdOw3)vE0R-sG*`j6E*7B9% zHjoZ_3TJAN(EzNs-RGtsjmyIL{*nYxbwvL35C=X{srT-H`60mTCL_sl{)gt;GWz=Z zrSbJ<`;vgN^4l?kdE{UPS(B+;BPJzfLowQq4i)~L9BS!5;jkMhS#WaILN2F&G&%gb z(rWjHYp`DibhEExC#Ca;Q>7jhiYZ{U0;RP0vN9;DClRiE=rraLs0U5`_>x}=pVT!P zB#K`{b|eFCRBoh1NJO?rmS~FCw%w})DX}S!i<>UxrHw=@A1+fGG;AjM=@T~)!>NTz z#ei-n3snj#hs`2Uio5<(k7sn~Kf;EilLZCAM08oy_Sn>Cl6>l$R{@em$--$Mr30j4 zXdV0ACS%y>Lz!}9`h=;@M#!)zfk zJBf;eiqUG9{&llcw|vWQqz$l;>-Pu$14GdB89A4gV0wfonw z6jgN4f7(8S6i3zT?gRE813d4)PjfN*|2F~LT*8Ha=7^NUg(3O(mRh9A|KFIDb%=i8 z2pv-(GmHVzXESj%;<@2yM{Oi`s;I~o5L6%~C>IpD)%=1ID0BIgZYcXqs?2wAV9!ul zChMe)Hi|EFQ0_jXOIp+@Xw<;J0dC1*Xww05Qr2=AsZd7JsV|$6(TK9H`B-0o|Hbg; zSn5EV0W$D3NTqx59`P%#*M?{r)o|>i*ufq^ke~@$?fjAc81#fC2YA zmKC_6K;pm2g3kq6@pNny%tH&7vu)^?wqrha7R>l`j@;MVb;_~Ed=p-ZhNVGUICMt097fO=mc@L8p(DfJY?;rnY?BfP0 zse#f^z}}#$o!CeQvmxXSQ4cg1Z{HV;c^DZH9Y2YqQC#WnWDzf|=R4(@nZ=DWh0o>! z3;lr8mMoI7lCm*K8%Qwn+6h5tXoNsL{dRgukCHpW$2;1p*OEivm4_F`HuAXhe?y%+ z>DFPMwF=iH95_6w`Y@-iZLbV`-Ct69Ls^;4y#SP1azVc3m$aU3*zUWTeVc96dScd? z^s%FoH~Cstl8=&ji9t$G8(=8b%F0C=EG#$HMAR7}-yjyn-G_Yz9tZNu&~^lrYU4pQ zQV}ow@r&6QAdfx=7EbiZ8^fK9KME_vd%NXKKu$RpX_5YkieSMEuBf(JCN8pw38E0F z4t?Z6IRI-Vd1bSJ>>hz~k!;_0-F|nJCL=W?&xow5qZYw)cx`-mvh8_Yt-ZI*#kkOq z%#eTZB5V-O15kUpWT@J)`O$VRrb^E3fS7sHk(2G$TA6+zkmPbKLw3J^H+^cL0k=q2 z_Dc=TR0#-oSbYBX!dF@t{0K+YM6h4g`)R@G+>OaI6W=Bq6y%t{is zj*{|TEGH>E@ZS1Eefb;nRg9oA<*tL*!sNjL?c&W3M;G@BtN;vBk&yYFO7qv-J;3be z%9bMn+UtqYEjPU8gQ);{$5+cGbo@|4N{X!O>K7wRYE=UL?y|2xj#CSJDnmV`i zSJbq*R*Scg-bojZg-3oi&$w&Ty9Y@nptXsU>tLyr|2qqC9!E-qAMCdjJyKF_H-^ry z|7r(K5^aqt(-)-Bh{G>Q#49zhKf}k4uXrMW7+cf&&hb*Nwm%4NgfFL#nme@@6H5*3 z+j@+6O(B3LkG^I^LPow|)<5q^6*&Ra-z10bg5>^S?oV2HG(pA@);Bh)bBzc9E-%^j z+*|0>P)BdIdkX2&IE5(X1M_js)Pz2;?`92~SW-f$K7|w;5rhEmhW73cWQK%&4(87H zi-70-KF=q>i-k26CR}qWh^6>f-Qs8^0J{d8de#qcG;Eu(hV1&by{ayHrogWuv6xs> zbo{H`_QcIDFG%T(#mELw04SM}3H!s(*3Y4fIYz+oHu7BxCgEVISvmQH%-gM<^j${{)Nm}!2fUmOW`h0@FyG^ART&k)}Re)J$#E)(EoCSYyHKWswfaPti zr5Oi0(htjnv0XHimN=Q32DgfkxdI~i!DzHXNsj9VsrfPaf-rWPBsP}XA;Vr%haG+? zjOB=pS2>W&;yd}Y@vE;m!F;L@FxqB+2Ea}s`nS`3f=(@3zxgbg3n$PJc6=uPvhnW0 zXJP=Ea_7!}kxff(e{!NbI5;K)1K4-)_E+gB*i|*+&>hXrfM{cH2v`k)Htb0(eOPxg z2CJ8^z(Rj_X~=DOH$Ghvq?GsyXM5*zWZ>xu3y)2(f!aeRaEzfPKe0wAgI(IDs1;iQc#4_=dqo6oE0D4{NmV~Ob?c@LVq|BHG@OuLv zkDZG0+WJ!@fc}k~D{!`bc$hGr>)^3_MQc~MC6THhME?yu5w*B>{uQtFqplr_HxB}* z+JSFu6Y=1j5x5!6{y$`1G9n0kWicsI5?!C6tO+OiwDmn0Dsd6w@e!&&iQCm-@foD$ zv1)$Ot@65&=TFb1Og=c*7pkCWc_ULg@{o|(zuA_Ee=6p84x;T#1%b%r2gaWY!6i*C zVaSrAmtdb%e4PBTHB5QUoSOhkuUH5q|wD1$S_2fCYJ z(pgG64rK3)f??BW=%y8ep&DrU#7$=(zFUQ_Qt~jW17f8Bi}_5Y&ZF7z~+zxf*0*okn@_LlC;&fd=jPL$K~Ie|5zc zf33W20vzeCZ!i@SVd)@w^Cu-RfhiigQ8svXN774O1f7&+pdjOos@A93ec7}Qf9C+y zG5HWm#GyU%2nsPlJd7t-(QlakCf*tN!IM=-0FDYold7q5RO$EYIlKtDqDRy!#UHvh z&jP7>`22|S6A)EWC=uEB>VcmqE%EZjc%53Z9dCpq^hTYSJS^$_!oDrd^{k-1 zA_(R8%U@;d$q_h(Olr0bdHHSVj?4>~N7Ot_bX3Iq#nlHYp~JwRSo&95z3zDL<(0dZ z*`DEe>fIt^W5q*<7w8Ac#4PxC%q@F1U8y^CnOz0-(@4KZMhc2)Ye3Fd+=@y)K#;5U z_;}R81><&hb6+tlsrB|hp-qU2M9UaV^W=yYPL)H$(@CBuSy_OS5nIH-m?r^eu&}Y* zBvI`&IT8j@Dm5iPZQRuV*sEEjzmeo{Opp%U7~ate_j=FMGC@xcKSn3G)PBsKT6J%k0a?XyvS5izRz%a2lf z-Wl*PwI9A@5+gJ&$FxU8tE$`DyNj)N=%9`&Na!dqbLS!g0G#(`L{-NDy{Vl;4eH)qdA4xOaMt`Q+&W^e2bbN(UFxaY}> z16&RWN_+U*K5WM#uJ9Hyy{EUv@MI>ry-poWCQ2D(tc?U`PFRG3lxS05j2T$VdxP>U zH(lMvq3kMnHT_;CaEC?&_XwDNI&;83f}7W%OUK~F#mz#IrqsU3$t#H8%$S8n5M|Hr zQ$k4tx}QXm9CotR&ZZezs*NBo^%h)Vn8@ zXIuny3K&qIlASM!Iwk$eSu5~5 z8K3FzCg50&Y^TQj%b$mYtyYW`x8eDmrhHrv;q!MhKl})CBTn1V=3^=1Iv*c+(iwzd zK%O^$JB_!r45!Cab+|h8I*~32D#g3^sjv6}p@ zU^*s^?P<2ToR*4B>dI+rr`0~Erk4s`b~7X1%Qzc)$F>KKZ^@d_pbv@I95+dwrdLZ> zT2*Fn2jv|xxiT~+D?`S&f4Nk$H4|Kq*1pGRu4N{OAfT5HMzIr}ZLGRLAnlDjNZC@j zg4Bxeu@qAzXVqA$xJ8CXth+ZbvN2AVQrbf|88u6K48`mZaJ(6Fox9-@mC`RA!=8|a z>b-d(Bx0rB?L=DB!!>B;J6AsC^b$K~-*TD0jc04mqZ6dPq&2jjW`g14FOaL9Jj}2d z!+9E-9(H>ZUWaCXMC|RnRMcNzd@h0GCksJ1g>Ro6%*NYz5favTB%a1UYih+)Jo^{l zH!&c}y^rcQ^-$@81nnQ7@yRE@ej{pK;Lb6c-3v)~UGW*Qx*U(Zd2rZr^{R4`6-};K zjM@6V^qw36WZ-$iOWQBYqaE;)&e0SwPPjnkFzU6%;^yb82kVf|iCJ5KYWLf9_Bgrf zMGgy}c|9mN_pKZO(G?f#TIq~P7D>FR8}-?0M@|JbnD=7;foweIoR5Zp5CB|^^jx~qJgF*c(RP6l8+)`_cpcf3(D z9}Z)NM4U%YV}fXh7urlQ{I4GZHpbH|w~p`Lr?xc!nBmy|LX{4T^!n0N|NQCw%p#yr z*8R~Ec75RQ>({%BOR7(c-}ul-KvHBD^MZzNiDu|uvxj9Kg{n!+MmS|AejW&-OID&V36&1s= z8RhfFcxzA8#${~4RR>K5^_Mt6V@7ow&!K57g%yPcSnOz370lUk=i)a?|_{O0V+}ZLlifmVf`0zED3blv$W0>H zqS`)`8*x7tr{QU;b*IyYket5UK0nP<@-9W<86IKWO+=P1stfEz4sLhniqzkNqJ zsDgVR#?wWx@ak1Jk0ZC<+ZsG&VMM5e0pHpAPB|IRb`}J2A!uT|}O*v-lo+ zbWD6n3lvEY`8*8N-B=8KO^=+#NcEgcCF)PN1d~f=X-p8KQ)lCWOFd_$)-gE8w0dcg z-z}Tt+BComfLK#e-)-k4P803VgAu1hI7PB8d#M2vICj!gXt=u-qT)_fStYs3+GEv4 z#)mGuPL2eXfHPFihbnEM@^XAwuKa0Ei%R}n8m5&2F@?K;%|f4Vh^KMoxnumBWp5hfN!)aS*0(B&@wz7Ce2)IJxionU?5~*IvC5 z4-TrfqO)PPp2rv-<{#5>JLd;#JAixnUml8?J~$ZSe6ERBNEcQQ?G{)BD$ZH{&uAsK zB&nG6Z|FDr#a@tm;djL`17RZKH!TBh5yo*FeX+-58C&v9}$an>}YY=tE&IyZPg- zTb2M<1l8|9wp%+%W@YQ%q?Kdqpu&-m*ctxE%c#TI)RJ2uy&_JEN)TO!0Syf+N-N}n zEUN|2mV(h1szfNFbB(TNBs_1&&y*z7IP?;_pIYB}u;O~PVL#umvsK-EKDF3-OlaKx zxxO0oyi4z*92p~?aN@*r5MaSc%x-L!6<+Yeflc)3o^nmU1+RwL6zuNtcwS5t8DlL- zsTDdLNK#2w1P_;>*>m8CzS(ZB3n&Fsg|g88_kCA;Njrxy3p6a~?-260(^v*sbu!mG zs*pjD@rxTOk@_5pyA?X!>ie{i1818L+%o6~NZLV_89BKdf13}hy}q9goNPR=o{hY5 z6xWNbOwa)6-CibXNUgcjdVqTb$&3F3lij)paxiGLi5CH5okO|Y`XGrs!FKSvx21eA z?Z_ubK96gvQ>7P8v9&i&bNvCI#AD}OqS3L zMhS~bsmHU$#e;PNOxT5EhqhWzoH++!WNi)bYACbt#n307syFU2QhKpScyTsANqi1N zl(a6!Bmi`QxAYiHAa7RAAgR_ZLz<>H0|e+sy*XQn)zUQ^owA^I~elZaF%~2H~=60v(gml2tAtd-WN!@0h6*P1* zkMQ(xWq&0=A4e8tJCG3Wd7`YP=!$Eh+^*rLmyGvm{&s0q*WTT3xqZU%W&QCFV=SQ{ zW-fy`CM+sZO58Wv7Yn-Yhiskx_yJF22~O)7eA_{-4ib{Se0D1>C>1g;qfhsXZ#b;P z#jZ?*Y!)a-fB1#m`vN&MKHCc}NYy|s?62)y$%73yvGCo{3)S<=#@hi1fUFbtgj^<( zElTEYEZ3y`Gx>JL#_)Q4)}$t;zJ{WT1K<_6L*`EVL~I;(%WG7^%MLaQMtP*BzXOL) z4UDY&D;FeKZqm7wlsp3PBCMPX1Ap1W4ml{feok`-xi_~jG|^BT5{Jcz61|pC@>vPK z&zbC}ir8djaMAI2?gto_Dz!+dbp>_|vxgt%Oxv)#ihFcI{m}4Big9fGTg0mCTC1)|kdFmx>)AM|cN#G*p^$ zLoz_8mnj^2(op#B{qgO4GDFZq)YMiniKW!UEhf6=cY?W+7HSGXiOjuB!?!lm zYNW$A7_E8Xmv#+`0OtV^0^UAv_hwiVp~`>RiRW|qdTJrOJxER^PP%jL`%8v|TA&Dm z3SS#K3g1@V1aU{4-aK@TcCDn)u8>u!c{SgPRq zYIV=Fbd?rK$#y4~>08&9In6nX{GZ&`59EFl_K73MD021n7N6$1S27MW6GIQ_7M8 zW2e9Sq%fl%NsP7Vm$>A5!UUWyFj18$Bw`-Po(J+EXUJ&Ye6e{ZdrgAj##{c6v-txB zO?Bw?aWc}1u?bS2r1$|1t5_Z_>kAOMfZEIX^88^EOnlLv>s_i|<_qE6Vf}6YO~H0` z>ziyxmGz?V2XunX?TMRWF)z5kR>OR;656Xz$rmc>+4sSs>e2*6%w|WE1_xIk)o@8g z4N8>1f(@LkSq{q4U~Kb$_VrAT77eqg>D%@K#OMBL?@H&-<@^4H&35+ufE)VmZX?7; zvds;r7kmyt7I{dxz3~{4i$hybsi-cB1e@g#lt?Tdf2les)$6oRXT=y#o-LmL1WedQ zb;2U^xL-aoRs><c=D6n$;yA=H>F2mzLw3XjW+n!HeWcRz95H=wg);BnACeR5Q?$PilOT)pBvt3N zm5<@Rj!Xask7)P>WfEC8xy@Z=?6B_qw5lhLCFR8gZlk-jzbcdbuG|!U-WD4hUxa7o zA5Jo*rDcpB_cp(KB^OkF{K~T{ng#@;^onqdeC# zE*0RzQ|Ps9S=|q>4GUoc>Z`oR!a-^wKGauw6j>=&G!C6k{0?aXr0VtffUw-#xO@(7 z%WhK-(dz5x<#zjo3%BDf8{Tz4oL`vb! z5gUlx3%gy2d>>vClfg47&K7683o<;;X4E?+5M|4sw|#^^nmpvzg8c4P3ck3*7ufBG zw{lq0ahj5eC0|0BSNSEAMUF&J{`VXUx&t35g!YK}eZ7$kY#azHg{C0;45N2i?oO~0 zR{%a&;xUpkcN-~a%8sA?3j>^=twj)udV;(y^6Qtq z1V6a1ivqeM!bLNH@xYQL)*9?AXdq4vgg%JKmDS$!E!FfqnfSLypCl^G!Gk7t@MC0t{_q{&(yo=`T*^G`#0iU>-2FW zb9C@h1jGaob64S9KHJof1yD4(HrW{5Vi2{g@o<27NH5~JG61!^rG)qy0kRGzW8}iWxlLVJhGx^s5^fUmO&lv zj*>K7zI5bu=*FPvq!>K+&2xV}kMs0El?$_T@!O`(ztXy$4njuRERCkdy8B4nsl%5h z0?ANqou2xWteGN#VcPVP%}vniWz@XkwO=P_)i?fP0F*zixuLy(4$=tZ?CWXS8$S-M zMQW3-jA^&p_T0kDYwbNl{*ICHr<@`WQh z^9b_F$@}lR$#|Y@h!jBej!!6isMuod-S^zt=oBY=bQz5O;tmWT-SWH$iQ`YyM3#mM z$50$(%WT`MnV8B%g}48zYFl0B$HZ+zqAys*7#++abZzu-;-^%Sx_szM(s5WP} z7i_DyqFK8W7x%v5*W=>F;i?>#u&b-I*1!2f2-E}Y)Qp%a>nPvU=8W#9;We1e^!!HV z0{1^@@SBAGbVWWn~RpNnVFD^c4ofozkN}VN(@0dzx>V zc?s;|3tYbQ>|A>q+jDpRm}lC&f+KOdrw)i{@KowS6$C1IAA3`K_qzjjDlSMC`gXvE zb~osFMNw{_q?!z~WJ*`Vmb|a+G1T>2JaT&Xd-zBiyO)AOn6&IPeA|tgNXOp?G`ZsU z!lQ}H=tQ>&2kawX-Q3=4U7pUj4A-z}>e-z5?=G+k!So=^iK zYn$l=fcGra*?(W>C9fnnp>OFL~?9KwcIcCVPjK4mVD9?Xy9LiO9rUhRcKB>G(=-*;FD? z8#zxBPt+Y!^)f!f{P_5tSV~%}z2JykAXC#=ggR7fOv_ea7Bb^E{3|3o8l7~wqK<8` z_#9b50L;HUE#Sz$_rvu#P$4ZpM5uXS&zP)AXWU*e-=L6&^TC(Gwe&1^TZ4?AKFaNKuRpiE}N|=VDbUj|jWhVolWSiU)V%^45>NkxCqskr0 zhN^u_XlW`B^)5HhI$0Ao_29pIF~SMQcm-fRVLR2IilH6YCiU#Ts$!M92A)rT$;>VY zawR1-;CAB3VsS(Z{tv)trhIs=o zMi2*w+KTUSQs%|tkvq_m2s(vR+!KivLJ7skZ4a5=u z4l0##>$jRQK|aLz1P@yuQ~<&TLStWu2w!gW-$@6=T>RF*Kdu#Kj`_o$fg+2idRleRy(AEiB8bh=l&yXgaJeIcUA@~+hlAz9C#SdO^xdu-}v zO&Jq2OzzNPy8(ZFq~*hME%Pj*5j}7Y+H|MudX1eJ`(6% z4${hURg=ielEOiBTu=0!*n3A?LYJIw&>6w$U2kNff#yYe!ut1a#dv+Lc^yfdnu~~5 zGJa-lV_V!y9p2lzfq8IKS*zZLSR>{zx?@%TyHiI%E!u8;2y~~@h)X@)r=WgnlpB@n zd&cWc!V@s?)o^bK6-_L(3Z`$_ z$Hcu%eLZGs+er~>g7-UT({i|q(5aXw-x*(oa7!^^EPgmFWxAgCnjusY;?1NUFg=yA4u6w z3_~u$kTBQtY01xvB8T#wHZ=KC=Xry_hD;8Iy0WEyTYS9INp+Yu&AM=rPmV+gjq!S6 z?Ufl#+^08M&q~=+&=VXI@|1gp=2_E@IRnwOS*huJr)fP?|0?Ew@5p6uYT5iq9O;z+ zuJ{j2r|Q{HQ4mcg3BFq1t9u6Df&8K@PAeVv%;s9VCD(75g^)LbL;MHczotKW%ncZh zfMu*O;}+JKdwuI+2A@5uEwx3TO3!3QDG@{#3hntq$(tDGG)F4G#i%EPL z##i=wm%GwgA$pQNLR2#>^>{qKBMq8gxiL#xGVhV2zIP2{TvW95eikGGlTxg3wnqe` z*_7IThJ|lcU<&mx$`z$&a`u=;K3>>p$`$51l!S7W;OA+SBObOp4r;Vb>;A>r$DoZb zgse98q&M%HacqZ2qn)74uvJB-oE)T8VayB;-9ErZNO*iIt27H$ERwLr)=jRK9e1?8 zW{oU$2(szw!2(z|@e+V>p~-7iE?3u7?JXgnQM;+52$X5T0GLe%a{Y!}Q7!TOe8HeR zNH1(!6n43+@{+m1Va`+Phkt=`I``%Xj2xDPtl=e?jYTjNkGV8LO@MLXrC*q_!Y3gf za<;k@?{RkBxBVxtcy{cY1cKPjl~Hdf#Q7+b8;4ZwdaGFFmaF-3P({G&F_Xi7P4Iy7 zEn`C4x3<8T6`wDWEXPON`B-lqa9>Pn(|DDJo`AyNAMVLpLM$-Z#3WvxEUk8kwcA-I zKdQ(1rf12@ZVS!)UzZA_5i&b(4QFRAZre&dj}XEoN!)qC1&U zyY=838>^Wa!Ra0US?U#3LRv;)dF~G>v!8ZF=3_HYXi0OC{ZCsr+Q=mPb@G@ou;p#_ zMNsMs%@2jX53t+cwf|8`R!7M#az6fq!P3(9OL<00&N*8J%CW_?(2jaE3_E5$GLDe3 z@!>Ik;f!B)<<;sPN|RXo)`3S);PLXA*_go*XN{s7=fj*w0KszVSlK;cO_6iMU8gdQ zf-@gFddYlO9?RHf;pU&Q?M1cu7U3XCgR~)g>npDp{=kr$wjHfi zB*Q#?qv^wy%&W7GeddAggZNdaEH=mUl5)rykA0CP^WM}+DqGMhHsOBjkdPs8ppau7 zC7Jbg(TE3=&}3{u4^yzGGh5?di*Ael*k+!@@uKnLx%WK!7wC>CWL8B{^OKo6hF43L zgcR~?G(T1rbW-v)1M|&#e*rxk@PaK!nvh#;IXFf5P7Ht%=-$mtWb^gZlKd z`U*%T@0GJ4!J4wwHu#bs>tCo%_xCfHto+y~nvL0UL6IZw0ctvYWXq&)*(E~8Dw$3e z0-gA&#No*LS#uZ>%>6#^ch?2EOzU@iY{64zCn?{&Fi1xKT1Y$)mUdvGv&JuQL?6FL zl}{d=I2XVmAL8G0y`zc$!o`Fg{;gaY*ml?lnuhCTsYNtIQcLh-1k#V{;n5^LNo&~- zgT(H7q%_WG&~6Nae55o1@7#vEHv|g)It2v{N`%e6;CD}bKaE0V+)rWXg<9*Hz%bon znmaS*wL^Gu8Hi?BqF&my^*ak!+FUSad0Pv8{$Zw^-h+d0^w+~o=w-BtMyi&oYC#f- z_>WU_Q!Zjc-fDiFvWChCjZE&ny$hn+$`7ViQyh>B{k1zsK0O~HYchn}%QrQiDv+PF zf86%}#$2Q~M;}c+EaKzMQZaL-0TNl2SLuKS6*X^c!bW!*CcfyDPV8Uxh1$~<*LDjI zhn-`2S0Tf9^i@^YjLShqR{O#kdc{+>g-*YBwc2XIL{L9zyDfxbC0!%q^~mS_(2$tsk0aw{>?TMlvFcib%%#HwFk+icWdNmA2<2F)$S1i$tM6XO2Dnn-L!Fm4s8JO}JS z<|d2R3IZP7r7G#Sb%CNtjv?4aT|0}dr-4a@AO0$d5+R`)gK%`CnYnZ>hvP}JS-!!U-j`?4NrU-%SAOvx>Yr4!ldW>^B+v*y z#V1ffTyDT%OKz?{^_ZOdtCY}45i|oz*yYGdVBCN>pXj@aRFs%O_#EuIeplFC0`DCmN%F2hCe|i*RqgFto z_*jGcV&P}w6$Ax0NJO&kQVw(rfG-XLFH4)=3qa3t%=>uQaL@8l@}`x5osS{zDqsYFEjd!ub0zBN~{oX8yMV8zOOc=QJ&}o2UCs#Vo)^KZ>eta&=)t9 zufVXPyfUdPa{-)s@Y{=`i-=aUo>4KY%1l1fX2J)PH!4iC{K=<8_A4&9mp!y=fE)GU zIm0&_OFXihliM|HV#FX9E)iK<(7l2-ums4uRx}_jE16FRW_6tK=V8n zlFk*ohUf$dsZUw2D1xzSPWpNgO8$_LSk`f(lgMl26oVggiO^{b5!}aTip1GzbY1Dw z>yAuTzI4G{T4|!<>OU`P5*0r8BMp37Jg}j&F092$Xte7-y&1NW5JND0{7VvTzL96O za9xxOZh142^Q_j1CVr=Rf5eP~h?R(VLy`{6*axFLvDEhk1S85u0%LNwW}K*%sEV0D zekeZrQcm>p7H6NmQByO%TD~vQuZzvwAq>`J0+FbMq>*$FQVnhO-t6?+3d<^F& zI(u#TV(c6zePon;kDC>CD}Gn&#Wde=tuK5uUxSkuf@yaCRSbMb$tg)u(MeB<=iLgw z3J`0To$)06k+dA>j>STKEQ}|4s0S6IuvKW_*JZ>t(d13AW|TL<5#@k96O1LQ3V=Pd z)fGD#<488yjs9bZPK%#Dz;)R=7$;qmOg4*Nnll^76wlE)469P)QbxOI7R;d6ykpmf zRrA;T#k{iDM)>o*o^aJNGSs}RU)I*JXKWWc(=O8IntcJPDO+vmdk~j)GRi;O*w|vT ze=vgca@*-&e{78`ppQ~?LE+on=wB>rdHK(8VhnE4tBc33c?+epOLSuV{WkGNb|RQ9 zBDDR)L8jSrS_&Ex@h_8XFXtP2az5y~(Ea_4k9c7xep_uKl-dxHt!{(rlK2(t?JChm zrmK$)TIQd+&sWO`^;1>pZa-#swwD_aDkdgi4Q}SFNSLE1BoJ zGXrO5&3B^9*A_$uBlodt*4)R#@9fVy*DN7mex zEMPlZF+y=#^d)s|E;=mi^x@$qCSpa{2cI?-kGN!rLmzq&YbqKpZsJMD zIUb|-p`ce%rq1vNOHvigkS4y2^n>b0q*>j`Am7l+fk9B7HUj4Pn8>7Ey_IYaY$HX^ zi~Z3b+wW7q(TSAnTxvM`86G_Z0ZQE2Fy)mR%FLh^lbK!PrF0TRyK4bmCx!P&viBQN zbS~Z80R|v-z4$85lPS-EE-fuhPfw4qh}sSMmgJjqai~$UtdN%T?qfjKOVpheeQ1C_ z4U~+Lm$S^mjx4aetF)qRD_PJws2I{XD$pyYVkVr41rV&-E_M}pPL>8i>6QBUo3?V? z^ri5DUxjj3*m7}s0LSd}eIIBI(t7Z+23y}5oy`yd@OP>j4r9;~6B5`qBU9nf4re2i zM$$4a|BYk4tJ4>bt|6{H?#oV}t1%b0EY&`qOpi~!nbw6EXa8Fv7C33+hWod7p#hwi zI>)7--t&#lF|ZWCd0;(FYx5#w$+%!0N&A@rsiOsoGMYUd&BZD`aDJamPp9Rx@Fmpb zOZA`368*O9$d`4r-B!68FtfV{n2@Sk9`IZQPGe$u^cWt!&EY|u!LIG|`wHf6w5az^nL87L3Erc06}CT5|>XC++vb-n7QZBp=2)PPp4Fp=V;W80(V+bRkxGe z-|lUx8mcl8^+d6bIa_5VufsMHU?e(b4S$zY__k1EyA(G*u2^fcA}(b~4+mcGxuU)> zByGgZpji%Rc~@17u+)O6#dIGSK|L((*-zKb9a+d*unGZ~-DLqL;- z`5plQ#)OpZe(e{Mt!^BG2x`+v71|aVajL~TVH6bj+BFuvHzR-MDwhPAHj9QNoCd_Z zH0wTt`k52Ch=|CBix9_etqx(goa*i$F5C6u9ey*A`*Ov{6#C1Z6-0UN{`C%zapvx2 zRZ_DjcmvkLOFc3+CU&O67qJgWwTl{f+U@QX8jqAM5D+59SEX} z-E0a?lo6F7j_8ypIv*$zW7rBBYc)BkOguc88vGMP)JpFA7wfS3nom@;E<+cxLefzj;xdxWRwTp#-0j z^bd#8dTkf%fd;6L0I+^re5{>r|F%!qz4U-?(f+>inwkC`f?QY5?C1AWhqJ%o%;H}K z0CY?~1B}~8Iox(5t~e}WdCl#4xp$bDHCSZY&ND`G(6HzdQ`oX+E|mT?+Tp7XXsI55 z;>gzXc8iim+w{jFe28J`Vy#}EzzS_|h{jC=_*l*_*-(NHh(Ui#uwyib&Cb1iM_%51 zamxL3BHPt>x{jA=;({w0_F}C!qw# z1(cChjzh>7S<<{BqG%jSYZ~VR1iIp$rO}1`GWH0Ick%(TdS<~`7a~E2Udp&%W4m7B zkm3y_+44K`WFD$R+3z;)TP6&D%JkI&^TTU#Nfe2ODXD}jEiE(fj_%jAH3vtf^P&nx zd_1;~mLS-Eoa5s>E{w_c_$5~*7pGcUX}us)A;h$<%A72nI@483>6KSUr)`R>ALbt4!)2nM;xzL!UmX=Ej>z1GWv(5 zcGLwB(a%?{2yg&J%#cq%K}U4CC$&HC&(mi>ee;{74mi#wi*J?Va>Jt0C@7dSDEk!J z{P@6zF!Fg0N2vAUmq?|ZF7Tgzh$w_Jf&!%3SKOkc()X zW3EP+(gNETV7@n>+SU6~M`#{|P7vYLA%FVqi)S-0{|%Rr2&Hl11bp%J9xsui%$`*O zLf5tF(5TsV@jl-a14wX?S6#0WtGc(3=C4%0C7s;aJvv5afo$xp=DpufLAw2?^hLxo{;Ps2WBW#OYX-0ug&mO3#hAl1IPNQMIGDosY?9IW z&a+|)j(sDu6A*XqVH(OJnN1G~e3iDl5OEoQaR`*v>Yw3_%Mtc=5h_Kn1>8OcfxZ6? z>ez!frmz(9T!4{^T!h1zhxWeq?F zV&ei3b)qnkcxh(yd{y^tPvLfE;QVkQql`37QPl>41BB5**h5v#7}n|{ly*Y=$DJJ0 zrRE8XQ5hD~q9G!5AztKL7rObJ-M?*3I|DtT$Tzo7(AqOKHzy-Q9GBFRCw}`9%NFd; zVj=-KZ!LG7bC|K?L{U;z5? zyzypjqoGGJvQ#}7bB+{)jjfWNj-k_r4V#=m3^X9kDW(p_GXGfkp#+mfZ>;$mUB`{= zQV%{?TOufHE6NxY>5D4i0vDvp+CPNq^()cmq%|9KC|3Ef5i?54P&e~VPK}$&e-~_> z>I$jfB!~*BUNjPcIr*3fNXAW<=9Sj|W;w-ggRw;9DnFtC$55M(+}@Yaiu1Y_4t&o= z(qVVjh!9I(AuVQ^U;%LUr&uFmn(*={yL@y4fZTflLe}T;JzxFk=Qd9eCkgT=cKes& z3jULkT4sBF{u-?{yL=mp?X$!VWGL6~%(We#lJe=^(KvlbR}N$yKD`^D8H&-bW0G@k zO@qN|ch9qKUu^H)BzzJs>3G7WG1o$d@vng2ie=tsIR2Sq>LfspR_wo*d;x#(spJtv zLnXkIsa4vt|2^3v>LDwgbj1VyE%{G>faV1f>up=h``0du9-D94nBEY8@p4o5h{wH#W$%CA zyu&X~{T9cn48ClK@Eq%xZ;1+a9^p)zuOZ6QowP0%&^ZW{q9X?Q;ITDNiy=P-3b}KO zL|PW&R$*BxU_7p3+L2UoYk7D2Wd<5asZobfia>_KRCZjy9L-rLyp|i2XQDFxi6x4} z@`J*x(beYF_sV;sa(a}HA^*%NRiEAv`t0Kiky}HKZ9?bIm_~V>&vc$#c@Z%X%cL*0 zY@gx16?D(j|MFgDmDm*gM~6t^hk860kvKUXHi2Ntqt-w`t39Zo1Z(Y4W4#nM!xaAv zzwP-?PY&WGt#%+GNW}x0DGVu8z2Z*L#Gvq)a3mKs@bmNWoMqeXCF<#;a0v#KPoV1v z1xNvOZYS9^pZMR?NblI!WNHM2BDz{^~q6&>||Y$JwL6cG34b(us%|`dJEM z1zw*ZvyLaLqq490;ev!b3c6m($qh?{A;F7{PkjQ5e{C|}06BA|*DU_!dPbGyeXI@E z>C42M^av1w@x>-2>-;Bk`!oU(yb=+tZ{=zxYym>gr4JO%k|)5H{e_9UC{@5%(6~ZR zKtKvo+yv!57hJ7c^&ykW-Z{lo!B}e~+pc_k)t19^sNRz_oV;{6axc`{5f6XIe#Uq2 z($_>`PJV6&B#wYSK>w(<;N^SbQ%?er7&fK!0<2J4q4btOHEVYztN3=HJ9e>KW=MSj}8S==BV5cxE!sT@cUQFo5u*2(&(Ac$@ z(IGSzR+wz!a~%nM6!~oVpXSX)98q4|o^9dmx^aE;b`FY%(+y9Yks@L2qLAoPzX+uw zWge$;!VwM2$)h`r<%k@(_t*1{nqnB=TT-#quK8p#_Ql1|pe@uj{W6w%o(dS2U!cZU zn^Pgz{z~1&a`sy==y61*R!sK`q1K7-#W3PkG9pjLp{v~}PDgsTJ@E4UBi0Tz*|!zk z%IMMGd@7EZH~9&~tlEAlB^^RGJnGwbZ91(!W4jzPa@c<8kXtmV4WvStE}$a^c=6ve zu*4q^EzkE-B~(2==7+C#fg>{@=BT^s^_^@pO%({1kKh@A~8S zxBwt8|6K;FGrO)-wgLr{b$k4J*x@N3oS3;wn= zK$n6Aa9)D1T@5(`MPBkzkJq5+6Ro;@>DsY_WH7t`0jt#s`l-C%?XYmDQ@ZhbFQ8WV zj7tq6dDrfr4Tk$VK;51G93NZ*5!w&^Msr?PO)P!bCH8?mhQAwTF%Y`I>-2n(D-Z(8Y-!8k zOI3HvKmP9htO(`V232=4gRSpw(%>mJwV+(KD^;oD2|+cs*FZg)xi=OjenMmd3Qm|q zzU%;{uF;!qj{%*IF7~Ge7(_OYp5nF>Z4?UU%SNum7d@-l1r6Q|)S5t5+{jGlB-1lq!%>s!!l^Da0eRWq$;Nlv&swz z5T3#rHFhIgXs4j2=QyGX1dWAmXUy5TiX$a!8*67K8|2&2_8fgItpmShT@BPr{qz|4xGx%4U3i@3&PV$i8J-}4K zzdk{q%?kZvGE}K)=k5ld5|}9*gu*xr-@Ok~@I#&3mvbWV?BTC0!pXw?}&ds{Yl2C_WVf}lvBaKV2sbRWG)_MvD%*7@_NUKhkD`(QKng@d2@vcUsHqZ;MxBJZ~jNqmZ6*h( zC4cq_!D%@awXSq)BkZ%`Jl&~vJlmdNkH95YX$NML$D5hFj-n{LK<^1I`f~{P@Cji) zY0|K&%{{k|ft*k88#0g!vyYGNzj^u>tbJDHt!ZWrxW)NzOpcty)7{UR3}OPL_<@31 zv#})d3!n4h@1#M0`ef(TJx&|8P9RKcy8TE`i3G$E7^TgBx|m1i!WccBMS&UETYKXJY4>aM-R3ujlda`hQ95Inswf3+lxkYf!jfT6WA8K!^XDbviIhWGoN{RyqkDfqvW$%FJ_ju?;s0M zyFWLwZ%;7JSHp`_*8J@qOAAufJnr^xIFznx{-4j5018SZoW*iAE9fHIYsLDq-Yj>q zmb6K$*@haU5GMfM*K54V8wNKk9oG2yzQC45FiQkPKc>l4KoaUy&AK_T=8PU>hbAl8 z#@6+`uWo;47_uD{H<)CmoM7V-cFZYtfy54qLuoKpAtk&HLar4NzuVDz54Z4B4t?5` zGB&zq)f8l4d}agy>*l4Q!=pZEwgW!kO{MdgpHK!D)VYfQ+bA|bE=+r{f6#0%TCWg& zF529r!g)+6d-{hC45Qi}8o{~8je~9Uc`|Cz(XQPS_q?k`9_QtA?+9}?4c1g)cTxn^6eLQ4Sn+x4}CkUbY zXWElf}q1$j@q2X@4FZwO7`?XaBfTP+oJvJcZ>`) z+$`ixU0rUWXw9V&h)2b|O%^Ouo`5TzG<_tx zBL`C13U~eD>&7O*s!Jp2hDz1lWAt8CNPU`v1S6Ez*6W&=Q;w3QEg+-W2Ui>f)RM}_ zu^_Xc6AxRJGaWNC1tViZaQ$nvkilde5H8dH%%F08RF{(zw%;r;&S7Mn8;*ooeB$iL zmwC9Nq@yEpL@qePM%qVr zb0r;c5;vVdGATm)Gt4x737iS_@`bF#)r$0W#U|*Ln(r?oUkJ4xOrEd+hbgIf*AcYmTOiSZnCz&t#rt zXQOATdSz7e9gAFN^WsPr{dfYsSkcV_xyjT6hY;Lys-9YoBi^=l}*5!jr=+0Yy?&RED zY{fTk8NUJg3d3xlWm?FRpVQL)K_X}vQCO`d06zis9#h^O;&p?llYH8}oF7a>-A zu47#aRmN8XJIqkpwn2APf3w|WX9vDaT|qOdt0m7!7))H8TK3w9<}8`9Fy|MN+%zbE z2b3y@_4=WP^R>^@b_6+=L^`zxp!Cwb(rz6;gVruM3K8MpPqh(H7&|i!Ewk3G8D|LO z{wFoCg6klS>>dlY)0vkl*S-f58S=Cd@)WGhN&ff7Z+*Sp#FcQjeA>bk%!X;+@e{~F zL7w_4$JS)m6dqhZXC;go;0iKqi^WH=@yU|%`mM0*8e%TzgKlq-Qb7OK6-5ZB=x)Lr1dOmuVEwi zM>GGXGd`o1{7E=0kCHOQW!>X{bB}i?Fp1?9fyDclF{{dI=U$|le`@}IX`-EImIC}I zeUAg*Co0R?zjTF_&l4l zT)ZhW$JAg?7oy`?jRv;!;7HdBH~`{uUwGS7!1uIx`2K_zP=vryTt;_z$qrYx+@7PD zw;G(Yvzz&k#TxI_%bQF^ zBB5?Z$S@n-Z6<@(h|Ug-K3$tm6>eSl4~x}bDJ7u_=ZO{{u18E)TQM=wDVb_`eZ5_& z={xH3WPgvOJN=t;wBh3CzuGq+y3|A4hPF&Pii`I!-g$n7Irh0(+AP;rEu1abxS0(< zM9;6VP|ScQJ8j)4#jwXmV^&wQG7*!XOjc z<#l_|Ag}T}Q(w*is~8&X5!ixZt9>%owj#YZwPk*%1*ohEF~ZqBV{H%7LpyfP7kq*T ze||dM8qla(#c$n}ht&zEZF%0^ILE|hydEPamo@DfDFDagex>Mqb1!}WeGkOC$1~P%hzJB2r0rc-%N@Qk0z?;pEKYSp z7@_e1Hl*A>x}k#FLZ=4J#x-}Lbl&8aTTIWgOu%uh1$pGzy}pTJZ}W3J>>(4gO^1V@ z-hwd@GWIwf8r_~L$ky@56)HU=1L7M2js$CF@*ab-mZHI1$U)QP=>skF+8N44BJU1o zF}}Amkxz*OWX8=_xfuNtgOsPyGY!y?-4}p)75ufGH857_YH&(FbMMs`R18T=9OX5knZ>v)>ZE zJG(M_vhDl`o5q6kX|tejx*`QR`SL+8Q!|*yj0L<8k1dVC$n}>>97>tZj$r8K8h~@2 z#{&dXW5Z=GS>H-*$b28v@E@8p9)&zG>7%d#Lq*-pc&nM*^Zs5zFiJ$cZ<759dDSg( zWJz&HUoB4I-0UAU@1h+dZg_^Q%xj&E+WD_#5Svi@D^ zb@ybDT#NwHN7O(z+uomReacL@)cNCwLUzsSbB!$DbH7GctE20>)U3n1*u34k2o)znhYy58Pg9jgDf)hjpo3iBexClXyD z(WQSv3sJaeD*!5@crq#NAI(WU7 zd26(fkTb@SFJDme-7|=@xrwQ>0DB}*oe>+|3on{53ghvLXO91!K|BY`=m3D+d^dXx zG{|6ZG4ol89y0^;NfESiMVM-UT2x-yKC^@^}fv0|H{${07<1yLVNrdw*UupKqBA+9dL4wHZ_= zB>LYCAh^pJfe|!I2xrREzVkEaTOVuF-fa2kWrqJwy>zpc-~R87qj9;miCG{LhO2x3 zwg6xzfF$q9S~~ghasmtBXAx-eHh^lJgrxcY;Zf7Bq=RNwPX%SVBZH2K1DD(dhja-IIVOQXl;0sXK+^RPA1 z+>>0K_HY9)2M`w~0Sy(0bRc@^R)Bku^T|-^b60Pw;BeV4b;Pr@f*eLvWH=2|&DYr% zuJc8l0>|B``cZ%nZn7}k(0qJjqOcB`-LqKWncusI_W@7K-k%afc8Ks=?k**Nnwv~e zQKu%25P8creHt5H8%SO@0?(z4&}ehni%M6zuhyG&nW^8kURGy|d9=gw4K?8NX&vBWIs4y*A2eOXVUf!e zyn41hcaXEE)A{ z<9@TZo{;Q0HF}s2!a%v)Glg<|BBOk{;v{`aIB!gnwXd_wv`rDwAx_z2X|N0KU$c9_ z+n|S{R;XGoA7~lxT{+<#9Vc84=4uDit3YS4 zNug4;8f&M~9(G9c0rCtSu6U^Y#OHTaXS+@0-n%cc02#QcTGdEFmVPEcc*cHhUSs6~ z<=@vMJc8u+7en?boL7>-tP;6&5Y;1u?JxSwl&o|oSXRx|8Y_&j@BuddXUx zA*cO%ck}EKGgwusGq6}KYs#XVDVLCbAbgwQc?#fPf7Zq$byWl6nVnp^W)Rc1idT|PMH{F6gS}_R z>BRp?_VXxaWYH1|EDE#0X?xpo%=(>vU1Wqz%Kd@QDJlvp5&F-3uDm^Z8JfYNgM|vF zCl`zm%2D(bl_@q)z)qDq1tkqy9 z=*T)zZ8u+{{QbNuH{WKllGMRn(XY-BnWgRGher8Ku9k5chxBPRJ+J4r!2PMt-T9Dx zmN8IB;Bn8@aDwJTYS2fI!T<2cZ+Lcv`PY;Z-Nzh`f3IrS;*`z&>%Nl{uoPd?=&7ky zIyt4@t}Adz%WrHUYarS}WJEr{66@1Fy2vOh0(C%v(}kzlKvbqsAYpLEE!x?9k?n`bsfZIjdbj50Xd6nh)h}aShkYhG*(tphE zX|~u=juMNTID$$vVBa@FfHIQwb#^Q*Mx{H#U;>{?o=x%0?op5-E4I9~DW>1M`} z;GPB%HTCIY-s4Z(y`(1w$-p^8P=$osZmDA*^u_&SP*!^BEEg7Os<=X?S*pH}U-On5 z5}al?foc{#AvYDApe}baHywN&s!5fI=!O$ z24V7jH+}%|^F8BnFUk2tEiDO$)XTOOP(&7^1#joP+kKbVwlBW58F3i+H{}eT-Jif{ zfTu8h=CL@Zbaz`OmGVu(27{la`LfjL(kN83W_=orm<_m39$a=u&w zU*pdBF&E551~Wm`)_wmOqy@7ck_kXk@ZX?Y^(LTW(bwx8@9d658FY`%^pLZpHx+Y`L9LwzraF9&D@XgJM**9e+9)BStS-wt4}rf5@&gC2rX1w zA=gV<0B}3FjI!rz*KEc(8$-PADNt{~(i=rOz)$WQEu*FdNSC3}B-Fr7D%safViYF) z25O~Z?$3@~4mPsf$pOvH|FA7ynA@JRuG6bz_lc9vt?#k;PD{$m3;)bt|B!Hgf3kwq zeNmvpSr!CiAOiPDI5bN#>hjFYYNi+2#QlQn1TK3;E+6`EM>?! zto;XN^GSy)2?i}dsKYwtbJhpC4L^fD%E;I!&;A99+zUWi1_du6{_o2bFvr#_@+8Ym zdZaN(oD{U?{g00=g%R>P0$2smLuEH*EYb`j@WWX zT~VC?UiXHBC`5!yVn0J7|mP<+9Un zJtnlqiRTI`EkF;n_Pu($Ouq3-EgS{RQ&TVissQSSs8 zr}nN?9I0-6uJ2kjEYfEtRmy-RAHftlJAa-oM|0SAhR;{)?Cu5>>~Z$(;YRYxLr(qA z=W5gcY?{r#H(Sj8;3-0I=pFF2$ot>CP!iM)fcqNY{n_aet9>fC-MCJ@sXMY!33 zsLg0r)@JpM7UVz^Df`&U=MT)A#Rox=%+8;xV%W*QA(qd*+`=9OO8EGR-(wS1*3lop z{%)2)fVA3B)#eUN z@6GOe=^k)fCFH~yR6T{C#o37$MAM?Lut9$?_?xdrX1(-LP(=CbDJGdvZx}fJDTcpR zCJ!9RmBnR!9`++E0R4?1dpf{L*u|O{-;)*s_sUxLP14Em21xxIqMOM4P#hjd*(#kt z%*|${p%2PiOow7D8D$6>I%gT4^;)x-uh)_er?<`N*-7977xqbpZbw`6RjeSdeJSen z{cB*35WCFlJy(r05EyIZX9iFScIb;;9-cFv1F}xgxkhKq&=}Gy0RnuYK`@1{IgQ?f z-E)W;30yL2YTB7*5CI7W`GuFZ?dtKv8M@1lCW!727kmKdMi(^#7m7RI^e|-Hh}F3x z$4?&umJ9?I)5^;g0j#30nxAVh7uMseUDxzNG&Q9qo&Z%P<5lTbLaKl<{@OZKq}c!H zFp*8>q0uX|5Dk>N%`^y}6jB`mHl4ZQO4ji8=T~svm!wpbl8hq63^Aqs`pRiTbcaZ_L{I6J<_G#oZ`u!^-h5vkDrIjIwsRVg!`z7bkae~9 zHO?D0DogWuQ~)Lb<8@zJT)nd^hW-jX@|tEzV71a*HQ6lxj`i5gnxp4EUw5>;m#y$) zTDG=KxH3L1?$eR}(m=mJMBm=#ajcDhM`ELyTzc?teN5~7Jzf99_*^=8HQY>Q^Y$<` zUv52D%l28frYM=M5tL6jdUc+}Fj*2D#X)`=hB!-=Xa06dv9Dwag?kOg^M00Ol;q7n zjD|JR#E>r;E*nZKKl6V8$-7!to{WS<{u2)Yc+}lMNTrzyeSTrrhGX7qjyDc2=Zagg z0oU)A){O|YAc`Wob+2inN&jZAs4MEq0?u{##iX{lL%nWUz#0b}=8`jN(926P5zAUe zBu;~uXGdyk)6I9;n3}bdSGV9uNdz|Lg{Qdxd$fXYXkYOD@DGBdrqD*OnCCNE=K9qeNrWlM#%tWk&rn8Ik=$&)#; zs+^oTR~0}k76V0<<8b;}cw&o}i@h`addzLoU`$8b1A0H@bBaiLak%d(!=@ds&DrV;7;=Ra;%BDIga z)!rEDVe4_HWv&zA&%Bo__cI+Brkvl{z0PEOQ4dkDSosqx?ik?E@!>vD_yQ8Z-@xpb`y+pbeArS{v1h!P)Qs$omZ7Vwa8 zV@bB!tqeTe$JWX{SyFIl(cOJMx5q&yCPEu|Xwcdk&ise3a=Pvm@^zzxju|xah3u<8 z#GxFGeXf*Te1nexOD#H#Crd^Xs(AKtF}jXYgv^D-Du^~9FD9EV(Au#FE%*le(&slc znMO7RoVqI4KC%KiGJ^42v)3X1Wtz@bK)GeMv=QM*4)(%rpM)AJD(szN`gj(y;io@@wS?xI(d@gEL$Y@gO9WATiBH%&x}R|bB$&~?*` z4+93ChoNhJP#({1i7)K$Y9`Bc`;}FW_gGI#$>vKB_4;;BtGtTcjJh{$j2NB`t8CuH z<8SvAT$N3M4sTROS$}y$Gzn)j)4l|pD9u4+*I>sWgK%jjd>HOcxhO&#<-M_~zj-U|v zjrJc(|KZDI!j|y&k*H-nn+d97Fr(r{*?@Rl+Gzi{M9k(l`Q1-Bn z=7>dCo6L#!VgH!?_jyn4&-WtGWhh-rSUWFOHq98fp<#_cHBWKo!qAXrWybrPAy1DJUk1&`Pu{3( zqdLYVutzU5+0*h~+1-Tl${ldc>Z7qL0@!We>0dS#7iMQi<=tN1ZP>aeG^|cyC}72$ zB~QR4(4k^s0S5fE%+>g$)Be0|fyL(vCW}B5QI;&6RR@9=vTLhOA^;I510w{2XnpHolmjQYlk>u% z8a_=K-+d*zL8F{aT{2x8-6DogmehT_IU3o=BNp4-8?MVm9<#Q5u$+!2xppl2`p+lJ zOa?EP1j$t&UhJUCv__bgiI-NmORou!P@;th?{%FW_+F9d zo+gq|%)UprAf4}p`KuPo#0e}atvK?`EVq(|b8F^1t=zwoJ=X|Ho?9tH2ithu* zdnvN|HV-!_leR^j!XhFRwM*yvyNX2%N<4VczlFb7zlUk9H_mJ)s(Dc8liir5$J89ta3ga>!w0mc;m zuge1XEj8OAI+{=-E84bf>m3nA#61WnOZ6{1)Ky+4YZRUEKl&o9Vq@Rx;t)cWqjWe( z4Moqgg}j=*uG)u^+$W>^>$hK*)bC)SsT=}vvoF(`5D3~Ly%wBG1>56#EAw$f&y9)4 zX+c#JR!ImOywDf1=V+Rq9y4Iqf4!1kTCopjiBJy;4t~MQMFxdF74q?FmF}STyCmE5 zI~UHeXeQxk=k1aB(br-O4^6*aWYaD|t+z;d#70_7WC)`~4;e!V58+MD)7<2o`XPC%SlP8zQHisD=|;Z(AHRdti(2eMtpne8CFZVJ zx?T7=(mf5{dhn`@p+kK z85|ZuD8WZn?}K7C_TIuqpWv~wbFbW}QtZd4qeE25{ic5&36uLZL>&i)@)2N{mQ?!e zyl#dyahs$R#CMaBQ4U&A^gr;r+2d($Bi<6aT6C$#%_=s@9Sk3@&%!B5~yR6>vC)V(eS zi0?)SxkFqnRTv=5PPdf^Rzr9vld|7)HIzwieslVrWRPriv8GZ5_PM7$j%(2)X-SdWh-g zfc>(TrGk3Po65_ntgumVifxQG;LSH!@#r^?NLuM2-92_^hps?OT`xC`q3i2haJrmE zf04nvSd+QySN&eNwB?FK&OIV(d6jH`N|fGZE~~||*Rycyq#oayz1|Di)|)HqF*^(a zrGkYJ%6Ac`J&}7IjLmiEVGJQSmRu*MoJhwXN@>IF)qSY$^{Axr%893hnHpN_4@cpI z=lo~*Y+L&6!NY>y@Z$~Clgh~sS|Q`k%oUECtJ}kA3&3n9C1l?tq8C;)Ro`G!eg#QV z0#D2DbXQVV4oU`m%fwEQ@Pj>1$z0D;x^2ntfQ2uR3?nF?uDcw;zit~0;$!^IYx8n$ zJay@#Owx?o9OLm;N>Hbr<=PLoRG?wK>=(0op0#-|jJ7k|y2Cw^=|+wD&J~fl)5XBq zm+yb$>-h6r&bW||0T%~N9DFzTQASG}s;LJZ*xK{!4^8JWchYBPeb`{D_y&Z;AHFMG zK5nt^x)Q#v1>(r4E3hGFPaatpussbK988Xgs^%7klUo<`vPS$?MjCbBKXT8v}uX49UFmHuY zPhMejGzGc^!7ZA^{$M&^{A9mNR^F^qKR^FCdXU?=If5QyhXDiUBoBDo@rX^-^w}90 zJmBoRt2+&K%OSpR6qKH^@T6xhj%ez$zfvdNZCJt`cbNLqj7|A8q%E1yV6tk*6ok)A z10{khVoNnOt*Gf8;Hg9)vmhi1&lp20Rt0L-{sR$_Q~y_7(+1uKst`{I=1=+VHG%!< znvhw~tKGU4k<&aYr*$WC7?0lI=Z`nkH~kwb;YB>`FFqYvrY^xm3H)|O_S**XR*_T* z=Dpl2$%pHam(sQRCbWD-PErqAASRp#&=ujzmr`# z8^z~vuf;FgJMTcX-FSH#{xH(<=8V50hAs=H__z@1Xf|Eqk2Ot=i*+^_KbGf7Y^j>k zp8{M;O0FW*jXaSeM>&FrFnU*OLeKAu;Y>=*5xcK@G)RD=)zxc6P7R z9H9%ayZBhS$iN2Ky}?s4JfkpX;rmG;gL^8u0*Y!a_k#}u`Db6H)D2LzN@IV;mPmk9@k@D--UtAj-FiG@?I z9x)%zjE>T~$Ln-ML%U617i4fRVKtLQVz+V;}dmOAc-a%{wogXIQzvi=$xktwxm|e{~`g zuWG`hV*p44=fjRgz6ERmBakZ5vh%z;uV$e&IO@iV#n`puVZy4t9fVvyaJ3ciV|-Nt*QLu zAJ<-I9{r~)irPfKpLV(+wxBZ|2pO2LgK(z_r961|`Wz16K^342l4D}(cr8ytOSYm) zNGIyTDp|k$zwiwJ zpQdFB^7t0Zcdfrb3OdRhn`ae!?jd?xv=Og&jbewlQRR(xg6?I|uUvCUClGLapIaDh z2$om)^iJ;7g89M-+3nf8%;!?-*O3uh^(v;$57_c_JKF6au1A82uB=D*!EWi=Ih}VB znKZOgym!tnshTbjbLG&|Cb=Rrs^!qQsd0!Od2u+MlaTOj;bfDPl&sRle%`%ix=1NS zASNi;L^UjCyc7G^tNM~kiz{wAG>ZS9y|U{O{$S6a&+{CdV1c{^>e#@MOFqkvN6e~E zcVJc}+HbK|KB!7cRpu;QBuXY)r?idT0CwEVBP8=wQKAk2PET!b&ON-En)`<$ET$bVK38 zQ?(QCSAU2`gNTEZ9r7zgVbtsVR&*UTihKq?7Ari&_Doaymv-ErtPeqLe0|;4aIt3VSZ;%2MmG{dp*ODY0gH|H+_MJ*EcZ8bB5zjy zU}?l(Iqk%9cWt;GY`=AcG^~5Q1^M3~eY#%|&K$SJ#veg*b2`FWN2cTLL7cS#6Hv`< zAcZ!vV;NO=KpxsVz_PXO>~j`n)gQ)y75rm_*mA$?DjgFmto`HbZn`)W-YOs9xh2Bf z=p5na!riV%H;m7swDYBR5y_S$Z1pcL}C2MUKSL^U-w z`|6avHo#Z9K&EQemGB^%+sd_qIhwi?6JdoU&jp_O&t_W}@AiP6>~xf`Xp@gyX%D? z44!lEjBDNd-F{rDI+VlGaQaMJt$Mp|*?Y>ual359U|8yajm38UPs0~yhL$R6|4NWX zVVXZlzdkki@TG0S+nLEr1o`4MR)p;zALNd@V>HLw(9!|k-Y#htZ+%J4`RBZGPG!Im zMIa#4oX9~gcAnDzK*46QvJw;$6!$NWLS3C?K!=K-V1|wukH`Y!f#hcX-_0en*^4Bq zgt=m_x;>DyX=wIax(3QgZXJ0wk&-342#AIv5bVzx+c#1s@KR9YX2TEKdH21V%;|@i zSXk)WdBe8IWu(FU)8cQq(O;2Uoti9ZqvkIJy~3P)#jv8~cFxG1lw!Nl{^Ky43?`p} zA;z1(rU+UUa2&0t%&?ezItxD$whIkavr;P*WI+9KI0{G?k$O;>64iyZ;#857&biSPNi zZ%O|_eM^MBacG7Hx>sC=xga6S9rudJT?6k@mH4o=p1_&i$8NW2h$ui%&@@(;0?zJi z3jVw)%Io!XUW5Y0ZFN3kbS{SvdqjK)u-^d4Ajnw!ZUDWzQC?<61a|0)f+7TFCUS51 z16@wbQW(jBq1nRyJObk4AjDQ$HHS{nb>|(_EuV|Hy6f$cBl7~-1X`Z+WdH*;reC+cCTOf z_GS*Y*pd#9ZeQrId*OUGfZSi2E+Iaz@f)u8bbiJd-y(T}zbaU9?&B?oqk3~9CxNyM zM^_ibcO6Vss-|z((6o9IA7@f?p;-K<|Fy*ab7>I2^=D=M-kJnL+YpAoB6cv_Jec73mxZM3%T&D_YQGUB;oBX*U-Ov|owxl;OzAH||)WqxD&uL)>78Yf4oId6?U zOSuzM9+CwGIM#7zG6MXVG7HR*e!H=eT-!+FnS*VJ0- ztYw&_lt;hwd5Zx`aXE!)?%?2)BPE~PGynKYfw;#TSB?$=Sm~U)vLN}R=uc`Be$oY* zTy#JFJqd0u8QF;qQMkY~lp985s==kcW=(d76A+7%P*C2xHsZJL=zM)8pR)uU_)aS# zfi@?zv2mn@*Z0O!soLMEK{|pMHiI22Q!TyO8eN^@172i9I}Hi zQAy{f`(HbIn?=oZ*>tg%7W4wpq<9gwTSYsF16ZYdz@Da%At_3#@ z4egKJOZGksYq=g#JghWAHctVSJ-X>ihIt1@E$vD+oLsNE4r0Z zo<`t1rj#Z?Ui@nTct$a7g0@mak2V^h(3#^lkQyIe@@kqTm-Kuw+T*b^ITVQwh&^}U z`;Yl}IW_#<2Kkd6)o?o-(6%zdC=mk~HZ{TYO$x%xU&T~6Re*ZsS0McZ-@-kMw(kz{N zGcYH!4FR>2gLn6dSt;04Jz|?%VFdX27U%NcoOgPu>J1+w!lMs3Afy;dW6Q#{ z_v*`OLZzp9(|5FJ`+AoOJNxu%ygB%pRZVUi^?)d7y^0nF);2&pec@-TE_FQ;-}>;<{RiH)T;LBiUDF>vd^qebUb_a&3>FjI z3qBNAHGXnTEO~!+6L3557y!LH&2y*o2VMqa&!|I&5t8;6PjSh}zGAxcv>U8TJ*6Ki4kNxv0?z{@1!~7W zBtzb>hPejy%axd?`9R_;bc}2Fta|N-gPT!$7Z^Y^dvpHwTd|>xoCO(ke7Mw&X5CU( zA%=%rUUNg4EPdPIG_3!Gx*`UBn!E%c=pL&oEFVU$y>MAKQDQC_WD9!PP-p0HnoDms z_c4%QJ^c@WfrkHS?%5U>AuOi{#lePP6lICMa%doOwO#E`%Va^H(@^S7ayfX83MTSd zT`p&&tGXfz)5H!t=z+!CGMGzgwwdir>mwl@R#NyD$F4IcM@qryc+(i%7XZZ?+L`UP z>OYRIfYzvV)fWB!Zdd>Y2nZa#bViXS3S3PVZ7%_2@j>D=YSFoIcKqbUw{KsM_MgB_ zR;H9EinNQ!`HhXNhHkMr0g8r1AK--$DcY(T)dQNA!Wa&uJQok!dCR^J+_v`boR;|` zjX}ZOYa|34*g?X=J0D-;5rBwspSvbyrEvVPAHP&n*H9X;Uv*o#g~E=j!Ym?#DnEM< zHbnqs=-1>$xu%lV1EATdwT={%r?`E+Um#fJ&g`itzw?PmULo}^%qE;erNm7nx; zn>k4md0ObS`?OkSazb&HEr(#L5+M-@y54BJ5EpEX{$3bF_jND^S^ zz|@`|^yRMqMk9Jd=R?~G&|s1!rq0d#us;Mow5~fV6qS}Y+8#S^t=cjz0IKq%?C+?~ za|2*w2P-ZZUZTIwDvcTNiSGg4^c-T)qPA(HNwrd zQ{7h7@@@!IKmr|wmRy_p3!Eyc`74Jex>%l07*@Qc^e7b^QZ`)#lBCVGALCq68fOy@ zv3N2 zCM$D~-FvN(J`39NTa3=qz9lQwOnmo1Ss8$-NAAblB&t0M*|=08Qq~1LCT}<5|8lQo z`MS?=!x&8qbuvJK^)Yka!`JDhzwLszds=6o&VsY=I;iZAhSH|&7?G8}H0_}Tz>>(V zq9Qgv5ylFPdKQbGnAKn&m&T6M{%tpYem>%`#p)?;-NjCg?7+tTsY9DVAqX?V==EPv zSe|chbxssRRskmf6kjV-gxiyzAL1%pILYzDn|xllkLZqE<^U_&6|%1wQvlGos9~rd zlUvZy*@b}wf!+MmN3tSD+_yKo-FH%b2mix8Ry}B`v0pO=`%&6XNkGTgMM6(LwjA(m zU@0#;iIh*<5p2&TQfe||3hdRro%M1Wpd9&q8yH%UpD#V8mHGVnQ?PiKx(QgG7j2M8 z)=Uq8-XpzAQ~vIwhpoJzl(U>!H;tkfOhsK0m3fS617?(3`9$nT%FtA_!DM1U7%1U6 zE%C({8!*0$uZgG)pR_(?P|4$dQicWk&UxXpoM7ua)>O1bWI7G3P?$nIXL6L36~s1% zHbbNok{nf}q@{;Egz2H9WxD>+#?U65KPWolr?}*ilk_Iq+S=tpH;?K~{}#%N^@jT= zqgryU`;+*B0XqsQtZsh|0q#~4KqynYwQ9?2O$B&RoRzP6rT5^zg@n9Wna_u-PVY|~ zdV@F>eji^3N+xmRLy{yPs|mFd&;V@|mo5yk=wrIeyxjfBydS2>SvPie4(!z`_~qF1 z-Npl(lDcbYC1#M{x`|+}fwfLaPw}YCezrN%3g#2PEYb94cQi#(;_5MI2?22Ec}=-q zF{eS@x0ah-ilCsXW*PTRVrs9|U}TS2Ty)mpwHMBPV&Zq(JwLBEdgZB=N8K94F_=7H zGx9LnvZz-9$DKlk0%wNX5Dp&n8a9Bq#h;msx(AZkjvv*)o20Z)`4C%Cvj~Kuo7TNi zmgu#gzK%@I2GfCR3?02&8dxb~W|q7C1%3wuXs|(=j828^)Tk=+RINX|IOp=ZwM&*h z%bfo-bv62}U|g1SZ3MtfyN`O$zr){iW@53KpnX1+(4h9{ zCoI#N3KK@*ot@dtEb4mD&fhs4x>a^epi^UoqQy6)Me?Q)H?6a9+y zs&yJ90J%X{S#3fv`YMK4;s()s5nDIdezt zmjf7pA3$*}=rOdyS4#$!ED*A=+#;h_FFDqZ64zj&D}s}l2XPZpsKQQGUip_Wt?@3b zbgkv~hAv6I!avKzonSlt&yJ8`+r^rS?HHkmm_QKFT2%lT7n7~kgE(RSN;2Z@Tko}Y zo_vG{Z@{sWM!a8iX5UI~z2$!;oWZsdfj(|9)drxVG@FE?L*%)QuVMQNa#(+i8J75Ie<=rqf4xn0nDu7nOMjrO3IJ zu^LHpbX)tK>&xMEr%5^2^09tov&pGjuF})?rdWyI@>H>LyNQuppaBQ2_pqC7=o|)Wfm)?7`z5;w0aKMlDE4$!Y0 zr9Lp^^}S@{dTawpsx_bG98BW9j8f?>M7XG_=@PsG5E{7Efg8`69?ai_gDk$3fo)~U z9{P0Oq-X=rDw}i!!eygT*ig)q8LWtl>R0J{-~ zx7VCx%iR$6J^u}cO1;YBx!7tkCVpdU7rj+l``^YiN4(_BibiIKRsm@?aD}B+& z(xzmHw;0HfvC2HrZ4kI)>oC zwgm?gOf__n-uC$6cg#XS_m^k>s(iA@P#8`}F(gi5g!DdL5$Y}V_5ij{utaK8?68Qa z`CnV5p#MD#D5Ag-Tjiqy#t8=nO(b~_-g9WUjUeJ$o1+8A=h=)4m#vWfN_UO%5Wt4d z9Y^S56LBKH9W+?|$OGv+yz!8HNuKy@?M#T}`SYke_ys?6EcHNWI=sDnMhyIZFq7b@ z?jo5UfPxEi;}NJkao$G1hLw zj>tE^n&gCRi5cIklL>p4*QuCob=@K<))&Gxec>=Bf)N;j6S&F;t(-H0q%EKp@p_fj zsRA;&a#crS**8VU-GkZRS&-dvz&a+wpC6K4AHhBru!SnNIrtV2k%1oGmJBI z-i``jcx+I0gT&7qG=%pjLkc?g-rjfKtT%7XWqsJ>({WUoj_L!=N^Kze^$hT|uDA*6 zvAk6$Ez*YNWJj+3=sVHrC%!K-sI_3qAm(&7FtgV5v+X~pc!i}3{53T#t>5YC@UIx` z1mq&1=Vc1v52WmJu2wE>sgzoxG`jt(uOlm2BQ{k7|D9;UG=atEpd|`KI#5x9#1K<% z{rQ84FFDA!W(U!j@b$Z?lwNbQTO3!g+kiCjJ@1~VaJdjhs$V01{<1U2qG+=0Yr&K5 zA1=;u^{%C%MC)_M7h^QtOV%wX>zlb}zS9%G$jHBz3Ee!y=CDKRTS9YpJ8gU&}u+Cpy=svz^DXw8*Y4n-~@kU5+dfHnMBGMLS+80tPP`3 z+rZ=B_c`$>VAk7GvaU(wgmp$bHp&Al7_zU>+vrouow3Fc5;;wH-2uUu6q=Pj3V5P-jxbliPGj?PZ0><+Z7Oi z-i}Q81{J8I4}_E;kReI;o9FoEX2+fl`I1VfK2xo8|L=HH3h-F8{#1F3%M*^ zE5(x#i*GsjK7FouNAKV#dR9|}Nawa}#6EUQk6L4q++78p!n zO}|W4k={PV1$moIo4#geUH#NYHhN)zM<6Y!Y=cj!@`s+ZYhY(P281pGPwnLiUBz&DHyQ2%JWJi z9l2%E8$q8Bm_WeI0K(?iLc7$AqxC%XH{A z@cO0`j?L@0f9Lf-z3fVHrjw0y9fLB@hePnrfkT`dk2XFoj4E%&WH~-6g-aZK$~G1#f9Ed*2D(T9EO$z^ltDC3In5(1!o{s2Z z!y&-5*%TQzeA$ER?!o6QtK*VWdr(5K_S)h0Kp$>^z&1EGhk!wEv*=?Eu)Mv`mUys# zJpf#s*DFboh4>79J%It_IZ7?%@)kKKK8Rrrinia0o&AAd@7W?0mE1^2nWI+@LU);# z3E0}^en?Aii-EB(px?gS|198?w#Nkg_*NaQ|3LT-@Hrc3JnPi~;^XYH+8~>}c9;dF z2_UA$E?d(YuUGMC#J{!6!#MA~fS0f57EW|4^e zo>hwTn8@e_w74Jrk>?PwWJE2mQY@ai3tMBPa}$9D+} zG0b?vzYAGfT3VSl>?8}OKt#gpGLex%DhVdQkr-0+{}=@6zg~+sb!cS;kRWu~L7vQU zg$%cc@|vwb>t7uwE87LxbT?fs{{DcKLsYN%p(DS;zcS`C_u-i&pf}7>O;a3(6po=M ze+G`+{Y5Y2Q;TlO)eyQmQ9g7WEwtL2D%w3jop;lxo1fGxZ%?JDC|CnxIvDhSDA7qq z&>f%vs>cHIS!3}<^-VHMVB>Ldw93m`Om8b>xA1=8-=~Rn)~;x}L9ocZ=hs?rTFr=x zo)@q_1Uq|q%qrcoeRT&E7P8jX!pxPgW6xi{CZg>qW5B-N$_1bcv0RoC8@pgh=X{e1 zO-Aiwv1nxZqqKyQEr7hawZGC76i~Kz`7&z44}y8Kog!*?H}s z!t@_-Tjz-cbuURF-AU!7Mg`#3-U~}7J_kPj$ZqqIsTp$W5Wsk~wNmt0BO*$+`WrSk z-^df*x$~evK~_r3gNVL&wY$csTXnBFhpbyZKtftT3i|B%y?!Y_F~VxsXocZkKJy^u ze`?U`(U}BWYPQeMfRpbaggKrQXPQQNbZSPyTTCbxBwb2R#qSo5eX3L0%q-dBG$;ql zt%5H_If{1pIBcnx4t9U|R_iNGJW@7>DtlHg92>Z4rbd_$?|Z7<{rG5SaO>bOZ_U`W z{TuD|`eL1-#{$je@osppc_v8m$RzFdhp1^JZCka~ELFWQ{vVn5JU=L^_hPvyFEm$2P-HpDG|SRs0!6r)}KyLt&4`GA7Sv|2wrcI$OZf-D$6SPZmJGsAft9g~b<2Qi3>`>YVG zrw=(u|K}P$TzAhmNcn|S$E2(#6tubahB06Z$N8tuT;Bzp6KEEAJjeA7h+%a(HR51l zr;oQuAeCJY?>zaCSwYcsL;U=IX1SUS0L-+vs+oQRQYif=eY7CDhDz__=9A(mF`r`P z-y#2c^U9Q$2pqA}sYNDuT5*rrK(H~#aSc-bC>s4+<{Mw0874y<~;yrv;rBk2^(HM3S!c188teTb_e+X-`8ip4Iw|>|+ee+?&72H5( zw2BpAhA*j%!T-ywmM(6auIcPJb8jKKKVir{PF+?cPW(R38$%pUy@C4ebZ>}!tI`Dj z(toSoN|UW3R~eqeJR0uyCZD3Lvs3*iLJT>JNA$q#9&o^3+;**>CM?wZmI-3dun5G} z>CXl7-lw6vEABAIzj^Py&W%43?sz+;=#40Yr&<-Ga8ym57q5qJ>{tLEMrA1CQsm;3 zl)A2Mbp@i0Z>!}It#Sbo+)H_y!dUe;Ro!MWsf-Z_i;0~wQuhy z{DU_Q7x`EIc|o9Ev-Ut&DbEo@T!!(->uuF;{zlw|C@cWa^5LjQwoW4q%FN9*y1K&? zzR@9S3k#&|`}nUMOXJnZ%Jime!7KoV!PnZZ-7zkcN*W(9U$Z35*=Eg1ii2`kd5}?C zewHF;u0<-zx_@>mMwz0wdLOX%?^8S7F4s!N@(O6-MBoY{^vc1H44h8w1&=7=!|9WK zTI!w-XR$XDEQ4PcJe_?f9Q?Rf%!I#pX#L40JR*x2FpuvZ^q{zbJnG&fDASz7ntSW$ zGGt)q+4E0so%1E9VrRn`M{L0iqG&M1O;JFmAk9Kos7#}qF{3rWD!vsTbD{EIeis3X#mpW&Mf`1*#a~)oNeS$FBsg?%|GcB!&xi7P_-@rD!o$xCW zECu6sy=}>NG4d>sFHsNb96VLmYHnt2D>(`vwv5`;$iEjf?`tUWaOV@c)W93`QB?j& zQponQ>08_KskENp`pz$D%o-bH{!SYa@kywo>nk~adi9E{eGGM0`vp~S%JgO3D(g9- zG-!G#bygev278Q}YO!H1-MWqH{9#r{Q16HU0H+`t`#G<0D{aaLeuEtbt2%MDr!y@y z<>p$mG+MJzdA@ENGmQ=9oII-J6%P@d07mC!9uBg0%JNxe7gA29?Wz+2|8C(Z2z0uaimE$_g+6t zur@6`LONJHp|F^0E_Eb8MbHGBmE&C|g>kz{58GZ5N&PPV%#86|y2ne*P?#Mhs2`&Z zc|4EWmZEDMVe1NRUN=%j!Spexdj0y7OVcWn0ww7uv&&JID=L*qCwcXy9>9VGQMj?> zbjbSf40vKv|D#U-N>C(GWd0;^rP(P-uU~oR0^j9GDA+J<}jnBYkK` z9$;U6$h0G%Q{20RBWhB(;`Dj>h+PSyF=KEcqR6sphaJ^G?obAC-+&)EW}Fd4FF% zIm|HSeKW4}e?P)4J2^pK;A<0vqcmCR`i>nA4Xuj=?bEU`vJH>;)sq*0v-oGXR@mF0 zHM^#QnF^RMShQOq{wv@j7)>k~d_s&FoYe2r`F|vxo zx@iQ061WC-bUgv%?aZsGTrxm3$GRFka?8D)X0 zH6I75$^_YWhk|Tvf>GQ5`W=+3*+b0uwW?wyc>dp4A-k5n?nT(?^u4KmG|R_`j;sF* instTN-?;b$jkUYVc%50&{NfJyqadduTOw`p>Hh)A`i9#8 literal 0 HcmV?d00001 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..83c47a5f13b --- /dev/null +++ b/app/src/main/res/xml/grid_widget_info.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file 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..0b97ff6ff2e --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetDao.kt @@ -0,0 +1,38 @@ +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 observe(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 * FROM grid_widgets") + fun observeAll(): Flow> + + @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..24681ae9648 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntity.kt @@ -0,0 +1,28 @@ +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, +) : WidgetEntity { + + @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..c27d6b2f761 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -983,6 +983,14 @@ A custom component is preventing action data from loading. Unable to create widget. Unable to fetch data for configured entity. + Actions + Add entities + A configurable grid of actions + Edit action + Action grid + Name + Grid widget + Home Assistant widget Toggle Living room 72.9 °F From 8302cd2b3ca3fc223687410cf90be8c3d407b019 Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Sun, 11 Jan 2026 20:11:16 +0100 Subject: [PATCH 2/9] Add grid widget unit tests --- .../widgets/grid/GridGlanceAppWidget.kt | 4 +- .../widgets/grid/GridGlanceAppWidgetTest.kt | 120 ++++++++++++++++++ .../widgets/grid/GridWidgetPressActionTest.kt | 77 +++++++++++ .../widgets/grid/GridWidgetStateTest.kt | 29 +++++ .../grid/GridWidgetStateUpdaterTest.kt | 81 ++++++++++++ .../android/widgets/grid/GridWidgetTest.kt | 55 ++++++++ .../database/widget/GridWidgetEntityTest.kt | 25 ++++ 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidgetTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetPressActionTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateUpdaterTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetTest.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/database/widget/GridWidgetEntityTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt index 6ff0e1f80c7..a0ee19cfec5 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt @@ -1,6 +1,7 @@ package io.homeassistant.companion.android.widgets.grid import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -102,7 +103,8 @@ private fun GlanceModifier.gridWidgetBackground(): GlanceModifier = this .background(GlanceTheme.colors.widgetBackground) @Composable -private fun GridWidgetContent(state: GridWidgetState) { +@VisibleForTesting +internal fun GridWidgetContent(state: GridWidgetState) { when (state) { is LoadingGridState -> LoadingScreen() is GridStateWithData -> GridContentWithData(state) 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..5d6502bd586 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetPressActionTest.kt @@ -0,0 +1,77 @@ +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 parameters = actionParametersOf(ENTITY_ID_KEY to "switch.test") + 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(any(), any(), any()) } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt new file mode 100644 index 00000000000..fa35250debc --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt @@ -0,0 +1,29 @@ +package io.homeassistant.companion.android.widgets.grid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GridWidgetStateTest { + + @Test + fun `Given GridStateWithData with items when created then properties are correct`() { + val item1 = GridButtonData("1", "Label", "mdi:icon", "on", true) + val state = GridStateWithData( + label = "My Grid", + items = listOf(item1), + ) + + assertEquals("My Grid", state.label) + assertEquals(1, state.items.size) + assertEquals(item1, state.items[0]) + } + + @Test + fun `Given GridStateWithData default values when created then defaults are correct`() { + val state = GridStateWithData() + + assertEquals(null, state.label) + assertTrue(state.items.isEmpty()) + } +} 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..1dde8df95d5 --- /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.observe(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.observe(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/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) + } +} From 45e72cd0ed985e0ac33fd5b09aac301eba8d5db3 Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Sun, 11 Jan 2026 22:48:56 +0100 Subject: [PATCH 3/9] Move widget_example_grid.png to nodpi Fixes lint issue. --- .../widget_example_grid.png | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/res/{drawable => drawable-nodpi}/widget_example_grid.png (100%) diff --git a/app/src/main/res/drawable/widget_example_grid.png b/app/src/main/res/drawable-nodpi/widget_example_grid.png similarity index 100% rename from app/src/main/res/drawable/widget_example_grid.png rename to app/src/main/res/drawable-nodpi/widget_example_grid.png From 47bf4e2e80cf5c3b4bb3e3e470247dd9b4e91d4c Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Sun, 11 Jan 2026 22:49:06 +0100 Subject: [PATCH 4/9] Update lint baseline --- app/lint-baseline.xml | 79 ++++++++++++++++++++++++- automotive/lint-baseline.xml | 108 ++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 4 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 84305ccf7b7..c5509a48fce 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -844,7 +844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -855,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2258,6 +2258,13 @@ file="${:automotive*fullDebug*MAIN*sourceProvider*0*resDir*1}/values-v27"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Date: Fri, 16 Jan 2026 01:58:09 +0100 Subject: [PATCH 5/9] Cleanup grid widget Addresses PR comments --- app/lint-baseline.xml | 54 +++-- app/src/main/AndroidManifest.xml | 3 - .../widgets/grid/GridGlanceAppWidget.kt | 115 +++++---- .../android/widgets/grid/GridWidget.kt | 9 + .../android/widgets/grid/GridWidgetActions.kt | 6 +- .../android/widgets/grid/GridWidgetState.kt | 3 +- .../widgets/grid/GridWidgetStateUpdater.kt | 29 +-- .../android/widgets/grid/GridWidgetUtils.kt | 8 +- .../grid/config/GridConfigurationScreen.kt | 219 ++++++++++++------ .../grid/config/GridConfigurationViewModel.kt | 29 ++- .../config/GridWidgetConfigureActivity.kt | 12 +- app/src/main/res/xml/grid_widget_info.xml | 3 +- .../widgets/grid/GridWidgetPressActionTest.kt | 5 +- .../widgets/grid/GridWidgetStateTest.kt | 29 --- .../grid/GridWidgetStateUpdaterTest.kt | 4 +- .../widgets/grid/GridWidgetUtilsTest.kt | 79 +++++++ automotive/lint-baseline.xml | 72 +++--- .../android/database/widget/GridWidgetDao.kt | 5 +- .../database/widget/GridWidgetEntity.kt | 7 +- common/src/main/res/values/strings.xml | 3 - 20 files changed, 445 insertions(+), 249 deletions(-) delete mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetUtilsTest.kt diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index c5509a48fce..03ff356c92a 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -980,17 +980,6 @@ column="12"/> - - - - @@ -2452,7 +2441,29 @@ errorLine2=" ~~~~~~~~~~~~"> + + + + + + + + @@ -2463,7 +2474,18 @@ errorLine2=" ~~~~~~~~~~~~"> + + + + @@ -2474,7 +2496,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2485,7 +2507,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03d919ff66c..877a69e0b97 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -258,9 +258,6 @@ android:exported="false"> - - - Unit = { GridButton( modifier = GlanceModifier.fillMaxSize(), @@ -156,29 +192,19 @@ private fun GridContentWithData(state: GridStateWithData, modifier: GlanceModifi ) } - val scaffoldTopPadding = if (showTitleBar()) 0.dp else 12.dp - - Scaffold( - modifier = modifier - .gridWidgetBackground() - .padding(top = scaffoldTopPadding), - titleBar = if (showTitleBar() && state.label != null) titleBar() else null, - ) { - val gridCells = gridCells() - if (gridCells == 1) { - LazyButtonColumn( - modifier = GlanceModifier.fillMaxSize(), - items = state.items, - itemContentProvider = buttonProvider(), - ) - } else { - LazyButtonGrid( - modifier = GlanceModifier.fillMaxSize(), - gridCells = gridCells(), - items = state.items, - itemContentProvider = buttonProvider(), - ) - } + if (gridCells == 1) { + LazyButtonColumn( + modifier = modifier.fillMaxSize(), + items = state.items, + itemContentProvider = buttonProvider(), + ) + } else { + LazyButtonGrid( + modifier = modifier.fillMaxSize(), + gridCells = gridCells, + items = state.items, + itemContentProvider = buttonProvider(), + ) } } @@ -209,13 +235,12 @@ private enum class GridWidgetLayoutSize(val maxWidth: Dp) { @Composable fun showTitleBar(): Boolean { - return fromLocalSize() == Normal && LocalSize.current.height >= 180.dp + return fromLocalSize() == Normal && LocalSize.current.height >= Small.maxWidth } @Composable fun gridCells(): Int { - val width = LocalSize.current.width - return when (width) { + return when (val width = LocalSize.current.width) { in 0.dp..Small.maxWidth -> ceil(width / 96.dp).toInt() else -> ceil(width / 260.dp).toInt() } @@ -237,7 +262,7 @@ private fun LazyButtonColumn( Box( modifier = GlanceModifier .fillMaxSize() - .padding(bottom = 8.dp), + .padding(bottom = HADimens.SPACE2), ) { itemContentProvider(it) } @@ -266,7 +291,7 @@ private fun LazyButtonGrid( gridCells = GridCells.Fixed(gridCells), horizontalAlignment = horizontalAlignment, ) { - itemsIndexed(items) { index, item -> + itemsIndexed(items, itemId = { index, item -> index.toLong() }) { index, item -> val row = index / gridCells val column = index % gridCells @@ -313,9 +338,9 @@ private fun LazyButtonGrid( @Composable private fun GridButton( label: String, + icon: String, onClick: Action, modifier: GlanceModifier = GlanceModifier, - icon: String? = null, state: String? = null, isActive: Boolean = false, buttonColors: GridButtonColors = GridButtonColors.Default, @@ -324,26 +349,24 @@ private fun GridButton( val isCompact = size == GridWidgetLayoutSize.Small val horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start - val baseModifier = if (!isCompact) modifier.height(64.dp) else modifier - val innerPadding = if (isCompact) 8.dp else 12.dp + 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(16.dp) + .cornerRadius(HARadius.XL) .padding(innerPadding) .background(backgroundColor), verticalAlignment = Alignment.CenterVertically, horizontalAlignment = horizontalAlignment, ) { - icon?.let { - GridButtonIcon(icon, colorFilter = ColorFilter.tint(contentColor)) - } ?: GridButtonIconFallback(label) + GridButtonIcon(icon, colorFilter = ColorFilter.tint(contentColor)) if (!isCompact) { - Spacer(GlanceModifier.width(8.dp)) + Spacer(GlanceModifier.width(HADimens.SPACE2)) Column { Text( text = label, @@ -361,7 +384,7 @@ private fun GridButton( } @Composable -private fun GridButtonIcon(icon: String, modifier: GlanceModifier = GlanceModifier, colorFilter: ColorFilter? = null) { +private fun GridButtonIcon(icon: String, colorFilter: ColorFilter, modifier: GlanceModifier = GlanceModifier) { val iconProvider = rememberMdiIconProvider(icon) Image( modifier = modifier.size(24.dp), @@ -371,13 +394,6 @@ private fun GridButtonIcon(icon: String, modifier: GlanceModifier = GlanceModifi ) } -@Composable -private fun GridButtonIconFallback(name: String, modifier: GlanceModifier = GlanceModifier) { - Box(modifier, contentAlignment = Alignment.Center) { - Text(name.first().toString(), style = HomeAssistantGlanceTypography.titleLarge) - } -} - @Composable private fun rememberMdiIconProvider(name: String): ImageProvider { val context = LocalContext.current @@ -385,7 +401,7 @@ private fun rememberMdiIconProvider(name: String): ImageProvider { val bitmap = CommunityMaterial.getIconByMdiName(name)?.let { IconicsDrawable(context, it).toBitmap() } - bitmap?.let { ImageProvider(bitmap) } ?: ImageProvider(commonR.drawable.ic_priority) + bitmap?.let(::ImageProvider) ?: ImageProvider(commonR.drawable.ic_priority) } } @@ -417,3 +433,12 @@ private fun DynamicGridWidgetPreview() { ) } } + +@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 index a4e921d8a49..ae012c33e99 100644 --- 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 @@ -8,6 +8,15 @@ 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() 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 index 26baa675111..8ae8a67d3f9 100644 --- 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 @@ -54,11 +54,11 @@ class RefreshAction : ActionCallback { internal val ENTITY_ID_KEY = ActionParameters.Key("ENTITY_ID_KEY") /** - * Action that will toggle the given Entity through a given parameters with the key [ENTITY_ID_KEY]. Use [actionPressEntity] to get the + * 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 call the server to toggle the item. On Success the widget is updated and the state will be updated - * through the [GridWidgetStateUpdater]. On Failure it will show a toast with the failure message. + * 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. */ 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 index b6e4beb6717..436547656b9 100644 --- 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 @@ -65,8 +65,7 @@ internal sealed interface GridWidgetState { internal object LoadingGridState : GridWidgetState -internal data class GridStateWithData(val label: String? = null, val items: List = emptyList()) : - GridWidgetState +internal data class GridStateWithData(val label: String?, val items: List) : GridWidgetState internal data class GridButtonData( val id: String, 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 index 51db1579c10..79fb5a813ea 100644 --- 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 @@ -12,6 +12,7 @@ import javax.inject.Inject 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 @@ -23,7 +24,9 @@ internal class GridWidgetStateUpdater @Inject constructor( private val serverManager: ServerManager, @param:ApplicationContext private val context: Context, ) { - fun stateFlow(widgetId: Int): Flow = gridWidgetDao.observe(widgetId).withEntityState() + fun stateFlow(widgetId: Int): Flow = gridWidgetDao.getFlow(widgetId) + .withEntityState() + .distinctUntilChanged() @OptIn(ExperimentalCoroutinesApi::class) private fun Flow.withEntityState(): Flow = transformLatest { widgetEntity -> @@ -35,19 +38,17 @@ internal class GridWidgetStateUpdater @Inject constructor( val entityUpdates = getAndObserveEntities(widgetEntity.serverId, entityIds) entityUpdates?.collect { updatedEntity -> - currentUiState = widgetEntity.toUiState().copy( - items = currentUiState.items.map { item -> - if (updatedEntity.entityId == item.id) { - item.copy( - state = updatedEntity.friendlyState(context, appendUnitOfMeasurement = true), - isActive = updatedEntity.isActive(), - ) - } else { - item - } - }, - ) - emit(currentUiState) + 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) + emit(currentUiState) + } } } 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 index 0c07e342c4e..c203d6beca6 100644 --- 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 @@ -4,27 +4,27 @@ 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 -fun GridConfiguration.asDbEntity(widgetId: Int) = GridWidgetEntity( +internal fun GridConfiguration.asDbEntity(widgetId: Int) = GridWidgetEntity( id = widgetId, serverId = serverId, label = label, items = items.map { it.asDbEntity(widgetId) }, ) -fun GridItem.asDbEntity(widgetId: Int) = GridWidgetEntity.Item( +internal fun GridItem.asDbEntity(widgetId: Int) = GridWidgetEntity.Item( gridId = widgetId, entityId = entityId, label = label, iconName = icon, ) -fun GridWidgetEntity.asGridConfiguration() = GridConfiguration( +internal fun GridWidgetEntity.asGridConfiguration() = GridConfiguration( serverId = serverId, label = label, items = items.map(GridWidgetEntity.Item::asGridItem), ) -fun GridWidgetEntity.Item.asGridItem() = GridItem( +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/GridConfigurationScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/config/GridConfigurationScreen.kt index 00ba80a67c5..51f71272d28 100644 --- 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 @@ -7,8 +7,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +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.Icon @@ -34,7 +35,9 @@ import com.mikepenz.iconics.compose.IconicsPainter import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.R 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 @@ -46,23 +49,25 @@ 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 sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @Composable -fun GridWidgetConfigurationScreen(viewModel: GridConfigurationViewModel, onAddWidget: (GridConfiguration) -> Unit) { +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, - selectedServerId = config.serverId, onServerSelected = viewModel::setServer, entities = entities, - onConfigure = onAddWidget, + onSubmit = onSubmit, config = config, onNameChange = viewModel::setLabel, onItemAdd = viewModel::addItem, onItemEdit = viewModel::editItem, + onItemMove = viewModel::moveItem, onItemDelete = viewModel::deleteItem, ) } @@ -70,15 +75,15 @@ fun GridWidgetConfigurationScreen(viewModel: GridConfigurationViewModel, onAddWi @Composable private fun GridWidgetConfigurationContent( servers: List, - selectedServerId: Int, onServerSelected: (Int) -> Unit, entities: List, config: GridConfiguration, onNameChange: (String) -> Unit, onItemAdd: (Entity) -> Unit, onItemEdit: (Int, Entity) -> Unit, + onItemMove: (GridItem, GridItem) -> Unit, onItemDelete: (Int) -> Unit, - onConfigure: (config: GridConfiguration) -> Unit, + onSubmit: (config: GridConfiguration) -> Unit, ) { Scaffold( topBar = { @@ -90,80 +95,151 @@ private fun GridWidgetConfigurationContent( ) }, ) { padding -> - Column( + GridWidgetConfigurationContent( modifier = Modifier .windowInsetsPadding(safeBottomWindowInsets()) .padding(padding), - ) { - 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 - }, - ) + config = config, + onNameChange = onNameChange, + servers = servers, + onServerSelected = onServerSelected, + onItemMove = onItemMove, + entities = entities, + onItemEdit = onItemEdit, + onItemDelete = onItemDelete, + onItemAdd = onItemAdd, + onSubmit = onSubmit, + ) + } +} - if (servers.size > 1) { - ServerExposedDropdownMenu( - servers = servers, - current = selectedServerId, - onSelected = { onServerSelected(it) }, - ) - } +@Composable +private fun GridWidgetConfigurationContent( + config: GridConfiguration, + onNameChange: (String) -> Unit, + servers: List, + onServerSelected: (Int) -> Unit, + onItemMove: (GridItem, GridItem) -> Unit, + entities: List, + 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, + ) + } - PreferenceSection( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .weight(1f), - title = stringResource(commonR.string.widget_grid_add_entities), + 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: List, + 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(config.items) { index, item -> + ReorderableItem( + state = reorderState, + key = item, ) { - config.items.forEachIndexed { i, item -> - val iconPainter = remember(item.icon) { - CommunityMaterial.getIconByMdiName(item.icon)?.let { - IconicsPainter(it) - } + val iconPainter = remember(item.icon) { + CommunityMaterial.getIconByMdiName(item.icon)?.let { + IconicsPainter(it) } - EntityEditorRow( - entityId = item.entityId, - entities = entities, - icon = iconPainter?.let { { Icon(it, null) } }, - onSelect = { selected -> - val entity = entities.first { it.entityId == selected } - onItemEdit(i, entity) - true - }, - onDelete = { onItemDelete(i) }, - ) } - EntityEditorRow( + entityId = item.entityId, entities = entities, - icon = { - Icon(painterResource(R.drawable.ic_plus), null) - }, + icon = iconPainter?.let { { Icon(it, null) } }, onSelect = { selected -> - val entity = entities.first { it.entityId == selected } - onItemAdd(entity) - false + FailFast.failOnCatch( + { "Selected entity not found: $selected" }, + Unit, + ) { + val entity = entities.first { it.entityId == selected } + onItemEdit(index, entity) + } + true }, - onDelete = {}, + onDelete = { onItemDelete(index) }, ) } - - Button( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - onClick = { onConfigure(config) }, - ) { - Text(stringResource(commonR.string.update_widget)) - } + } + item { + EntityEditorRow( + entities = entities, + icon = { + Icon(painterResource(R.drawable.ic_plus), null) + }, + onSelect = { selected -> + FailFast.failOnCatch( + { "Selected entity not found: $selected" }, + Unit, + ) { + val entity = entities.first { it.entityId == selected } + onItemAdd(entity) + } + false + }, + onDelete = {}, + ) } } } @@ -202,8 +278,8 @@ private fun PreferenceSection( ) { Column(modifier) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(HADimens.SPACE4), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), ) { title?.let { SectionHeader(title) } content() @@ -216,7 +292,7 @@ private fun PreferenceSection( @Composable private fun SectionHeader(title: String, modifier: Modifier = Modifier) { Text( - modifier = modifier.padding(start = 48.dp), + modifier = modifier.padding(start = HADimens.SPACE12), text = title, style = MaterialTheme.typography.subtitle2, color = MaterialTheme.colors.primary, @@ -229,6 +305,7 @@ private fun GridConfigurationPreview() { HomeAssistantAppTheme { GridWidgetConfigurationContent( config = GridConfiguration( + serverId = 0, label = "Home lights", items = listOf( GridItem("Bedroom", "mdi:lightbulb"), @@ -240,7 +317,6 @@ private fun GridConfigurationPreview() { previewServer1, previewServer2, ), - selectedServerId = 0, onServerSelected = {}, entities = listOf( previewEntity1, @@ -249,8 +325,9 @@ private fun GridConfigurationPreview() { onNameChange = {}, onItemAdd = {}, onItemEdit = { _, _ -> }, + onItemMove = { _, _ -> }, onItemDelete = {}, - onConfigure = {}, + 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 index 5527e48a4e8..dc3e173c153 100644 --- 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 @@ -97,28 +97,43 @@ class GridConfigurationViewModel @Inject constructor( fun addItem(entity: Entity) = _gridConfig.update { it.copy(items = it.items + entity.asGridItem()) } - fun editItem(i: Int, entity: Entity) = _gridConfig.update { - it.copy(items = it.items.mapIndexed { index, it -> if (index == i) entity.asGridItem() else it }) + fun editItem(selectedIndex: Int, entity: Entity) = _gridConfig.update { + it.copy(items = it.items.mapIndexed { index, it -> if (index == selectedIndex) entity.asGridItem() else it }) } - fun deleteItem(i: Int) = _gridConfig.update { - it.copy(items = it.items.filterIndexed { index, _ -> index != i }) + 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) { + suspend fun updateWidgetConfiguration(config: GridConfiguration): Boolean { if (!isValidConfig(config)) { Timber.d("Widget data is invalid") - error("Widget data is invalid") + return false } if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { Timber.w("Widget ID is invalid") - error("Widget ID is invalid") + return false } Timber.d("Updating widget $widgetId with $config") val entity = config.asDbEntity(widgetId) gridWidgetDao.add(entity) + return true } /** 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 index 5ae9a663ec6..d3bfb4bd704 100644 --- 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 @@ -6,7 +6,6 @@ import android.os.Build import android.os.Bundle import android.widget.Toast import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope @@ -22,7 +21,6 @@ class GridWidgetConfigureActivity : BaseActivity() { private val viewModel: GridConfigurationViewModel by viewModels() public override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) // Set the result to CANCELED. This will cause the widget host to cancel @@ -39,14 +37,14 @@ class GridWidgetConfigureActivity : BaseActivity() { HomeAssistantAppTheme { GridWidgetConfigurationScreen( viewModel = viewModel, - onAddWidget = { onSetupWidget(it) }, + onSubmit = ::onSubmit, ) } } } @SuppressLint("ObsoleteSdkInt") - private fun onSetupWidget(config: GridConfiguration) { + private fun onSubmit(config: GridConfiguration) { lifecycleScope.launch { if (intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true) { if ( @@ -74,12 +72,12 @@ class GridWidgetConfigureActivity : BaseActivity() { } private suspend fun onUpdateWidget(config: GridConfiguration) { - try { - viewModel.updateWidgetConfiguration(config) + val success = viewModel.updateWidgetConfiguration(config) + if (success) { setResult(RESULT_OK) viewModel.updateWidget(this@GridWidgetConfigureActivity) finish() - } catch (_: IllegalStateException) { + } else { showUpdateWidgetError() } } diff --git a/app/src/main/res/xml/grid_widget_info.xml b/app/src/main/res/xml/grid_widget_info.xml index 83c47a5f13b..fb0bfbb51e0 100644 --- a/app/src/main/res/xml/grid_widget_info.xml +++ b/app/src/main/res/xml/grid_widget_info.xml @@ -13,5 +13,4 @@ android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:widgetFeatures="reconfigurable" - tools:ignore="UnusedAttribute"> - \ No newline at end of file + tools:ignore="UnusedAttribute,UnusedResources" /> 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 index 5d6502bd586..4ad36a7d5c8 100644 --- 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 @@ -51,7 +51,8 @@ class GridWidgetPressActionTest { fun `Given a widgetID when present in DAO and invoking onAction then call integration repository`() { val action = spyk() val glanceManager = mockk() - val parameters = actionParametersOf(ENTITY_ID_KEY to "switch.test") + 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()) @@ -72,6 +73,6 @@ class GridWidgetPressActionTest { } assertEquals("Invalid Glance ID", exception.message) - coVerify { integrationRepository.callAction(any(), any(), any()) } + coVerify { integrationRepository.callAction("switch", "toggle", mapOf("entity_id" to entityId)) } } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt deleted file mode 100644 index fa35250debc..00000000000 --- a/app/src/test/kotlin/io/homeassistant/companion/android/widgets/grid/GridWidgetStateTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.homeassistant.companion.android.widgets.grid - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class GridWidgetStateTest { - - @Test - fun `Given GridStateWithData with items when created then properties are correct`() { - val item1 = GridButtonData("1", "Label", "mdi:icon", "on", true) - val state = GridStateWithData( - label = "My Grid", - items = listOf(item1), - ) - - assertEquals("My Grid", state.label) - assertEquals(1, state.items.size) - assertEquals(item1, state.items[0]) - } - - @Test - fun `Given GridStateWithData default values when created then defaults are correct`() { - val state = GridStateWithData() - - assertEquals(null, state.label) - assertTrue(state.items.isEmpty()) - } -} 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 index 1dde8df95d5..0ac0e7ee0ff 100644 --- 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 @@ -32,7 +32,7 @@ class GridWidgetStateUpdaterTest { val item1 = GridWidgetEntity.Item(1, "switch.test", "Switch", "mdi:switch") val entity = GridWidgetEntity(widgetId, serverId, "Label", listOf(item1)) - coEvery { dao.observe(widgetId) } returns flowOf(entity) + 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() @@ -54,7 +54,7 @@ class GridWidgetStateUpdaterTest { val item1 = GridWidgetEntity.Item(1, "switch.test", "Switch", "mdi:switch") val entity = GridWidgetEntity(widgetId, serverId, "Label", listOf(item1)) - coEvery { dao.observe(widgetId) } returns flowOf(entity) + coEvery { dao.getFlow(widgetId) } returns flowOf(entity) coEvery { serverManager.integrationRepository(serverId) } returns integrationRepository coEvery { integrationRepository.getEntity("switch.test") } returns null 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/lint-baseline.xml b/automotive/lint-baseline.xml index e3dda7d4140..b2700f77a77 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -2595,28 +2595,6 @@ column="12"/> - - - - - - - - - - - - @@ -4085,7 +4056,40 @@ errorLine2=" ~~~~~~~~~~~~"> + + + + + + + + + + + + @@ -4096,7 +4100,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -4107,7 +4111,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -4118,7 +4122,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> 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 index 0b97ff6ff2e..e031966194f 100644 --- 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 @@ -16,7 +16,7 @@ interface GridWidgetDao : WidgetDao { suspend fun get(id: Int): GridWidgetEntity? @Query("SELECT * FROM grid_widgets WHERE id = :id") - fun observe(id: Int): Flow + fun getFlow(id: Int): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun add(entity: GridWidgetEntity) @@ -30,9 +30,6 @@ interface GridWidgetDao : WidgetDao { @Query("SELECT * FROM grid_widgets") suspend fun getAll(): List - @Query("SELECT * FROM grid_widgets") - fun observeAll(): Flow> - @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 index 24681ae9648..a56b4a71a0e 100644 --- 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 @@ -18,7 +18,12 @@ data class GridWidgetEntity( val label: String?, @ColumnInfo(name = "items") val items: List, -) : WidgetEntity { + @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) : diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index c27d6b2f761..28bada3d64e 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -983,14 +983,11 @@ A custom component is preventing action data from loading. Unable to create widget. Unable to fetch data for configured entity. - Actions Add entities A configurable grid of actions - Edit action Action grid Name Grid widget - Home Assistant widget Toggle Living room 72.9 °F From a17336e9ba129368f50e0f309eef496883dd411a Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Mon, 19 Jan 2026 00:19:20 +0100 Subject: [PATCH 6/9] Make grid widget entities reorderable --- .../grid/config/GridConfigurationScreen.kt | 45 ++++++++++++------- common/src/main/res/values/strings.xml | 1 + 2 files changed, 29 insertions(+), 17 deletions(-) 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 index 51f71272d28..eef4b6ef4fd 100644 --- 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 @@ -6,13 +6,14 @@ 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.Icon +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text @@ -25,15 +26,14 @@ 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.painterResource 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.IconicsPainter +import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial -import io.homeassistant.companion.android.R 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 @@ -42,7 +42,6 @@ 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.icondialog.getIconByMdiName import io.homeassistant.companion.android.util.previewEntity1 import io.homeassistant.companion.android.util.previewEntity2 import io.homeassistant.companion.android.util.previewServer1 @@ -194,20 +193,17 @@ private fun EntityList( state = lazyListState, verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), ) { - itemsIndexed(config.items) { index, item -> + itemsIndexed( + items = config.items, + key = { _, item -> item }, + ) { index, item -> ReorderableItem( state = reorderState, key = item, ) { - val iconPainter = remember(item.icon) { - CommunityMaterial.getIconByMdiName(item.icon)?.let { - IconicsPainter(it) - } - } EntityEditorRow( entityId = item.entityId, entities = entities, - icon = iconPainter?.let { { Icon(it, null) } }, onSelect = { selected -> FailFast.failOnCatch( { "Selected entity not found: $selected" }, @@ -219,15 +215,22 @@ private fun EntityList( 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, - icon = { - Icon(painterResource(R.drawable.ic_plus), null) - }, onSelect = { selected -> FailFast.failOnCatch( { "Selected entity not found: $selected" }, @@ -239,6 +242,14 @@ private fun EntityList( 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), + ) + }, ) } } @@ -251,14 +262,14 @@ private fun EntityEditorRow( onDelete: () -> Unit, modifier: Modifier = Modifier, entityId: String? = null, - icon: (@Composable () -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - icon?.invoke() + leadingContent?.invoke() SingleEntityPicker( modifier = Modifier.weight(1f), label = { Text(stringResource(commonR.string.entity)) }, diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 28bada3d64e..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 From 9b21efafe5f38f4cf926d3c8e7c910ce44eb32c0 Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Mon, 19 Jan 2026 00:28:22 +0100 Subject: [PATCH 7/9] Use immutable lists in grid widget Adds the kotlinx-collections-immutable dependency and use ImmutableList for list arguments in the grid widget configuration screen. --- app/gradle.lockfile | 2 + app/lint-baseline.xml | 92 +------------------ .../widgets/grid/GridGlanceAppWidget.kt | 8 +- .../android/widgets/grid/GridWidgetState.kt | 3 +- .../widgets/grid/GridWidgetStateUpdater.kt | 5 +- .../android/widgets/grid/GridWidgetUtils.kt | 3 +- .../grid/config/GridConfigurationScreen.kt | 20 ++-- .../grid/config/GridConfigurationViewModel.kt | 12 ++- automotive/gradle.lockfile | 2 + automotive/lint-baseline.xml | 92 +------------------ ...ApplicationDependenciesConventionPlugin.kt | 1 + common/lint-baseline.xml | 6 +- gradle/libs.versions.toml | 2 + 13 files changed, 46 insertions(+), 202 deletions(-) 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 03ff356c92a..00c025802b0 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -2423,94 +2423,6 @@ column="21"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2705,7 +2617,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt index bb816816f98..6be3b2be1d0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/grid/GridGlanceAppWidget.kt @@ -69,6 +69,8 @@ import io.homeassistant.companion.android.widgets.grid.GridWidgetState.Companion import io.homeassistant.companion.android.widgets.grid.GridWidgetState.Companion.getColors import io.homeassistant.companion.android.widgets.grid.config.GridWidgetConfigureActivity import kotlin.math.ceil +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * Glance widget for displaying a grid of actions. @@ -250,7 +252,7 @@ private enum class GridWidgetLayoutSize(val maxWidth: Dp) { @Composable private fun LazyButtonColumn( - items: List, + items: ImmutableList, itemContentProvider: @Composable (item: GridButtonData) -> Unit, modifier: GlanceModifier = GlanceModifier, ) { @@ -273,7 +275,7 @@ private fun LazyButtonColumn( @Composable private fun LazyButtonGrid( gridCells: Int, - items: List, + items: ImmutableList, itemContentProvider: @Composable (item: GridButtonData) -> Unit, modifier: GlanceModifier = GlanceModifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, @@ -422,7 +424,7 @@ private fun DynamicGridWidgetPreview() { modifier = GlanceModifier.cornerRadius(32.dp), state = GridStateWithData( label = "Home", - items = listOf( + 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"), 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 index 436547656b9..89f4ae879e9 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -65,7 +66,7 @@ internal sealed interface GridWidgetState { internal object LoadingGridState : GridWidgetState -internal data class GridStateWithData(val label: String?, val items: List) : GridWidgetState +internal data class GridStateWithData(val label: String?, val items: ImmutableList) : GridWidgetState internal data class GridButtonData( val id: String, 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 index 79fb5a813ea..3a333f0b938 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -46,7 +47,7 @@ internal class GridWidgetStateUpdater @Inject constructor( state = updatedEntity.friendlyState(context, appendUnitOfMeasurement = true), isActive = updatedEntity.isActive(), ) - currentUiState = currentUiState.copy(items = items) + currentUiState = currentUiState.copy(items = items.toImmutableList()) emit(currentUiState) } } @@ -70,7 +71,7 @@ internal class GridWidgetStateUpdater @Inject constructor( private fun GridWidgetEntity.toUiState() = GridStateWithData( label = label, - items = items.map(GridWidgetEntity.Item::toUiState), + items = items.map(GridWidgetEntity.Item::toUiState).toImmutableList(), ) private fun GridWidgetEntity.Item.toUiState() = GridButtonData( 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 index c203d6beca6..c3616cfd70a 100644 --- 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 @@ -3,6 +3,7 @@ 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, @@ -21,7 +22,7 @@ internal fun GridItem.asDbEntity(widgetId: Int) = GridWidgetEntity.Item( internal fun GridWidgetEntity.asGridConfiguration() = GridConfiguration( serverId = serverId, label = label, - items = items.map(GridWidgetEntity.Item::asGridItem), + items = items.map(GridWidgetEntity.Item::asGridItem).toImmutableList(), ) private fun GridWidgetEntity.Item.asGridItem() = GridItem( 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 index eef4b6ef4fd..0c2a449db22 100644 --- 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 @@ -48,6 +48,8 @@ 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 @@ -73,9 +75,9 @@ fun GridWidgetConfigurationScreen(viewModel: GridConfigurationViewModel, onSubmi @Composable private fun GridWidgetConfigurationContent( - servers: List, + servers: ImmutableList, onServerSelected: (Int) -> Unit, - entities: List, + entities: ImmutableList, config: GridConfiguration, onNameChange: (String) -> Unit, onItemAdd: (Entity) -> Unit, @@ -116,10 +118,10 @@ private fun GridWidgetConfigurationContent( private fun GridWidgetConfigurationContent( config: GridConfiguration, onNameChange: (String) -> Unit, - servers: List, + servers: ImmutableList, onServerSelected: (Int) -> Unit, onItemMove: (GridItem, GridItem) -> Unit, - entities: List, + entities: ImmutableList, onItemEdit: (Int, Entity) -> Unit, onItemDelete: (Int) -> Unit, onItemAdd: (Entity) -> Unit, @@ -178,7 +180,7 @@ private fun GridWidgetConfigurationContent( private fun EntityList( onItemMove: (GridItem, GridItem) -> Unit, config: GridConfiguration, - entities: List, + entities: ImmutableList, onItemEdit: (Int, Entity) -> Unit, onItemDelete: (Int) -> Unit, onItemAdd: (Entity) -> Unit, @@ -257,7 +259,7 @@ private fun EntityList( @Composable private fun EntityEditorRow( - entities: List, + entities: ImmutableList, onSelect: (String) -> Boolean, onDelete: () -> Unit, modifier: Modifier = Modifier, @@ -318,18 +320,18 @@ private fun GridConfigurationPreview() { config = GridConfiguration( serverId = 0, label = "Home lights", - items = listOf( + items = persistentListOf( GridItem("Bedroom", "mdi:lightbulb"), GridItem("Bedroom", "mdi:lightbulb"), GridItem("Living room", "mdi:lightbulb"), ), ), - servers = listOf( + servers = persistentListOf( previewServer1, previewServer2, ), onServerSelected = {}, - entities = listOf( + entities = persistentListOf( previewEntity1, previewEntity2, ), 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 index dc3e173c153..7d05878ebe1 100644 --- 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 @@ -22,6 +22,9 @@ 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 @@ -48,12 +51,14 @@ class GridConfigurationViewModel @Inject constructor( ) : ViewModel() { private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID val servers = serverManager.defaultServersFlow + .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 + val entities: StateFlow> = gridConfig .map { it.serverId } .distinctUntilChanged() .filterNotNull() @@ -62,11 +67,12 @@ class GridConfigurationViewModel @Inject constructor( serverManager.integrationRepository(serverId) .getEntities() .orEmpty() + .toImmutableList() } else { Timber.w("No server registered") - emptyList() + persistentListOf() } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), emptyList()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), persistentListOf()) fun onSetup(widgetId: Int) { if (this.widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { 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 b2700f77a77..ad8f6cf1277 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -4038,94 +4038,6 @@ column="21"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4320,7 +4232,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt index 6b4febbbd11..d4d9bcf4291 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt @@ -36,6 +36,7 @@ class AndroidApplicationDependenciesConventionPlugin : Plugin { "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/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" } From 07f0662aa198517d48fde833fc6938c81ca2b967 Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Mon, 19 Jan 2026 00:41:35 +0100 Subject: [PATCH 8/9] Small grid widget config optimization Check if server is different before doing .copy --- .../grid/config/GridConfigurationViewModel.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 index 7d05878ebe1..7f9c4382723 100644 --- 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 @@ -93,10 +93,14 @@ class GridConfigurationViewModel @Inject constructor( } fun setServer(serverId: Int) = _gridConfig.update { currentConfig -> - currentConfig.copy( - serverId = serverId, - items = if (currentConfig.serverId == serverId) currentConfig.items else emptyList(), - ) + if (currentConfig.serverId != serverId) { + currentConfig.copy( + serverId = serverId, + items = emptyList(), + ) + } else { + currentConfig + } } fun setLabel(label: String) = _gridConfig.update { it.copy(label = label) } From a5f23d73755f00bc10531cc22c442308a9d2efad Mon Sep 17 00:00:00 2001 From: mrdanielps Date: Mon, 19 Jan 2026 01:01:01 +0100 Subject: [PATCH 9/9] Update serverManager call It was renamed when rebasing. --- .../android/widgets/grid/config/GridConfigurationViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7f9c4382723..7313170b035 100644 --- 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 @@ -50,7 +50,7 @@ class GridConfigurationViewModel @Inject constructor( @param:ApplicationContext private val applicationContext: Context, ) : ViewModel() { private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID - val servers = serverManager.defaultServersFlow + val servers = serverManager.serversFlow .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), persistentListOf())