iOS/Sources/App/WebView/ExternalMessageBus/EntityAddToHandler.swift
Bruno Pantaleão Gonçalves 6b2f83e97e
Add "Add to" frontend compatibility for CarPlay, Widgets and Apple Watch (#4273)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
This PR allows adding supported domain entities to CarPlay, Widgets and
Apple watch directly from the entity more info dialog

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-01-29 17:00:17 +01:00

276 lines
10 KiB
Swift

import Foundation
import PromiseKit
@preconcurrency import Shared
import SwiftUI
/// Handles the "Add To" functionality for Home Assistant entities, allowing users to add entities
/// to various iOS platform features and connected devices.
///
/// This class provides two main capabilities:
/// 1. Determining which actions are available for a given entity based on its type and domain
/// 2. Executing the selected action to add the entity to the chosen platform feature
final class EntityAddToHandler {
weak var webViewController: WebViewControllerProtocol?
init(webViewController: WebViewControllerProtocol? = nil) {
self.webViewController = webViewController
}
/// Returns the list of available actions for the specified entity.
///
/// The available actions depend on the entity's domain and current system state.
///
/// - Parameter entityId: The entity ID to get available actions for (e.g., "light.living_room")
/// - Returns: Promise that resolves to a list of actions that can be performed for this entity
func actionsForEntity(entityId: String) -> Promise<[any EntityAddToAction]> {
Promise { seal in
DispatchQueue.global(qos: .userInitiated).async {
var actions: [any EntityAddToAction] = []
// Extract the domain from the entity ID
let domain = Domain(entityId: entityId)
// CarPlay is available on iPhone only (not iPad) for supported domains
#if !targetEnvironment(macCatalyst)
if !Current.isCatalyst, UIDevice.current.userInterfaceIdiom == .phone {
let isCarPlaySupported = domain.map { CarPlaySupportedDomains.all.contains($0) } ?? false
if isCarPlaySupported {
actions.append(CarPlayQuickAccessAction())
}
}
#endif
// Watch is available on iPhone for supported domains
#if os(iOS)
if !Current.isCatalyst {
let isWatchSupported = domain.map { WatchSupportedDomains.all.contains($0) } ?? false
if isWatchSupported {
actions.append(WatchItemAction())
}
}
#endif
// Widgets are available on all platforms
actions.append(CustomWidgetAction())
seal.fulfill(actions)
}
}
}
/// Executes the specified action to add the entity to the chosen platform feature.
///
/// This function performs the appropriate operation based on the action type.
///
/// - Parameters:
/// - action: The action to execute
/// - entityId: The entity ID to add (e.g., "light.living_room")
/// - Returns: Promise that resolves when the action is executed
func execute(action: any EntityAddToAction, entityId: String) -> Promise<Void> {
Promise { seal in
DispatchQueue.main.async { [weak self] in
guard let self else {
seal.reject(EntityAddToError.handlerDeallocated)
return
}
guard let webViewController else {
seal.reject(EntityAddToError.webViewControllerUnavailable)
return
}
let actionType = EntityAddToActionType(rawValue: action.actionType)
switch actionType {
case .carPlayQuickAccess:
addToCarPlayQuickAccess(entityId: entityId, webViewController: webViewController)
seal.fulfill(())
case .watchItem:
addToWatchItems(entityId: entityId, webViewController: webViewController)
seal.fulfill(())
case .customWidget:
openWidgetBuilder(
actionType: actionType,
entityId: entityId,
webViewController: webViewController
)
seal.fulfill(())
case .none:
seal.reject(EntityAddToError.unknownActionType)
}
}
}
}
// MARK: - Private Methods
private func addToCarPlayQuickAccess(entityId: String, webViewController: WebViewControllerProtocol) {
// Navigate to CarPlay configuration screen
Current.Log.info("Adding entity \(entityId) to CarPlay quick access")
let viewModel = CarPlayConfigurationViewModel(prefilledItem: .init(
id: entityId,
serverId: webViewController.server.identifier.rawValue,
type: .entity
))
let carPlaySettingsView = CarPlayConfigurationView(viewModel: viewModel)
webViewController.presentOverlayController(
controller: carPlaySettingsView.embeddedInHostingController(),
animated: true
)
}
private func addToWatchItems(entityId: String, webViewController: WebViewControllerProtocol) {
// Navigate to Watch configuration screen
Current.Log.info("Adding entity \(entityId) to Watch")
let viewModel = WatchConfigurationViewModel(prefilledItem: .init(
id: entityId,
serverId: webViewController.server.identifier.rawValue,
type: .entity
))
let watchSettingsView = WatchConfigurationView(needsNavigationController: true, viewModel: viewModel)
.preferredColorScheme(.dark)
let viewController = watchSettingsView.embeddedInHostingController()
viewController.overrideUserInterfaceStyle = .dark
webViewController.presentOverlayController(controller: viewController, animated: true)
}
private func openWidgetBuilder(
actionType: EntityAddToActionType?,
entityId: String,
webViewController: WebViewControllerProtocol
) {
Current.Log.info("Opening widget selection for entity \(entityId)")
let serverId = webViewController.server.identifier.rawValue
let selectionView = WidgetSelectionView(
entityId: entityId,
serverId: serverId
) { [weak self] selectedWidget in
self?.handleWidgetSelection(
widget: selectedWidget,
entityId: entityId,
serverId: serverId,
webViewController: webViewController
)
}
let hostingController = selectionView.embeddedInHostingController()
// Present as a bottom sheet
if let sheet = hostingController.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
webViewController.presentOverlayController(controller: hostingController, animated: true)
}
private func handleWidgetSelection(
widget: CustomWidget?,
entityId: String,
serverId: String,
webViewController: WebViewControllerProtocol
) {
// Small delay to allow the selection sheet to dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if let widget {
// Add entity to existing widget
self.addEntityToWidget(
widget: widget,
entityId: entityId,
serverId: serverId,
webViewController: webViewController
)
} else {
// Create new widget with the entity pre-filled
self.createNewWidgetWithEntity(
entityId: entityId,
serverId: serverId,
webViewController: webViewController
)
}
}
}
private func addEntityToWidget(
widget: CustomWidget,
entityId: String,
serverId: String,
webViewController: WebViewControllerProtocol
) {
Current.Log.info("Adding entity \(entityId) to widget '\(widget.name)'")
// Create a new MagicItem for the entity
let newItem = MagicItem(
id: entityId,
serverId: serverId,
type: .entity
)
// Create updated widget with the new item
var updatedWidget = widget
updatedWidget.items.append(newItem)
// Save to database
do {
try Current.database().write { db in
try updatedWidget.update(db)
}
// Open the widget creation view to let user see and further customize
let widgetCreationView = WidgetCreationView(widget: updatedWidget) {
// Reload widgets after changes
}
let hostingController = widgetCreationView
.embeddedInHostingController()
webViewController.presentOverlayController(controller: hostingController, animated: true)
} catch {
Current.Log.error("Failed to add entity to widget: \(error.localizedDescription)")
}
}
private func createNewWidgetWithEntity(
entityId: String,
serverId: String,
webViewController: WebViewControllerProtocol
) {
Current.Log.info("Creating new widget with entity \(entityId)")
// Create a new widget with the entity pre-filled
let newItem = MagicItem(
id: entityId,
serverId: serverId,
type: .entity
)
let newWidget = CustomWidget(
id: UUID().uuidString,
name: "",
items: [newItem]
)
let widgetCreationView = WidgetCreationView(widget: newWidget) {
// Reload widgets after changes
}
let hostingController = widgetCreationView
.embeddedInHostingController()
webViewController.presentOverlayController(controller: hostingController, animated: true)
}
}
// MARK: - Error Types
extension EntityAddToError {
static let handlerDeallocated = EntityAddToError.decodingFailed
static let webViewControllerUnavailable = EntityAddToError.decodingFailed
static let unknownActionType = EntityAddToError.invalidPayload
}