Migrate configure camera widget to compose#6499
Migrate configure camera widget to compose#6499cwrogers wants to merge 6 commits intohome-assistant:mainfrom
Conversation
| @Composable | ||
| fun HAExposedDropdownMenu( | ||
| label: String, | ||
| keys: List<String>, |
Check failure
Code scanning / Android Lint
Immutable collections should ideally be used in Composables Error
There was a problem hiding this comment.
You can ignore the Immutable collections with an annotation for now.
app/src/main/kotlin/io/homeassistant/companion/android/util/compose/ExposedDropdownMenu.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...in/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt
Fixed
Show fixed
Hide fixed
...n/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt
Fixed
Show fixed
Hide fixed
48a076c to
992bc17
Compare
There was a problem hiding this comment.
Pull request overview
Migrates the Camera widget configuration screen from the legacy XML/View system to Jetpack Compose, while introducing a new Material 3-based exposed dropdown that maps LocalHAColorScheme colors to avoid mismatched Material theming.
Changes:
- Replace
CameraWidgetConfigureActivityUI with a ComposeScaffold-based configuration screen - Introduce
CameraWidgetConfigureViewModelto hold selection state and drive entity/registry loading - Add
HAExposedDropdownMenu(Material 3) and switch existing dropdown usages to it
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| automotive/lint-baseline.xml | Updates lint baseline metadata and adds new baseline entries related to the Compose migration |
| app/lint-baseline.xml | Same as automotive: lint version bump + new baseline entries for new Compose code |
| app/src/main/res/layout/widget_camera_configure.xml | Removes the legacy XML layout now that the screen is Compose-based |
| app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt | New ViewModel that loads entities/registries and persists widget configuration |
| app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt | Refactors the Activity into Compose UI + wires it to the new ViewModel |
| app/src/main/kotlin/io/homeassistant/companion/android/util/compose/ExposedDropdownMenu.kt | Adds M3 HAExposedDropdownMenu and routes server/background dropdowns through it |
|
|
||
| import androidx.annotation.StringRes | ||
| import androidx.compose.foundation.layout.fillMaxWidth | ||
| import androidx.compose.foundation.text.selection.TextSelectionColors |
There was a problem hiding this comment.
There are unused imports here (e.g., TextSelectionColors). These will be flagged by ktlint/IDE and should be removed.
| import androidx.compose.foundation.text.selection.TextSelectionColors |
| import androidx.compose.ui.graphics.Color | ||
| import io.homeassistant.companion.android.common.compose.composable.HATextField |
There was a problem hiding this comment.
These imports appear to be unused (Color, HATextField). Please remove them to keep the file ktlint-clean.
| import androidx.compose.ui.graphics.Color | |
| import io.homeassistant.companion.android.common.compose.composable.HATextField |
| import kotlinx.coroutines.ExperimentalCoroutinesApi | ||
| import kotlinx.coroutines.flow.SharingStarted | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.WhileSubscribed |
There was a problem hiding this comment.
WhileSubscribed is imported but not used (the code uses SharingStarted.WhileSubscribed). Remove the unused import to avoid ktlint failures.
| import kotlinx.coroutines.flow.WhileSubscribed |
| ) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
requestWidgetCreation is a public API on this ViewModel but currently has no KDoc. Similar widget configure ViewModels (e.g., TodoWidgetConfigureViewModel) document the cancellation behavior and what the method waits for; adding that documentation here would help future maintenance.
| /** | |
| * Requests pinning a new camera widget on API 26 and above. | |
| * | |
| * This is a suspending function. It starts the system "pin widget" flow using | |
| * [AppWidgetManager.requestPinAppWidget] and then waits for the next emission from | |
| * [cameraWidgetDao.getWidgetCountFlow]. That emission typically occurs after the user | |
| * completes (or cancels) the pinning dialog and the broadcast receiver handling | |
| * [ACTION_APPWIDGET_CREATED] has persisted the widget using the pending configuration | |
| * from [getPendingDaoEntity]. | |
| * | |
| * Cancellation of the calling coroutine stops waiting for the next widget-count | |
| * emission, but it does not cancel the platform pin-request dialog itself; the user | |
| * may still finish the system flow and a widget may still be created. | |
| * | |
| * Callers must ensure that the widget configuration in this ViewModel is valid | |
| * (for example, a server and entity have been selected) before invoking this | |
| * function, otherwise [getPendingDaoEntity] may throw. | |
| * | |
| * @param ctx The [Context] used to access [AppWidgetManager] and to build the | |
| * callback [PendingIntent] for widget creation. | |
| */ |
| val appWidgetManager = AppWidgetManager.getInstance(ctx) | ||
| val provider = appWidgetManager.getInstalledProviders() | ||
| .first { it.provider.className == CameraWidget::class.java.name } | ||
|
|
There was a problem hiding this comment.
getInstalledProviders().first { ... } will throw if the CameraWidget provider is not found. Prefer using a ComponentName(ctx, CameraWidget::class.java) directly with requestPinAppWidget, or use firstOrNull and handle the missing-provider case with a user-facing error/log.
| ) ?: AppWidgetManager.INVALID_APPWIDGET_ID | ||
|
|
There was a problem hiding this comment.
The previous implementation finished immediately when started without a valid EXTRA_APPWIDGET_ID (unless configuring via launcher). That guard is gone, so this Activity can now be opened in an invalid state and will only error later. Consider restoring the early finish() when widgetId is invalid and this is not a launcher pin flow.
| ) ?: AppWidgetManager.INVALID_APPWIDGET_ID | |
| ) ?: AppWidgetManager.INVALID_APPWIDGET_ID | |
| val fromLauncher = intent.extras?.getBoolean( | |
| ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, | |
| false, | |
| ) ?: false | |
| if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !fromLauncher) { | |
| // Invalid widget ID in a normal configuration flow; finish early to avoid errors later | |
| finish() | |
| return | |
| } |
| private suspend fun onUpdateWidget() { | ||
| try { | ||
| viewModel.updateWidgetConfiguration() | ||
| setResult(RESULT_OK) | ||
| viewModel.updateWidget(this@CameraWidgetConfigureActivity) | ||
| finish() | ||
| } catch (_: Exception) { | ||
| showUpdateWidgetError() | ||
| } |
There was a problem hiding this comment.
Catching Exception here will also catch CancellationException and can break coroutine cancellation. It also drops the exception context (no logging). Prefer rethrowing CancellationException and logging the failure (e.g., with Timber) before showing the toast.
| class CameraWidgetConfigureViewModel @AssistedInject constructor( | ||
| private val cameraWidgetDao: CameraWidgetDao, | ||
| private val serverManager: ServerManager, | ||
| @Assisted preselectedEntityId : String?, |
There was a problem hiding this comment.
The assisted parameter declaration has an extra space before : (preselectedEntityId : String?). This (and similar spacing) will be flagged by ktlint; please format to standard Kotlin style.
| @Assisted preselectedEntityId : String?, | |
| @Assisted preselectedEntityId: String?, |
| entities.collect { entities -> | ||
| selectedEntityMutex.withLock { | ||
| if(selectedEntityId == null) { | ||
| selectedEntityId = entities.firstOrNull()?.entityId | ||
| } |
There was a problem hiding this comment.
Spacing around if/parentheses here (e.g., if(selectedEntityId == null)) does not match Kotlin style and is typically caught by ktlint. Please run ktlintFormat or adjust spacing.
| stringResource(commonR.string.refresh), | ||
| stringResource(commonR.string.widget_tap_action_open) | ||
| ), | ||
| currentIndex = selectedTapAction?.ordinal ?: 0, |
There was a problem hiding this comment.
currentIndex = selectedTapAction?.ordinal assumes the enum ordinal matches the keys list. Since WidgetTapAction has more entries than the 2 options shown here, a stored value like TOGGLE would make currentIndex out of range and crash when indexing keys. Consider mapping explicitly between the displayed options and WidgetTapAction values (and clamping/handling unknown values).
| currentIndex = selectedTapAction?.ordinal ?: 0, | |
| currentIndex = (selectedTapAction?.ordinal ?: 0).coerceIn(0, 1), |
TimoPtr
left a comment
There was a problem hiding this comment.
Thanks for you contributions 🙏🏻 you might need to reduce the scope a bit of the PR since there are some missing piece.
I didn't yet fully review the PR as I think the first comments are enough before making a full review.
|
|
||
| @OptIn(ExperimentalMaterial3Api::class) | ||
| @Composable | ||
| fun HAExposedDropdownMenu( |
There was a problem hiding this comment.
Let's not do that in this PR. We can't replace the ServerExposedDropdown like this because it has an impact everywhere.
It is a nice goal to migrate to M3, but we should make sure to not mix M2 and M3 and today this dropdown is used in a lot of place that are still in M2.
So I would suggest to make a first PR about making a new version of the ServerExposedDropdownMenu close to the other HA* composable like the HAButton. Inspire yourself from the other components (it has tests and integration within the compose catalog and documentation).
Then a second PR for the configure camera widget compose to M3.
There was a problem hiding this comment.
Actually another contributor is also migrating a screen to compose in this PR #6444 he took the approach to first migrate to compose and then do the migration to M3 later.
I feel that both of you are going to miss the Menu for the server and color. If you don't do it I might help you by making the component so you can use it directly.
I think you would learn some stuff by looking at the comments I left in the other PR that could help you on that one.
| @Composable | ||
| fun HAExposedDropdownMenu( | ||
| label: String, | ||
| keys: List<String>, |
There was a problem hiding this comment.
You can ignore the Immutable collections with an annotation for now.
| onExpandedChange = { expanded = !expanded }, | ||
| modifier = modifier, | ||
| ) { | ||
| TextFieldM3( |
There was a problem hiding this comment.
Check the other existing components HATextField for instance.
| }, | ||
| ) | ||
| private fun showUpdateWidgetError() { | ||
| Toast.makeText(applicationContext, commonR.string.widget_update_error, Toast.LENGTH_LONG).show() |
|
|
||
| override val widgetClass: Class<*> = CameraWidget::class.java | ||
| @Composable | ||
| private fun CameraWidgetConfigureScreen(viewModel: CameraWidgetConfigureViewModel, onActionClick: () -> Unit) { |
|
|
||
|
|
||
| @HiltViewModel(assistedFactory = CameraWidgetConfigureViewModel.Factory::class) | ||
| class CameraWidgetConfigureViewModel @AssistedInject constructor( |
There was a problem hiding this comment.
You're going to need to write unit tests for this new ViewModel.
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
|
You can see the discussion around the server picker |
|
Thanks for the feedback. I will start addressing these comments today and will continue following the discord discussion around the server picker to see where that lands us. |
|
Here a proposal #6528 |
|
We are merging #6528 if you want to proceed with it. |
Summary
Checklist
Screenshots
Any other notes
I am relatively new to using Kotlin and Compose so please be thorough in reviewing. I have only contributed a single line change previously.
While migrating the CameraWidgetConfigureActivity to Compose, I noticed the existing ExposedDropdownMenu rendered with purple Material 2 colors when used inside HATheme. To fix this, I created a new HAExposedDropdownMenu alongside it that uses the ExperimentalMaterial3Api and explicitly maps LocalHAColorScheme colors to the text field slots. I have my own doubts on whether or not this was the correct approach.
If there is a preferred or standard approach for migrating to M3 components using the LocalHAColorScheme, please let me know and I will make the adjustments.
It was also noticed that the EntityPicker and HAButton variants don't fillMaxWidth due to another modifier applied to these components with
HASize.MaxButtonWidthas seen in the screenshots