Refactor ManageTilesView to Material3#7052
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors the Quick Settings “Manage Tiles” UI to Material 3 and Home Assistant design-system Compose components, aligning the screen with issue #6301 and preparing for a future ViewModel migration by bundling UI inputs into a view-state holder.
Changes:
- Migrated
ManageTilesViewfrom Material2 widgets to Material3 + HA design system components (dropdowns, text fields, switches, buttons, divider, scaffold/snackbar host) - Introduced
ManageTilesViewStateto keep the composable API smaller and decouple UI rendering from the ViewModel shape - Added screenshot tests for the “add tile” and “update tile” variants of the screen
Reviewed changes
Copilot reviewed 3 out of 17 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| app/src/main/kotlin/io/homeassistant/companion/android/settings/qs/views/ManageTilesView.kt | Material3/HA component migration, state holder introduction, and new previews |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/qs/ManageTilesFragment.kt | Switched the fragment Compose theme wrapper to HATheme |
| app/src/screenshotTest/kotlin/io/homeassistant/companion/android/settings/qs/views/ManageTilesViewScreenshotTest.kt | Added screenshot coverage for the refactored composable |
|
UX feedbacks before onto the code First of all it does look way better than today 💪🏻
|
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
|
@TimoPtr 1000004099.mp4Questions.
Did you mean this should apply to all inputs (including labels and dropdowns) or just HATextField? I applied it only to HATextField for now, which makes the layout look a bit unbalanced
This turned out to be a ViewModel issue. While debugging, I found that updating MutableState on an IO thread doesn't trigger recomposition. See also: https://youtrack.jetbrains.com/projects/CMP/issues/CMP-8595. Does serverManager.servers() need to be called on the IO thread? Thanks! |
To all, you can check the onboarding screens for how I've done it in the past.
It is main safe already, but I had other comments on this that might already address this.
|
* Add `ManageTiles multiple servers` preview test and associated state * Relocate reference screenshots to match the `ui` package structure * Update reference images for server discovery and tile management screens
* Add test tags to ManageTiles UI components to support automated testing * Update `LabeledSwitchRow` to allow passing a test tag to the switch component * Implement `ManageTilesTest` to verify UI visibility, state changes, and user interactions
* Extract label and subtitle input fields into `TileLabelContent` * Move entity selection, icon configuration, and switch rows into `TileIconContent` * Improve code organization within `ManageTilesContent`
* Replace the text-based reset button with an icon button using the Restore icon * Refactor the layout to use a Row for icon selection and reset actions * Update tests to identify the reset icon by content description instead of test tag
…ject * Move `ManageTilesState` to `ManageTilesViewModel` and include derived UI logic as computed properties * Replace multiple `MutableState` properties in the ViewModel with a single `MutableStateFlow` * Expose `submitEnabled` as a derived `StateFlow` using `stateIn` * Update the `ManageTiles` composable to collect state using `collectAsStateWithLifecycle` * Implement explicit setter methods in the ViewModel for UI updates and configuration changes * Update tests and previews to reflect the new state structure and parameters
* Replace index-based tile selection with ID-based selection in `ManageTilesViewModel` and the UI layer. * Update `ManageTilesState` to store `selectedTileId` instead of a `TileSlot` object. * Move dropdown item mapping logic for tiles and servers into `ManageTilesState`. * Update `ManageTilesViewModel` and associated tests to support ID-based selection.
* Remove unused imports in `ManageTiles.kt` * Reformat `ManageTilesViewModel` and its state object for better readability * Apply minor formatting fixes in `ManageTilesViewModelTest`
* Simplify `ManageTilesState` by removing redundant UI flags and using `selectedTileId` * Update `ManageTiles` layout to center content horizontally * Update screenshot tests and relocate reference images to match the test class name * Explicitly pass `submitEnabled` as a parameter to the `ManageTiles` composable
| selectedEntityId = state.selectedEntityId, | ||
| onEntitySelectedId = onEntitySelectedId, | ||
| onEntityCleared = onEntityCleared, | ||
| addButtonText = "${stringResource(R.string.tile_entity)}*", |
There was a problem hiding this comment.
| addButtonText = "${stringResource(R.string.tile_entity)}*", | |
| addButtonText = "${stringResource(R.string.tile_entity)}", |
| Row( | ||
| modifier = modifier | ||
| .fillMaxWidth() | ||
| .clip(RoundedCornerShape(HARadius.XL)) |
There was a problem hiding this comment.
Don't clip here since it's for the full row
| @Stable | ||
| internal data class ManageTilesState( | ||
| val selectedTileId: String = "", | ||
| val servers: List<Server> = emptyList(), |
There was a problem hiding this comment.
Let's keep it like this but I think we could drop this line and look at the HaDropdownItem only since the key is the ID of the server.
There was a problem hiding this comment.
I removed servers property
| internal data class ManageTilesState( | ||
| val selectedTileId: String = "", | ||
| val servers: List<Server> = emptyList(), | ||
| val sortedEntities: List<Entity> = emptyList(), |
There was a problem hiding this comment.
Why is it called sorted? It is filtered for sure but not sorted.
There was a problem hiding this comment.
I'm not sure too..I just used the name as is I'll change it
| selectedIcon = icon ?: sortedEntities.firstOrNull { it.entityId == selectedEntityId }?.getIcon(app) | ||
| _state.update { | ||
| val resolvedIcon = | ||
| icon ?: it.sortedEntities.firstOrNull { entity -> entity.entityId == it.selectedEntityId }?.getIcon(app) |
There was a problem hiding this comment.
This probably would benefits from being in Default if the list of entities is huge.
| submitButtonLabel = commonR.string.tile_save | ||
| withContext(Dispatchers.IO) { | ||
| tileDao.add(tileData.copy(id = insertedId.toInt(), added = true)) | ||
| } |
There was a problem hiding this comment.
| } | |
| tileDao.add(tileData.copy(id = insertedId.toInt(), added = true)) |
DAO is main safe
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertNull | ||
| import org.junit.Assert.assertTrue |
There was a problem hiding this comment.
| import org.junit.Assert.assertEquals | |
| import org.junit.Assert.assertNull | |
| import org.junit.Assert.assertTrue | |
| import org.junit.jupiter.api.Assertions.assertEquals | |
| import org.junit.jupiter.api.Assertions.assertTrue | |
| import org.junit.jupiter.api.assertNull |
Let's use jupiter assertions.
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertTrue |
There was a problem hiding this comment.
| import org.junit.Assert.assertEquals | |
| import org.junit.Assert.assertTrue | |
| import org.junit.jupiter.api.Assertions.assertEquals | |
| import org.junit.jupiter.api.Assertions.assertTrue |
| <string name="tile_entity">Select an entity to toggle or call</string> | ||
| <string name="tile_label">Tile label</string> | ||
| <string name="tile_not_setup">Requires setup</string> | ||
| <string name="tile_required_field_hint">* Indicates a required field</string> |
There was a problem hiding this comment.
If you add the indicator here, I would like the indicator in the other strings CC @jpelgrom
There was a problem hiding this comment.
Agreed; see #7052 (comment), I'd add the * indicator to each string.
| viewModelScope.launch { | ||
| val previousEntityId = _state.value.selectedEntityId | ||
| _state.update { it.copy(selectedServerId = serverId) } | ||
| loadEntities(serverId) |
There was a problem hiding this comment.
You could have a race here if you quickly change servers and the first one did not finish to load entities. You could in loadEntities discard any previous Job so calling loadEntities from anywhere would discard any previous call.
There was a problem hiding this comment.
Oh hadn't considered that.
| val showServerSelector = servers.size > 1 || | ||
| servers.none { server -> server.id == selectedServerId } | ||
|
|
||
| val showResetIcon = selectedIconId != null && selectedEntityId.isNotBlank() | ||
|
|
||
| val submitEnabled = tileLabel.isNotBlank() && | ||
| servers.any { it.id == selectedServerId } && | ||
| sortedEntities.any { it.entityId == selectedEntityId } |
There was a problem hiding this comment.
Let's add some small unit tests for this logic
| <string name="enable_change_log_popup_summary">Allow the change log dialog to automatically appear when the app is updated</string> | ||
| <string name="tile_icon">Tile icon</string> | ||
| <string name="tile_reset_icon">Reset icon</string> | ||
| <string name="tile_icon_original">Use entity icon</string> |
There was a problem hiding this comment.
| <string name="tile_icon_original">Use entity icon</string> |
* Wrap `IconDialog` in `HomeAssistantAppTheme` to ensure consistent styling and add a migration TODO * Remove visual indicators (asterisks) from required field labels for server, label, and entity pickers * Remove `@VisibleForTesting` constants and `testTag` modifiers from switch components * Update `TileIconRow` layout to use a `Box` for icon selection and update the reset button content description to "undo" * Center the submit button horizontally in the main column * Update `EntityPicker` to use `state.entities` instead of `state.sortedEntities` * Remove redundant clipping in `LabeledSwitchRow` and clean up preview mock data
* Rename `sortedEntities` to `entities` in `ManageTilesState` * Update `showServerSelector` and `submitEnabled` logic to rely on dropdown items instead of raw `Server` lists * Track entity loading in `ManageTilesViewModel` with a `Job` to ensure only the latest request completes and cancel stale tasks * Offload entity filtering and default icon resolution to `Dispatchers.Default` to avoid blocking the main thread * Add `ManageTilesStateTest` to verify UI state logic including `submitEnabled`, `showResetIcon`, and `showServerSelector`
- Add regression test to `ManageTilesViewModelTest` to ensure fast server switching does not result in stale entity registry data - Migrate assertions from JUnit 4 to JUnit 5 across Quick Settings test files - Remove unused `servers` list property from `ManageTilesState` and clean up `fakeServer` helpers in tests - Update `ManageTilesTest` to find vibrate and auth switches by localized text instead of test tags - Update UI test expectations for the reset icon content description from `tile_reset_icon` to `undo` - Refine tile slot assertion in `ManageTilesViewModelTest` to verify the exact count against resource arrays - Update screenshot test references to reflect UI and state changes
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||
| import com.mikepenz.iconics.compose.Image | ||
| import com.mikepenz.iconics.typeface.IIcon | ||
| import io.homeassistant.companion.android.common.R |
There was a problem hiding this comment.
We usually do
| import io.homeassistant.companion.android.common.R | |
| import io.homeassistant.companion.android.common.R as commonR |
|
I don't want to change the entire architecture here last minute, also because @TimoPtr has some pending changes related to fetching the entity data, but my connection was a bit slower than usual so I noticed that it actually didn't show anything until loading data from the server finished. tiles-loading.mp4Artificially delayed loading entities and yes, it's even longer now. tiles-loading-extra.mp4This is even more problematic when switching tiles as it'll keep showing the previous' tile data until it's done, as can be seen in the second video. Related: #7052 (comment). I think it should load the tile data from the database/system first and update state before waiting for network data, or block content with a spinner. (Why are we even reloading everything when changing tiles, if I'm not changing the server? But that's something we can fix later, same behavior as the previous version.) |
|
@oungsi2000 Also please mark review comments as resolved when you're done with them so we can quickly see what you addressed and if there are any open questions from your end as well. (My last comment isn't a review comment, but most comments on this PR are.) |
Either we manage to merge my changes on the entity before this PR and we need this PR to adjust and address the issue you've mentioned or I'll handle it in a next PR. @oungsi2000 go over the last comments open, @jpelgrom anything you want to see before merging this? |
#7052 (comment) at least, if we agree without addressing the rest we do need to fix the remainder before a production release in my opinion. |
|
Should this PR be merged?? I think it's not over |
I'm working on a second PR that changes the logic, I'll tag you there. If you think it misses something let's open a new PR for the rest 👍🏻, on top of the one I'm going to open in few hours. |

Summary
resolves #6301
Changes
ViewModel is intentionally left untouched. State is extracted into ManageTilesViewState to prepare for a future migration, but refactoring the ViewModel itself felt out of scope for this PR, so it stays as-is.
Checklist
Screenshots