Skip to content

Migrate configure camera widget to compose#6499

Draft
cwrogers wants to merge 6 commits intohome-assistant:mainfrom
cwrogers:feature/configure_camera_widget_compose
Draft

Migrate configure camera widget to compose#6499
cwrogers wants to merge 6 commits intohome-assistant:mainfrom
cwrogers:feature/configure_camera_widget_compose

Conversation

@cwrogers
Copy link
Copy Markdown
Contributor

Summary

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Screenshots

darkmode-open darkmode lightmode-open lightmode

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.MaxButtonWidth as seen in the screenshots

@Composable
fun HAExposedDropdownMenu(
label: String,
keys: List<String>,

Check failure

Code scanning / Android Lint

Immutable collections should ideally be used in Composables Error

The Compose Compiler cannot infer the stability of a parameter if a List is used in it, even if the item type is stable.
You should use Kotlinx Immutable Collections instead: keys: ImmutableList<String> or create an @Immutable wrapper for this class: @Immutable data class KeysList(val items: List<String>)
See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can ignore the Immutable collections with an annotation for now.

@cwrogers cwrogers force-pushed the feature/configure_camera_widget_compose branch from 48a076c to 992bc17 Compare February 25, 2026 22:36
@cwrogers cwrogers marked this pull request as ready for review February 26, 2026 14:40
Copilot AI review requested due to automatic review settings February 26, 2026 14:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CameraWidgetConfigureActivity UI with a Compose Scaffold-based configuration screen
  • Introduce CameraWidgetConfigureViewModel to 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
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unused imports here (e.g., TextSelectionColors). These will be flagged by ktlint/IDE and should be removed.

Suggested change
import androidx.compose.foundation.text.selection.TextSelectionColors

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +34
import androidx.compose.ui.graphics.Color
import io.homeassistant.companion.android.common.compose.composable.HATextField
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These imports appear to be unused (Color, HATextField). Please remove them to keep the file ktlint-clean.

Suggested change
import androidx.compose.ui.graphics.Color
import io.homeassistant.companion.android.common.compose.composable.HATextField

Copilot uses AI. Check for mistakes.
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WhileSubscribed is imported but not used (the code uses SharingStarted.WhileSubscribed). Remove the unused import to avoid ktlint failures.

Suggested change
import kotlinx.coroutines.flow.WhileSubscribed

Copilot uses AI. Check for mistakes.
)
}
}

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/**
* 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.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +217
val appWidgetManager = AppWidgetManager.getInstance(ctx)
val provider = appWidgetManager.getInstalledProviders()
.first { it.provider.className == CameraWidget::class.java.name }

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +87
) ?: AppWidgetManager.INVALID_APPWIDGET_ID

Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
) ?: 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
}

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +133
private suspend fun onUpdateWidget() {
try {
viewModel.updateWidgetConfiguration()
setResult(RESULT_OK)
viewModel.updateWidget(this@CameraWidgetConfigureActivity)
finish()
} catch (_: Exception) {
showUpdateWidgetError()
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
class CameraWidgetConfigureViewModel @AssistedInject constructor(
private val cameraWidgetDao: CameraWidgetDao,
private val serverManager: ServerManager,
@Assisted preselectedEntityId : String?,
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@Assisted preselectedEntityId : String?,
@Assisted preselectedEntityId: String?,

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +149
entities.collect { entities ->
selectedEntityMutex.withLock {
if(selectedEntityId == null) {
selectedEntityId = entities.firstOrNull()?.entityId
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
stringResource(commonR.string.refresh),
stringResource(commonR.string.widget_tap_action_open)
),
currentIndex = selectedTapAction?.ordinal ?: 0,
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
currentIndex = selectedTapAction?.ordinal ?: 0,
currentIndex = (selectedTapAction?.ordinal ?: 0).coerceIn(0, 1),

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can ignore the Immutable collections with an annotation for now.

onExpandedChange = { expanded = !expanded },
modifier = modifier,
) {
TextFieldM3(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the other existing components HATextField for instance.

},
)
private fun showUpdateWidgetError() {
Toast.makeText(applicationContext, commonR.string.widget_update_error, Toast.LENGTH_LONG).show()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use snackbar


override val widgetClass: Class<*> = CameraWidget::class.java
@Composable
private fun CameraWidgetConfigureScreen(viewModel: CameraWidgetConfigureViewModel, onActionClick: () -> Unit) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to a dedicated file like I suggested in #6444.



@HiltViewModel(assistedFactory = CameraWidgetConfigureViewModel.Factory::class)
class CameraWidgetConfigureViewModel @AssistedInject constructor(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're going to need to write unit tests for this new ViewModel.

@home-assistant
Copy link
Copy Markdown

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@home-assistant home-assistant bot marked this pull request as draft February 27, 2026 09:42
@TimoPtr TimoPtr changed the title Feature/configure camera widget compose Migrate configure camera widget to compose Feb 27, 2026
@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Feb 27, 2026

You can see the discussion around the server picker
https://discord.com/channels/330944238910963714/1476923259353305098

@cwrogers
Copy link
Copy Markdown
Contributor Author

cwrogers commented Mar 2, 2026

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.

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 4, 2026

Here a proposal #6528

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 23, 2026

We are merging #6528 if you want to proceed with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rework UI of CameraWidgetConfigureActivity to compose and Material3

4 participants