mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 05:08:23 -05:00
<!-- 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 --> Introduce HAAppUsedContent to centralize the list of Home Assistant domains and provide rawValues. Use HAAppUsedContent.rawValues in AppEntitiesModel to filter entities (removing the local domains list). Update EntityAddToHandler to only add CustomWidgetAction when the entity's Domain can be constructed and is included in HAAppUsedContent.domains. Change MagicItem's default interactionType from a refresh app intent to a navigateIntent that opens the entity deep link (AppConstants.openEntityDeeplinkURL). These changes restrict widget availability to supported domains and make default item interactions navigate directly to the entity. ## 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. -->
295 lines
11 KiB
Swift
295 lines
11 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
|
|
if let domain, HAAppUsedContent.domains.contains(domain) {
|
|
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
|
|
)
|
|
}
|
|
.modify { view in
|
|
if Current.isCatalyst {
|
|
view.toolbar(content: {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
CloseButton {
|
|
webViewController.dismissOverlayController(animated: true, completion: nil)
|
|
}
|
|
}
|
|
})
|
|
} else {
|
|
view
|
|
}
|
|
}
|
|
|
|
let hostingController = selectionView.embeddedInHostingController()
|
|
|
|
if Current.isCatalyst {
|
|
let navigationController = UINavigationController(rootViewController: hostingController)
|
|
webViewController.presentOverlayController(controller: navigationController, animated: true)
|
|
} else {
|
|
// 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
|
|
}
|